From 4e0f863df3ac73e868b754ae2fe5e66a11be3586 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 24 Jun 2026 23:24:01 +0100 Subject: [PATCH] Add a connector declaration snapshot (#29851) ## Why Connector declarations currently enter Codex through broad plugin capability summaries, then MCP setup, turn tooling, and `app/list` each reconstruct the same information. That makes executor-selected connectors difficult to add without coupling connector behavior to the host plugin loader. This PR introduces a small connector-owned value that later stack layers can populate before thread startup. ## What changed - Move the pure app-declaration parser into `codex-connectors`, preserving declaration order and category cleanup while leaving host-side validation and deduplication unchanged. - Add an immutable `ConnectorSnapshot` with ordered connector IDs and plugin display-name provenance. - Adapt the existing local-plugin capability summaries into that snapshot at current consumer boundaries. - Use the snapshot for MCP tool provenance, turn connector inventory, and `app/list`. - Keep the crate API narrow: no test-only snapshot accessors are exposed. The externally visible behavior is unchanged. Connector tools still come from the orchestrator-owned `/ps/mcp` server, and local plugin enablement remains owned by the existing plugin loader. ## Stack scope This is the foundation only. It does not read selected executor packages or change thread startup. #29852 adds the executor-backed declaration reader, and #29856 composes selected declarations into a thread snapshot. --- codex-rs/Cargo.lock | 5 +- .../src/request_processors/apps_processor.rs | 10 +- codex-rs/codex-mcp/Cargo.toml | 3 +- codex-rs/codex-mcp/src/mcp/mod.rs | 24 ++-- codex-rs/codex-mcp/src/mcp/mod_tests.rs | 56 ++++---- codex-rs/connectors/Cargo.toml | 2 + codex-rs/connectors/src/lib.rs | 6 + codex-rs/connectors/src/plugin_config.rs | 50 +++++++ .../connectors/src/plugin_config_tests.rs | 53 +++++++ codex-rs/connectors/src/snapshot.rs | 136 ++++++++++++++++++ codex-rs/connectors/src/snapshot_tests.rs | 47 ++++++ codex-rs/core-plugins/Cargo.toml | 2 +- codex-rs/core-plugins/src/loader.rs | 71 +++------ codex-rs/core/src/config/mod.rs | 5 +- codex-rs/core/src/session/turn.rs | 30 ++-- 15 files changed, 390 insertions(+), 110 deletions(-) create mode 100644 codex-rs/connectors/src/plugin_config.rs create mode 100644 codex-rs/connectors/src/plugin_config_tests.rs create mode 100644 codex-rs/connectors/src/snapshot.rs create mode 100644 codex-rs/connectors/src/snapshot_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8cc9ca818..61ffd3a01 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2584,6 +2584,8 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-config", + "codex-plugin", + "indexmap 2.14.0", "pretty_assertions", "serde", "serde_json", @@ -2756,6 +2758,7 @@ dependencies = [ "codex-analytics", "codex-app-server-protocol", "codex-config", + "codex-connectors", "codex-core-skills", "codex-exec-server", "codex-git-utils", @@ -2773,7 +2776,6 @@ dependencies = [ "codex-utils-plugins", "dirs", "flate2", - "indexmap 2.14.0", "libc", "pretty_assertions", "regex", @@ -3312,6 +3314,7 @@ dependencies = [ "codex-api", "codex-async-utils", "codex-config", + "codex-connectors", "codex-exec-server", "codex-login", "codex-model-provider", diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index ce25387da..67d768fa5 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -183,10 +183,14 @@ impl AppsRequestProcessor { None => 0, }; - let plugin_apps = plugins_manager + let loaded_plugins = plugins_manager .plugins_for_config(&config.plugins_config_input()) - .await - .effective_apps(); + .await; + let connector_snapshot = + codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries( + loaded_plugins.capability_summaries(), + ); + let plugin_apps = connector_snapshot.connector_ids().to_vec(); let (mut accessible_connectors, mut all_connectors) = tokio::join!( connectors::list_cached_accessible_connectors_from_mcp_tools(&config), connectors::list_cached_all_connectors(&config, &plugin_apps) diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index 15866e49e..428134b19 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -19,11 +19,11 @@ async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-api = { workspace = true } codex-config = { workspace = true } +codex-connectors = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-otel = { workspace = true } -codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-utils-path-uri = { workspace = true } @@ -41,6 +41,7 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] +codex-plugin = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] } tempfile = { workspace = true } diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 4a765e6a6..93ccf1a9e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -24,8 +24,8 @@ use codex_config::McpServerTransportConfig; use codex_config::types::AppToolApproval; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; +use codex_connectors::ConnectorSnapshot; use codex_login::CodexAuth; -use codex_plugin::PluginCapabilitySummary; use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; @@ -142,9 +142,9 @@ pub struct McpConfig { pub client_elicitation_capability: ElicitationCapability, /// Resolved MCP registrations keyed by logical server name. pub mcp_server_catalog: ResolvedMcpCatalog, - /// Plugin metadata used to attribute connector tools to plugin display names. + /// Plugin declarations used to attribute connector tools to plugin display names. /// MCP registrations retain their own package attribution in the catalog. - pub plugin_capability_summaries: Vec, + pub connector_snapshot: ConnectorSnapshot, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -182,14 +182,16 @@ impl ToolPluginProvenance { fn from_config(config: &McpConfig) -> Self { let mut tool_plugin_provenance = Self::default(); - for plugin in &config.plugin_capability_summaries { - for connector_id in &plugin.app_connector_ids { - tool_plugin_provenance - .plugin_display_names_by_connector_id - .entry(connector_id.0.clone()) - .or_default() - .push(plugin.display_name.clone()); - } + for connector_id in config.connector_snapshot.connector_ids() { + tool_plugin_provenance + .plugin_display_names_by_connector_id + .insert( + connector_id.0.clone(), + config + .connector_snapshot + .plugin_display_names_for_connector_id(&connector_id.0) + .to_vec(), + ); } for (server_name, attribution) in config diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 9617dca08..3a159e111 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -34,7 +34,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { prefix_mcp_tool_names: true, client_elicitation_capability: ElicitationCapability::default(), mcp_server_catalog: ResolvedMcpCatalog::default(), - plugin_capability_summaries: Vec::new(), + connector_snapshot: codex_connectors::ConnectorSnapshot::default(), } } @@ -133,25 +133,26 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None), )); config.mcp_server_catalog = catalog.build(); - config.plugin_capability_summaries = vec![ - PluginCapabilitySummary { - config_name: "alpha@test".to_string(), - display_name: "alpha-plugin".to_string(), - app_connector_ids: vec![AppConnectorId("connector_example".to_string())], - mcp_server_names: vec!["alpha".to_string()], - ..PluginCapabilitySummary::default() - }, - PluginCapabilitySummary { - config_name: "beta@test".to_string(), - display_name: "beta-plugin".to_string(), - app_connector_ids: vec![ - AppConnectorId("connector_example".to_string()), - AppConnectorId("connector_gmail".to_string()), - ], - mcp_server_names: vec!["beta".to_string()], - ..PluginCapabilitySummary::default() - }, - ]; + config.connector_snapshot = + codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries(&[ + PluginCapabilitySummary { + config_name: "alpha@test".to_string(), + display_name: "alpha-plugin".to_string(), + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + mcp_server_names: vec!["alpha".to_string()], + ..PluginCapabilitySummary::default() + }, + PluginCapabilitySummary { + config_name: "beta@test".to_string(), + display_name: "beta-plugin".to_string(), + app_connector_ids: vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ], + mcp_server_names: vec!["beta".to_string()], + ..PluginCapabilitySummary::default() + }, + ]); let provenance = tool_plugin_provenance(&config); assert_eq!( @@ -199,12 +200,15 @@ fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() { codex_apps_mcp_server_config("https://github.example", /*apps_mcp_product_sku*/ None), )); config.mcp_server_catalog = catalog.build(); - config.plugin_capability_summaries = vec![PluginCapabilitySummary { - config_name: "shared-plugin-id".to_string(), - display_name: "Local GitHub".to_string(), - mcp_server_names: vec!["github".to_string()], - ..PluginCapabilitySummary::default() - }]; + config.connector_snapshot = + codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries(&[ + PluginCapabilitySummary { + config_name: "shared-plugin-id".to_string(), + display_name: "Local GitHub".to_string(), + mcp_server_names: vec!["github".to_string()], + ..PluginCapabilitySummary::default() + }, + ]); let provenance = tool_plugin_provenance(&config); diff --git a/codex-rs/connectors/Cargo.toml b/codex-rs/connectors/Cargo.toml index c010effe8..6890408ea 100644 --- a/codex-rs/connectors/Cargo.toml +++ b/codex-rs/connectors/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-config = { workspace = true } +codex-plugin = { workspace = true } +indexmap = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index aa36e3bce..f77936e88 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -15,6 +15,8 @@ mod directory_cache; pub mod filter; pub mod merge; pub mod metadata; +mod plugin_config; +mod snapshot; pub use app_info::AppBranding; pub use app_info::AppInfo; @@ -27,6 +29,10 @@ 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 use plugin_config::parse_plugin_app_config; +pub use plugin_config::parse_plugin_app_config_value; +pub use snapshot::ConnectorSnapshot; +pub use snapshot::PluginConnectorSource; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); diff --git a/codex-rs/connectors/src/plugin_config.rs b/codex-rs/connectors/src/plugin_config.rs new file mode 100644 index 000000000..6f3179be8 --- /dev/null +++ b/codex-rs/connectors/src/plugin_config.rs @@ -0,0 +1,50 @@ +use codex_plugin::AppConnectorId; +use codex_plugin::AppDeclaration; +use indexmap::IndexMap; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginAppFile { + #[serde(default)] + apps: IndexMap, +} + +#[derive(Debug, Default, Deserialize)] +struct PluginAppConfig { + id: String, + category: Option, +} + +/// Parses connector declarations from a plugin app configuration file. +pub fn parse_plugin_app_config(contents: &str) -> serde_json::Result> { + serde_json::from_str(contents).map(app_declarations_from_file) +} + +/// Parses connector declarations from an already-decoded plugin app configuration. +pub fn parse_plugin_app_config_value(value: Value) -> serde_json::Result> { + serde_json::from_value(value).map(app_declarations_from_file) +} + +fn app_declarations_from_file(parsed: PluginAppFile) -> Vec { + parsed + .apps + .into_iter() + .map(|(name, app)| AppDeclaration { + name, + connector_id: AppConnectorId(app.id), + category: cleaned_category(app.category), + }) + .collect() +} + +fn cleaned_category(category: Option) -> Option { + category + .map(|category| category.trim().to_string()) + .filter(|category| !category.is_empty()) +} + +#[cfg(test)] +#[path = "plugin_config_tests.rs"] +mod tests; diff --git a/codex-rs/connectors/src/plugin_config_tests.rs b/codex-rs/connectors/src/plugin_config_tests.rs new file mode 100644 index 000000000..60944ce76 --- /dev/null +++ b/codex-rs/connectors/src/plugin_config_tests.rs @@ -0,0 +1,53 @@ +use codex_plugin::AppConnectorId; +use codex_plugin::AppDeclaration; +use pretty_assertions::assert_eq; + +use super::parse_plugin_app_config; + +#[test] +fn parses_plugin_app_config_in_order_without_validating_connector_ids() { + let parsed = parse_plugin_app_config( + r#"{ + "apps": { + "calendar": { + "id": "connector_calendar", + "category": " productivity " + }, + "drive": { + "id": "connector_calendar", + "category": " " + }, + "blank": { + "id": " " + } + } + }"#, + ) + .expect("plugin app config should parse"); + + assert_eq!( + parsed, + vec![ + AppDeclaration { + name: "calendar".to_string(), + connector_id: AppConnectorId("connector_calendar".to_string()), + category: Some("productivity".to_string()), + }, + AppDeclaration { + name: "drive".to_string(), + connector_id: AppConnectorId("connector_calendar".to_string()), + category: None, + }, + AppDeclaration { + name: "blank".to_string(), + connector_id: AppConnectorId(" ".to_string()), + category: None, + }, + ] + ); +} + +#[test] +fn rejects_invalid_plugin_app_config() { + assert!(parse_plugin_app_config("not json").is_err()); +} diff --git a/codex-rs/connectors/src/snapshot.rs b/codex-rs/connectors/src/snapshot.rs new file mode 100644 index 000000000..a07b1ef58 --- /dev/null +++ b/codex-rs/connectors/src/snapshot.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use codex_plugin::AppConnectorId; +use codex_plugin::AppDeclaration; +use codex_plugin::PluginCapabilitySummary; + +/// Connector declarations contributed by one plugin package. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginConnectorSource { + plugin_id: String, + plugin_display_name: String, + connector_ids: Vec, +} + +impl PluginConnectorSource { + /// Creates one plugin source from parsed app declarations. + pub fn new( + plugin_id: impl Into, + plugin_display_name: impl Into, + declarations: impl IntoIterator, + ) -> Self { + Self::from_connector_ids( + plugin_id, + plugin_display_name, + declarations + .into_iter() + .map(|declaration| declaration.connector_id), + ) + } + + /// Creates one plugin source from connector IDs that were already parsed. + pub fn from_connector_ids( + plugin_id: impl Into, + plugin_display_name: impl Into, + connector_ids: impl IntoIterator, + ) -> Self { + let mut seen_connector_ids = HashSet::new(); + let connector_ids = connector_ids + .into_iter() + .filter(|connector_id| !connector_id.0.trim().is_empty()) + .filter(|connector_id| seen_connector_ids.insert(connector_id.clone())) + .collect(); + Self { + plugin_id: plugin_id.into(), + plugin_display_name: plugin_display_name.into(), + connector_ids, + } + } + + /// Returns the package name shown in connector provenance. + pub fn plugin_display_name(&self) -> &str { + &self.plugin_display_name + } + + /// Returns the connector IDs contributed by this package. + pub fn connector_ids(&self) -> &[AppConnectorId] { + &self.connector_ids + } +} + +/// Immutable connector declarations and their plugin provenance. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ConnectorSnapshot { + sources: Vec, + connector_ids: Vec, + plugin_display_names_by_connector_id: HashMap>, +} + +impl ConnectorSnapshot { + /// Builds a connector snapshot from package-scoped declarations. + pub fn from_plugin_sources(sources: impl IntoIterator) -> Self { + let sources = sources + .into_iter() + .filter(|source| !source.connector_ids().is_empty()) + .collect::>(); + let mut connector_ids = Vec::new(); + let mut seen_connector_ids = HashSet::new(); + let mut plugin_display_names_by_connector_id: HashMap> = HashMap::new(); + + for source in &sources { + for connector_id in source.connector_ids() { + if seen_connector_ids.insert(connector_id.clone()) { + connector_ids.push(connector_id.clone()); + } + plugin_display_names_by_connector_id + .entry(connector_id.0.clone()) + .or_default() + .push(source.plugin_display_name().to_string()); + } + } + for plugin_names in plugin_display_names_by_connector_id.values_mut() { + plugin_names.sort_unstable(); + plugin_names.dedup(); + } + + Self { + sources, + connector_ids, + plugin_display_names_by_connector_id, + } + } + + /// Adapts the current host plugin summaries to the connector-owned snapshot. + pub fn from_plugin_capability_summaries(summaries: &[PluginCapabilitySummary]) -> Self { + Self::from_plugin_sources(summaries.iter().map(|summary| { + PluginConnectorSource::from_connector_ids( + summary.config_name.clone(), + summary.display_name.clone(), + summary.app_connector_ids.clone(), + ) + })) + } + + /// Returns the connector IDs in source contribution order. + pub fn connector_ids(&self) -> &[AppConnectorId] { + &self.connector_ids + } + + /// Returns the package display names associated with one connector. + pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] { + self.plugin_display_names_by_connector_id + .get(connector_id) + .map(Vec::as_slice) + .unwrap_or_default() + } + + /// Combines two snapshots while preserving source order and provenance. + pub fn merged_with(&self, other: &Self) -> Self { + Self::from_plugin_sources(self.sources.iter().chain(&other.sources).cloned()) + } +} + +#[cfg(test)] +#[path = "snapshot_tests.rs"] +mod tests; diff --git a/codex-rs/connectors/src/snapshot_tests.rs b/codex-rs/connectors/src/snapshot_tests.rs new file mode 100644 index 000000000..3087bc73c --- /dev/null +++ b/codex-rs/connectors/src/snapshot_tests.rs @@ -0,0 +1,47 @@ +use codex_plugin::AppConnectorId; +use pretty_assertions::assert_eq; + +use super::ConnectorSnapshot; +use super::PluginConnectorSource; + +#[test] +fn snapshot_merges_sources_in_order_and_dedupes_provenance() { + let host_source = source("host", "Zulu", &["calendar", "calendar"]); + let host = ConnectorSnapshot::from_plugin_sources([ + source("skills", "Skills only", &[]), + host_source.clone(), + ]); + let selected = ConnectorSnapshot::from_plugin_sources([ + source("selected-a", "Alpha", &["drive", "calendar"]), + source("selected-b", "Alpha", &["calendar"]), + ]); + + let merged = host.merged_with(&selected); + + assert_eq!(host.sources, vec![host_source]); + assert_eq!( + merged.connector_ids(), + &[ + AppConnectorId("calendar".to_string()), + AppConnectorId("drive".to_string()), + ] + ); + assert_eq!( + merged.plugin_display_names_for_connector_id("calendar"), + &["Alpha".to_string(), "Zulu".to_string()] + ); + assert_eq!( + merged.plugin_display_names_for_connector_id("missing"), + &[] as &[String] + ); +} + +fn source(id: &str, display_name: &str, connector_ids: &[&str]) -> PluginConnectorSource { + PluginConnectorSource::from_connector_ids( + id, + display_name, + connector_ids + .iter() + .map(|id| AppConnectorId((*id).to_string())), + ) +} diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 448723828..81cfe2f89 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } codex-analytics = { workspace = true } codex-app-server-protocol = { workspace = true } codex-config = { workspace = true } +codex-connectors = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-git-utils = { workspace = true } @@ -35,7 +36,6 @@ codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } -indexmap = { workspace = true, features = ["serde"] } reqwest = { workspace = true } regex = { workspace = true } semver = { workspace = true } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index db99256f3..49f4580e3 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -20,6 +20,8 @@ use codex_config::HooksFile; use codex_config::types::McpServerConfig; use codex_config::types::PluginConfig; use codex_config::types::PluginMcpServerConfig; +use codex_connectors::parse_plugin_app_config; +use codex_connectors::parse_plugin_app_config_value; use codex_core_skills::PluginSkillSnapshots; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; @@ -29,7 +31,6 @@ use codex_core_skills::loader::SkillRoot; use codex_core_skills::loader::load_skills_from_roots; use codex_exec_server::LOCAL_FS; use codex_mcp::parse_plugin_mcp_config; -use codex_plugin::AppConnectorId; use codex_plugin::AppDeclaration; use codex_plugin::LoadedPlugin; use codex_plugin::PluginCapabilitySummary; @@ -42,8 +43,6 @@ use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; -use indexmap::IndexMap; -use serde::Deserialize; use serde_json::Value as JsonValue; use std::collections::HashMap; use std::collections::HashSet; @@ -96,19 +95,6 @@ pub(crate) fn log_plugin_load_errors(plugins: &[LoadedPlugin]) } } -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PluginAppFile { - #[serde(default)] - apps: IndexMap, -} - -#[derive(Debug, Default, Deserialize)] -struct PluginAppConfig { - id: String, - category: Option, -} - /// Load configured plugins without applying auth-dependent runtime policies. #[instrument(level = "trace", skip_all)] pub(crate) async fn load_plugins_from_layer_stack( @@ -965,10 +951,10 @@ pub(crate) async fn load_plugin_apps_from_manifest( } pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec { - let Ok(parsed) = serde_json::from_value::(value.clone()) else { + let Ok(mut apps) = parse_plugin_app_config_value(value.clone()) else { return Vec::new(); }; - let mut apps = app_declarations_from_file(parsed, /*plugin_root*/ None); + apps.retain(|app| !app.connector_id.0.trim().is_empty()); let mut seen_connector_ids = HashSet::new(); apps.retain(|app| seen_connector_ids.insert(app.connector_id.0.clone())); apps @@ -1116,8 +1102,8 @@ async fn load_apps_from_paths( let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else { continue; }; - let parsed = match serde_json::from_str::(&contents) { - Ok(parsed) => parsed, + let declarations = match parse_plugin_app_config(&contents) { + Ok(declarations) => declarations, Err(err) => { warn!( path = %app_config_path.display(), @@ -1127,44 +1113,21 @@ async fn load_apps_from_paths( } }; - app_declarations.extend(app_declarations_from_file(parsed, Some(plugin_root))); + app_declarations.extend(declarations.into_iter().filter(|app| { + if app.connector_id.0.trim().is_empty() { + warn!( + plugin = %plugin_root.display(), + "plugin app config is missing an app id" + ); + false + } else { + true + } + })); } app_declarations } -fn app_declarations_from_file( - parsed: PluginAppFile, - plugin_root: Option<&Path>, -) -> Vec { - parsed - .apps - .into_iter() - .filter_map(|(name, app)| { - if app.id.trim().is_empty() { - if let Some(plugin_root) = plugin_root { - warn!( - plugin = %plugin_root.display(), - "plugin app config is missing an app id" - ); - } - None - } else { - Some(AppDeclaration { - name, - connector_id: AppConnectorId(app.id), - category: cleaned_app_category(app.category), - }) - } - }) - .collect() -} - -fn cleaned_app_category(category: Option) -> Option { - category - .map(|category| category.trim().to_string()) - .filter(|category| !category.is_empty()) -} - pub async fn plugin_capability_summary_from_root( plugin_id: &PluginId, plugin_root: &AbsolutePathBuf, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 96ba987b9..ac79dea39 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1578,7 +1578,10 @@ impl Config { ElicitationCapability::default() }, mcp_server_catalog: catalog.build(), - plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), + connector_snapshot: + codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries( + loaded_plugins.capability_summaries(), + ), } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 3094bc77a..a8fe50b78 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -532,6 +532,9 @@ async fn build_skills_and_plugins( // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries()); + let connector_snapshot = codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries( + loaded_plugins.capability_summaries(), + ); let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { // Plugin mentions need raw MCP/app inventory even when app tools // are normally hidden so we can describe the plugin's currently @@ -553,10 +556,10 @@ async fn build_skills_and_plugins( }; let available_connectors = if turn_context.apps_enabled() { let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( - loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0), + connector_snapshot + .connector_ids() + .iter() + .map(|connector_id| connector_id.0.clone()), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), ); connectors::with_app_enabled_state(connectors, &turn_context.config) @@ -1177,6 +1180,9 @@ pub(crate) async fn built_tools( .plugins_for_config(&turn_context.config.plugins_config_input()) .instrument(trace_span!("built_tools.load_plugins")) .await; + let connector_snapshot = codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries( + loaded_plugins.capability_summaries(), + ); let apps_enabled = turn_context.apps_enabled(); let accessible_connectors = @@ -1187,10 +1193,10 @@ pub(crate) async fn built_tools( }); let connectors = if apps_enabled { let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( - loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0), + connector_snapshot + .connector_ids() + .iter() + .map(|connector_id| connector_id.0.clone()), accessible_connectors.clone().unwrap_or_default(), ); Some(connectors::with_app_enabled_state( @@ -1228,10 +1234,10 @@ pub(crate) async fn built_tools( presentation: ToolSuggestPresentation::RecommendationContext, }) } else { - let loaded_plugin_app_connector_ids = loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0) + let loaded_plugin_app_connector_ids = connector_snapshot + .connector_ids() + .iter() + .map(|connector_id| connector_id.0.clone()) .collect::>(); async { if apps_enabled && tool_suggest_is_enabled {