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.
This commit is contained in:
jif
2026-06-24 23:24:01 +01:00
committed by GitHub
Unverified
parent bb05c1f30f
commit 4e0f863df3
15 changed files with 390 additions and 110 deletions
+17 -54
View File
@@ -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<McpServerConfig>])
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginAppFile {
#[serde(default)]
apps: IndexMap<String, PluginAppConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct PluginAppConfig {
id: String,
category: Option<String>,
}
/// 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<AppDeclaration> {
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(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::<PluginAppFile>(&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<AppDeclaration> {
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<String>) -> Option<String> {
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,