Reconcile V2 executor plugins with cloud catalogs (#55301)

## What changed

- Use V2 discovery inventories for plugin MCP contributions when supplied, including empty or failed inventories; retain legacy discovery when no V2 snapshot is supplied.
- Merge plugins by canonical identity, using cloud metadata and enablement for remote plugins and attaching executor installations only when versions match. Preserve configured executor-local plugins and executor enablement when cloud data is unavailable.
- Keep installation order stable across refreshes and run merged MCP declarations on the first retained executor installation while preserving attribution for the others.
- Carry local and remote identities through selected-plugin snapshots, skill attribution, and active-plugin telemetry. Track disabled plugin IDs alongside disabled roots.
- Preserve a complete cloud catalog when a partial refresh arrives, and cap tool-description plugin attribution at 1,024 bytes while retaining all plugin associations.

## Testing

Add coverage for catalog parsing and reconciliation, enablement and discovery-mode selection, installation refresh ordering, executor-specific MCP normalization, selected-plugin snapshots, and bounded UTF-8 attribution text.

GitOrigin-RevId: a8ad352de6a2b8dc4bfd1ff6a5c62ee8ed1d1664
This commit is contained in:
Anthony Tafoya
2026-10-19 15:15:17 +00:00
committed by copyberry
parent 1277bcfddc
commit f7a135ec61
32 changed files with 1804 additions and 90 deletions
+1
View File
@@ -3381,6 +3381,7 @@ dependencies = [
"codex-api",
"codex-config",
"codex-context-fragments",
"codex-core-plugin-common",
"codex-exec-server-protocol",
"codex-file-system",
"codex-guardian-context",
+2
View File
@@ -78,9 +78,11 @@ pub use mcp::hosted_plugin_runtime_mcp_server_config;
pub use mcp::tool_plugin_context;
pub use plugin_config::PluginMcpConfigParseOutcome;
pub use plugin_config::PluginMcpServerParseError;
pub use plugin_config::normalize_executor_plugin_mcp_declarations;
pub use plugin_config::parse_agent_plugin_mcp_config;
pub use plugin_config::parse_executor_plugin_mcp_config;
pub use plugin_config::parse_plugin_mcp_config;
pub use plugin_config::parse_plugin_mcp_declarations;
pub use mcp::McpServerStatusSnapshot;
pub use mcp::McpSnapshotDetail;
+32 -3
View File
@@ -63,6 +63,13 @@ impl PluginMcpFile {
}
}
/// Parses wrapped or plain plugin MCP declarations without runtime normalization.
pub fn parse_plugin_mcp_declarations(
contents: &str,
) -> Result<BTreeMap<String, JsonValue>, serde_json::Error> {
serde_json::from_str::<PluginMcpFile>(contents).map(PluginMcpFile::into_mcp_servers)
}
/// Parses the two supported plugin MCP file shapes and normalizes each server.
///
/// Native plugin HTTP servers share the regular MCP transport configuration;
@@ -93,6 +100,21 @@ pub fn parse_executor_plugin_mcp_config(
)
}
/// Normalizes already-parsed declarations for one executor installation.
pub fn normalize_executor_plugin_mcp_declarations(
plugin_root: &PathUri,
declarations: BTreeMap<String, JsonValue>,
environment_id: &str,
) -> PluginMcpConfigParseOutcome {
normalize_plugin_mcp_declarations(
declarations,
PluginMcpSource::Environment {
root: plugin_root,
environment_id,
},
)
}
impl PluginMcpSource<'_> {
fn display(self) -> String {
match self {
@@ -106,10 +128,17 @@ fn parse_plugin_mcp_config_from(
contents: &str,
source: PluginMcpSource<'_>,
) -> Result<PluginMcpConfigParseOutcome, serde_json::Error> {
let parsed = serde_json::from_str::<PluginMcpFile>(contents)?;
let declarations = parse_plugin_mcp_declarations(contents)?;
Ok(normalize_plugin_mcp_declarations(declarations, source))
}
fn normalize_plugin_mcp_declarations(
declarations: BTreeMap<String, JsonValue>,
source: PluginMcpSource<'_>,
) -> PluginMcpConfigParseOutcome {
let mut outcome = PluginMcpConfigParseOutcome::default();
for (name, config_value) in parsed.into_mcp_servers() {
for (name, config_value) in declarations {
match normalize_plugin_mcp_server(config_value, source) {
Ok(config) => {
outcome.servers.insert(name, config);
@@ -120,7 +149,7 @@ fn parse_plugin_mcp_config_from(
}
}
Ok(outcome)
outcome
}
fn normalize_plugin_mcp_server(
@@ -1,5 +1,6 @@
use super::PluginMcpConfigParseOutcome;
use super::PluginMcpServerParseError;
use super::normalize_executor_plugin_mcp_declarations;
use super::parse_agent_plugin_mcp_config;
use super::parse_executor_plugin_mcp_config;
use super::parse_plugin_mcp_config;
@@ -751,6 +752,18 @@ fn executor_environment_placement_resolves_foreign_uri_cwd() {
)
.expect("parse plugin MCP config");
assert_eq!(
normalize_executor_plugin_mcp_declarations(
&plugin_root,
BTreeMap::from([(
"demo".to_string(),
serde_json::json!({"command": "demo-mcp", "cwd": "scripts"}),
)]),
"executor-1",
),
outcome
);
assert_eq!(
outcome,
PluginMcpConfigParseOutcome {
+38 -11
View File
@@ -12,6 +12,10 @@
#[path = "rmcp_client/status.rs"]
mod status;
#[cfg(test)]
#[path = "rmcp_client/provenance_tests.rs"]
mod provenance_tests;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -97,6 +101,8 @@ use tracing::warn;
/// not use it. Its `cacheable: false` property disables sharing tool definitions across connections.
const MCP_TOOL_CATALOG_CACHE_CAPABILITY: &str = "codex/tool-catalog-cache";
const MCP_TOOL_CATALOG_CACHEABLE_PROPERTY: &str = "cacheable";
// Bound only the model-visible attribution note, not plugin membership or tool availability.
const MAX_PLUGIN_PROVENANCE_BYTES: usize = 1_024;
pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str =
"codex.mcp.tools.fetch_uncached.duration_ms";
@@ -766,18 +772,39 @@ fn add_plugin_provenance_to_tool(tool: &mut ToolInfo, plugin_names: &[String]) {
return;
}
let plugin_source_note = if plugin_names.len() == 1 {
format!("This tool is part of plugin `{}`.", plugin_names[0])
let plural = if plugin_names.len() == 1 { "" } else { "s" };
let mut plugin_source_note = format!("This tool is part of plugin{plural} ");
let mut shown = 0;
for (index, name) in plugin_names.iter().enumerate() {
let separator = if index == 0 { "" } else { ", " };
let remaining = plugin_names.len() - index - 1;
let ending = if remaining == 0 {
".".to_string()
} else {
format!(", and {remaining} more.")
};
// Reserve the suffix and keep whole names, including multibyte UTF-8 characters.
if plugin_source_note.len() + separator.len() + name.len() + 2 + ending.len()
> MAX_PLUGIN_PROVENANCE_BYTES
{
break;
}
plugin_source_note.push_str(separator);
plugin_source_note.push('`');
plugin_source_note.push_str(name);
plugin_source_note.push('`');
shown += 1;
}
if shown == 0 {
plugin_source_note = format!(
"This tool is part of {} plugin{plural}.",
plugin_names.len()
);
} else if shown < plugin_names.len() {
plugin_source_note.push_str(&format!(", and {} more.", plugin_names.len() - shown));
} else {
format!(
"This tool is part of plugins {}.",
plugin_names
.iter()
.map(|plugin_name| format!("`{plugin_name}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
plugin_source_note.push('.');
}
let description = tool
.tool
.description
@@ -0,0 +1,84 @@
//! Plugin attribution stays bounded without changing tool definitions or membership.
use super::*;
use pretty_assertions::assert_eq;
fn app_tool(description: &str) -> ToolInfo {
tool_info_from_listed_tool(
CODEX_APPS_MCP_SERVER_NAME,
/*is_codex_apps_mcp_server*/ true,
/*server_instructions*/ None,
ToolWithConnectorId {
tool: RmcpTool::new("search", description.to_string(), Arc::default()),
connector_id: Some("shared-connector".to_string()),
connector_name: None,
connector_description: None,
},
)
}
#[test]
fn provenance_preserves_short_descriptions_and_membership() {
for (description, names, expected_description) in [
("Search", vec![], "Search"),
("", vec!["Alpha"], "This tool is part of plugin `Alpha`."),
(
"Search",
vec!["Alpha"],
"Search. This tool is part of plugin `Alpha`.",
),
(
"Search!",
vec!["Alpha", "Beta"],
"Search! This tool is part of plugins `Alpha`, `Beta`.",
),
] {
let names = names.into_iter().map(str::to_string).collect::<Vec<_>>();
let mut tool = app_tool(description);
let mut expected = tool.clone();
expected.tool.description = Some(expected_description.to_string().into());
expected.plugin_display_names = names.clone();
add_plugin_provenance_to_tool(&mut tool, &names);
assert_eq!(tool, expected);
}
}
#[test]
fn provenance_bounds_aggregate_names_and_retains_all_associations() {
let exact_name =
"a".repeat(MAX_PLUGIN_PROVENANCE_BYTES - "This tool is part of plugin ``.".len());
for names in [
vec![exact_name.clone()],
vec![format!("{exact_name}a")],
vec!["界".repeat(MAX_PLUGIN_PROVENANCE_BYTES)],
(0..512)
.map(|index| format!("{index:03}{}", "界".repeat(61)))
.collect(),
] {
let mut tool = app_tool("Search.");
let original = tool.clone();
add_plugin_provenance_to_tool(&mut tool, &names);
let note = tool
.tool
.description
.as_deref()
.unwrap()
.strip_prefix("Search. ")
.unwrap();
assert!(note.len() <= MAX_PLUGIN_PROVENANCE_BYTES);
if names[0] == exact_name {
assert_eq!(note.len(), MAX_PLUGIN_PROVENANCE_BYTES);
} else if names.len() == 1 {
assert_eq!(note, "This tool is part of 1 plugin.");
} else {
let shown = note.matches('`').count() / 2;
assert!(shown > 0 && shown < names.len());
assert!(note.starts_with(&format!("This tool is part of plugins `{}`", names[0])));
assert!(note.ends_with(&format!(", and {} more.", names.len() - shown)));
}
let mut expected = original;
expected.tool.description = tool.tool.description.clone();
expected.plugin_display_names = names;
assert_eq!(tool, expected);
}
}
@@ -0,0 +1,20 @@
//! Source-neutral identity shared by plugin catalogs and runtime attribution.
/// Stable key used to merge discoveries of the same logical plugin.
/// Identity is independent of whether the plugin is supplied by the cloud or an executor.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PluginIdentity {
/// Stable ID assigned by plugin-service and shared by its materialized installations.
Remote { remote_plugin_id: String },
/// Local config key (normally `name@marketplace`), or a legacy selected-root identity.
Local { plugin_id: String },
}
impl PluginIdentity {
pub fn as_str(&self) -> &str {
match self {
Self::Remote { remote_plugin_id } => remote_plugin_id,
Self::Local { plugin_id } => plugin_id,
}
}
}
+3
View File
@@ -1,4 +1,7 @@
//! Shared plugin identities and read-only installed-package rules.
mod identity;
pub mod installed;
pub mod plugin_id;
pub use identity::PluginIdentity;
+88 -25
View File
@@ -1,32 +1,63 @@
//! Source-neutral plugin discovery types.
//! Source-neutral plugin catalogs, reconciliation, and source-aware enablement policy.
use codex_config::types::PluginConfig;
use codex_core_plugin_common::PluginIdentity;
use codex_utils_path_uri::PathUri;
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
/// One source's complete discovery snapshot; no entries means no plugins were found.
/// One source's discovery snapshot; only a complete empty catalog establishes absence.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginCatalog {
pub entries: Vec<PluginCatalogEntry>,
pub warnings: Vec<String>,
/// Listing omitted entries.
pub is_partial: bool,
}
/// Manifest metadata and every location that can supply the same logical plugin.
#[derive(Clone, PartialEq, Eq)]
pub struct PluginCatalogEntry {
pub id: PluginIdentity,
pub enabled: bool,
pub display_name: String,
pub version: Option<String>,
/// Serialized `.mcp.json` server declarations keyed by their authored names.
pub mcp_servers: BTreeMap<String, String>,
pub version: String,
/// Raw MCP server declarations keyed by their authored names.
pub mcp_servers: BTreeMap<String, Value>,
pub connector_ids: Vec<String>,
pub locations: Vec<PluginSourceLocation>,
}
impl PluginCatalogEntry {
/// Disabled entries never project. Otherwise Cloud membership supplies enablement;
/// executor-only entries require every installation's local plugin ID in the supplied
/// config, where omitted enablement retains the configured plugin's default.
/// A remote identity alone does not establish Cloud membership.
pub fn is_enabled(&self, configured_plugins: &HashMap<String, PluginConfig>) -> bool {
if !self.enabled {
return false;
}
let is_cloud_plugin = self
.locations
.iter()
.any(|location| matches!(location, PluginSourceLocation::Cloud { .. }));
is_cloud_plugin
|| self.locations.iter().all(|location| match location {
PluginSourceLocation::Executor { plugin_id, .. } => configured_plugins
.get(plugin_id)
.is_some_and(|plugin| plugin.enabled.unwrap_or(true)),
PluginSourceLocation::Cloud { .. } => true,
})
}
}
impl std::fmt::Debug for PluginCatalogEntry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginCatalogEntry")
.field("id", &self.id)
.field("enabled", &self.enabled)
.field("display_name", &self.display_name)
.field("version", &self.version)
// Declaration values can contain secrets, so only log their keys.
@@ -40,27 +71,8 @@ impl std::fmt::Debug for PluginCatalogEntry {
}
}
/// Stable key used to merge discoveries of the same logical plugin.
/// Identity is independent of whether the plugin is supplied by the cloud or an executor.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PluginIdentity {
/// Stable ID assigned by plugin-service and shared by its materialized installations.
Remote { remote_plugin_id: String },
/// Local `name@marketplace` config key for a plugin without a remote ID.
Local { plugin_id: String },
}
impl PluginIdentity {
pub fn as_str(&self) -> &str {
match self {
Self::Remote { remote_plugin_id } => remote_plugin_id,
Self::Local { plugin_id } => plugin_id,
}
}
}
/// A source that can supply a plugin; cloud URIs are opaque, not local paths.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PluginSourceLocation {
Cloud {
resource_uri: String,
@@ -74,3 +86,54 @@ pub enum PluginSourceLocation {
root: PathUri,
},
}
/// Uses Cloud membership and metadata, retaining version-matched installations in order.
/// Unavailable Cloud data preserves executor enablement. With a Cloud catalog, unmatched remote
/// plugins are retained but disabled. Local plugins retain their existing enablement.
pub fn merge_catalogs(
executor: Option<&PluginCatalog>,
cloud: Option<&PluginCatalog>,
) -> PluginCatalog {
let Some(executor) = executor else {
return cloud.cloned().unwrap_or_default();
};
let Some(cloud) = cloud else {
return executor.clone();
};
let mut merged = cloud.clone();
merged.is_partial |= executor.is_partial;
merged.warnings.extend(executor.warnings.iter().cloned());
for installation in &executor.entries {
if matches!(&installation.id, PluginIdentity::Local { .. }) {
merged.entries.push(installation.clone());
continue;
}
// Appended executor entries are not Cloud definitions and cannot authorize each other.
let Some(definition) = merged.entries[..cloud.entries.len()]
.iter_mut()
.find(|definition| definition.id == installation.id)
else {
let mut disabled = installation.clone();
disabled.enabled = false;
merged.entries.push(disabled);
continue;
};
if definition.version != installation.version {
merged.warnings.push(format!(
"plugin `{}` has different cloud and executor versions; retaining the cloud definition",
definition.id.as_str(),
));
continue;
}
for location in &installation.locations {
if !definition.locations.contains(location) {
definition.locations.push(location.clone());
}
}
}
merged
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
+164
View File
@@ -0,0 +1,164 @@
//! Tests Cloud catalog authority, version matching, and installation reconciliation.
use super::PluginCatalog;
use super::PluginCatalogEntry;
use super::PluginIdentity;
use super::PluginSourceLocation;
use super::merge_catalogs;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn cloud_plugin(remote_id: &str) -> PluginCatalogEntry {
PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: remote_id.to_string(),
},
display_name: "Example".to_string(),
version: "1.0.0".to_string(),
mcp_servers: BTreeMap::from([(
"example".to_string(),
serde_json::json!({"command":"node","args":["server.js"]}),
)]),
connector_ids: vec!["connector_example".to_string()],
locations: vec![PluginSourceLocation::Cloud {
resource_uri: format!("plugin://{remote_id}"),
bundle_uri: None,
}],
}
}
fn executor_plugin(remote_id: &str, environment_id: &str) -> anyhow::Result<PluginCatalogEntry> {
let mut entry = cloud_plugin(remote_id);
entry.locations = vec![PluginSourceLocation::Executor {
environment_id: environment_id.to_string(),
plugin_id: "example@marketplace".to_string(),
root: PathUri::parse("file:///plugins/example/1.0.0")?,
}];
Ok(entry)
}
#[test]
fn merged_catalog_prefers_cloud_and_matches_executor_versions() -> anyhow::Result<()> {
let definition = cloud_plugin("plugin_a");
let mut first = executor_plugin("plugin_a", "executor-a")?;
first.display_name = "Installed display name".to_string();
first.mcp_servers.clear();
first.connector_ids.clear();
let second = executor_plugin("plugin_a", "executor-b")?;
let mut incompatible = executor_plugin("plugin_a", "executor-c")?;
incompatible.version = "2.0.0".to_string();
let mut expected = definition.clone();
expected.locations.extend(first.locations.clone());
expected.locations.extend(second.locations.clone());
let executor = PluginCatalog {
entries: vec![first.clone(), first, second, incompatible],
warnings: vec!["executor warning".to_string()],
..Default::default()
};
let cloud = PluginCatalog {
entries: vec![definition],
warnings: vec!["cloud warning".to_string()],
..Default::default()
};
assert_eq!(
merge_catalogs(Some(&executor), Some(&cloud)),
PluginCatalog {
entries: vec![expected],
warnings: vec![
"cloud warning".to_string(),
"executor warning".to_string(),
"plugin `plugin_a` has different cloud and executor versions; retaining the cloud definition"
.to_string(),
],
..Default::default()
}
);
Ok(())
}
#[test]
fn cloud_catalog_controls_remote_plugins_but_preserves_local_plugins() -> anyhow::Result<()> {
let installed = executor_plugin("plugin_a", "executor-a")?;
let mut local = executor_plugin("plugin_a", "executor-b")?;
local.id = PluginIdentity::Local {
plugin_id: "example@marketplace".to_string(),
};
let executor = PluginCatalog {
entries: vec![installed.clone(), local.clone()],
..Default::default()
};
let local_only = PluginCatalog {
entries: vec![local.clone()],
..Default::default()
};
// An available Cloud catalog controls remote entries; local entries are unaffected.
let mut other_installation = executor_plugin("plugin_a", "executor-c")?;
other_installation.version = "2.0.0".to_string();
let unmatched = PluginCatalog {
entries: vec![installed.clone(), other_installation.clone(), local.clone()],
..Default::default()
};
let mut disabled = installed.clone();
disabled.enabled = false;
other_installation.enabled = false;
let disabled_entries = vec![disabled, other_installation, local.clone()];
assert_eq!(merge_catalogs(Some(&unmatched), /*cloud*/ None), unmatched);
let explicitly_disabled = PluginCatalog {
entries: disabled_entries.clone(),
..Default::default()
};
assert_eq!(
merge_catalogs(Some(&explicitly_disabled), /*cloud*/ None),
explicitly_disabled,
);
for cloud in [
Some(PluginCatalog::default()),
Some(PluginCatalog {
is_partial: true,
warnings: vec!["partial cloud listing".to_string()],
..Default::default()
}),
] {
assert_eq!(
merge_catalogs(Some(&unmatched), cloud.as_ref()),
PluginCatalog {
entries: disabled_entries.clone(),
is_partial: cloud.as_ref().is_some_and(|catalog| catalog.is_partial),
warnings: cloud.map(|catalog| catalog.warnings).unwrap_or_default(),
},
);
}
let definition = cloud_plugin("plugin_a");
let cloud = PluginCatalog {
entries: vec![definition.clone()],
..Default::default()
};
assert_eq!(merge_catalogs(/*executor*/ None, Some(&cloud)), cloud);
assert_eq!(
merge_catalogs(/*executor*/ None, /*cloud*/ None),
PluginCatalog::default(),
);
let mut authorized = definition.clone();
authorized.locations.extend(installed.locations);
assert_eq!(
merge_catalogs(Some(&executor), Some(&cloud)),
PluginCatalog {
entries: vec![authorized, local.clone()],
..Default::default()
}
);
// Removing an installation preserves its Cloud definition without the executor route.
assert_eq!(
merge_catalogs(Some(&local_only), Some(&cloud)),
PluginCatalog {
entries: vec![definition, local],
..Default::default()
}
);
Ok(())
}
@@ -0,0 +1,97 @@
//! Converts V2 discovery contents to inert plugin catalogs without additional I/O.
use crate::PluginCatalog;
use crate::PluginCatalogEntry;
use crate::PluginIdentity;
use crate::PluginSourceLocation;
use codex_connectors::parse_plugin_app_config;
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
use codex_exec_server::ExecutorPlugin;
use codex_mcp::parse_plugin_mcp_declarations;
/// Projects V2 inventories into a plugin catalog without additional filesystem access.
pub fn catalog_from_snapshots(
snapshots: &[ExecutorCapabilityDiscoveryV2Snapshot],
) -> PluginCatalog {
let mut catalog = PluginCatalog::default();
for snapshot in snapshots {
let inventory = match &snapshot.result {
Ok(inventory) => inventory,
Err(error) => {
catalog.warnings.push(format!(
"could not discover plugins in environment `{}`; skipping its plugin inventory: {error}",
snapshot.environment_id
));
continue;
}
};
catalog.warnings.extend(inventory.warnings.iter().cloned());
for plugin in &inventory.plugins {
let (entry, warnings) = from_discovered_plugin(&snapshot.environment_id, plugin);
catalog.entries.push(entry);
catalog.warnings.extend(warnings);
}
}
catalog
}
fn from_discovered_plugin(
environment_id: &str,
plugin: &ExecutorPlugin,
) -> (PluginCatalogEntry, Vec<String>) {
let plugin_id = &plugin.id;
let mut warnings = Vec::new();
let mcp_servers = match plugin
.files
.mcp_config
.as_ref()
.map(|file| parse_plugin_mcp_declarations(&file.contents))
{
Some(Ok(servers)) => servers,
Some(Err(error)) => {
warnings.push(format!("plugin `{plugin_id}` MCP configuration: {error}"));
Default::default()
}
None => Default::default(),
};
let connector_ids = match plugin
.files
.apps_config
.as_ref()
.map(|file| parse_plugin_app_config(&file.contents))
{
Some(Ok(apps)) => apps.into_iter().map(|app| app.connector_id.0).collect(),
Some(Err(error)) => {
warnings.push(format!("plugin `{plugin_id}` app configuration: {error}"));
Vec::new()
}
None => Vec::new(),
};
(
PluginCatalogEntry {
enabled: true,
id: match &plugin.remote_plugin_id {
Some(remote_plugin_id) => PluginIdentity::Remote {
remote_plugin_id: remote_plugin_id.clone(),
},
None => PluginIdentity::Local {
plugin_id: plugin.id.clone(),
},
},
display_name: plugin.display_name.clone(),
version: plugin.version.clone(),
mcp_servers,
connector_ids,
locations: vec![PluginSourceLocation::Executor {
environment_id: environment_id.to_string(),
plugin_id: plugin_id.to_string(),
root: plugin.root.clone(),
}],
},
warnings,
)
}
#[cfg(test)]
#[path = "executor_catalog_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
//! Tests conversion of DiscoveryV2 payloads into inert plugin catalogs.
use super::catalog_from_snapshots;
use crate::PluginCatalog;
use crate::PluginCatalogEntry;
use crate::PluginIdentity;
use crate::PluginSourceLocation;
use codex_exec_server::CapabilityTextFile;
use codex_exec_server::DiscoverV2CapabilitiesResponse;
use codex_exec_server::DiscoveredPluginFiles;
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
use codex_exec_server::ExecutorPlugin;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::sync::Arc;
#[test]
fn discovered_mcp_contents_populate_catalog() -> anyhow::Result<()> {
let root = PathUri::parse("file:///plugins/example/1.0.0")?;
let manifest_path = root.join(".codex-plugin/plugin.json")?;
let declarations = serde_json::json!({"example": {"command": "node"}});
let snapshot = ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: "executor-a".to_string(),
result: Ok(Arc::new(DiscoverV2CapabilitiesResponse {
plugins: vec![ExecutorPlugin {
id: "example@marketplace".to_string(),
display_name: "Example".to_string(),
remote_plugin_id: Some("plugin_a".to_string()),
version: "1.0.0".to_string(),
files: DiscoveredPluginFiles {
manifest: CapabilityTextFile {
path: manifest_path.clone(),
contents: r#"{"name":"example"}"#.to_string(),
},
mcp_config: Some(CapabilityTextFile {
path: manifest_path,
contents: declarations.to_string(),
}),
apps_config: None,
},
root: root.clone(),
skills: Vec::new(),
}],
..Default::default()
})),
};
assert_eq!(
catalog_from_snapshots(&[snapshot]),
PluginCatalog {
entries: vec![PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: "plugin_a".to_string(),
},
display_name: "Example".to_string(),
version: "1.0.0".to_string(),
mcp_servers: BTreeMap::from([(
"example".to_string(),
serde_json::json!({"command": "node"}),
)]),
connector_ids: Vec::new(),
locations: vec![PluginSourceLocation::Executor {
environment_id: "executor-a".to_string(),
plugin_id: "example@marketplace".to_string(),
root,
}],
}],
..Default::default()
},
);
Ok(())
}
+6 -1
View File
@@ -4,6 +4,7 @@ mod catalog;
mod command_migration;
mod discoverable;
mod error_subtype;
mod executor_catalog;
mod executor_hooks;
mod executor_provider;
mod git_policy;
@@ -61,8 +62,9 @@ pub use artifact_operation::ArtifactOperation;
pub use artifact_operation::recognize_artifact_operation;
pub use catalog::PluginCatalog;
pub use catalog::PluginCatalogEntry;
pub use catalog::PluginIdentity;
pub use catalog::PluginSourceLocation;
pub use catalog::merge_catalogs;
pub use codex_core_plugin_common::PluginIdentity;
pub use command_migration::CommandDescriptionMode;
pub use command_migration::CommandMigrationProfile;
pub use command_migration::RewriteProfile as CommandRewriteProfile;
@@ -71,6 +73,7 @@ pub use command_migration::import_commands_with_profile;
pub use command_migration::missing_command_names_with_profile;
pub use discoverable::ToolSuggestDiscoverablePlugin;
pub use discoverable::ToolSuggestPluginDiscoveryInput;
pub use executor_catalog::catalog_from_snapshots;
pub use executor_hooks::executor_plugin_hook_sources;
pub use executor_provider::ExecutorPluginProvider;
pub use executor_provider::ExecutorPluginProviderError;
@@ -126,3 +129,5 @@ pub use script_attribution::PluginCommandAttribution;
pub use script_attribution::PluginMeasurementTarget;
pub use script_attribution::TrustedPluginRoots;
pub use script_attribution::command_script_arguments;
pub use marketplace_policy::configured_plugins_from_stack;
@@ -295,7 +295,7 @@ fn allowed_configured_marketplace_names_with_policy(
.collect()
}
pub(crate) fn configured_plugins_from_stack(
pub fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
codex_home: &Path,
) -> HashMap<String, PluginConfig> {
+2 -1
View File
@@ -38,7 +38,8 @@ pub type PluginProviderFuture<'a, T> =
/// Cloud providers implement `list`; executor listing is not yet supported.
/// Package access uses [`crate::ExecutorPluginProvider::resolve_bound`] directly.
pub trait PluginProvider: Send + Sync {
/// Returns a complete snapshot; on error, callers retain the previous snapshot.
/// Returns a snapshot; on error, callers retain the previous snapshot.
/// Providers set `is_partial` when listing omits entries; warnings are diagnostic only.
fn list(&self, _query: PluginListQuery) -> PluginProviderFuture<'_, PluginCatalog> {
Box::pin(async { Ok(PluginCatalog::default()) })
}
+65 -9
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use crate::config::Config;
@@ -7,6 +8,7 @@ use codex_config::McpServerConfig;
use codex_config::types::PluginsConfigToml;
use codex_connectors::ConnectorRuntimeManager;
use codex_connectors::PluginConnectorSource;
use codex_core_plugins::PluginIdentity as CatalogPluginIdentity;
use codex_core_plugins::PluginsManager;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
@@ -202,32 +204,54 @@ impl McpManager {
let roots = context
.ready_selected_capability_roots()
.unwrap_or_default();
let mut selected = SelectedPluginSnapshot::default();
if roots.is_empty() {
let mut selected = SelectedPluginSnapshot {
disabled_plugin_ids: disabled_plugin_ids.to_vec(),
..Default::default()
};
let v2 = context.executor_capability_discovery_v2().is_some();
let available_roots = available_plugin_roots(context);
if roots.is_empty() && !v2 {
return selected;
}
for contributor in self.extensions.mcp_server_contributors() {
for SelectedPlugin {
selected_root_id,
plugin_id,
identity,
enabled,
..
} in contributor.selected_plugins(context, plugins_config).await
{
if !roots.iter().any(|root| root.id == selected_root_id) {
if !available_roots.contains(&selected_root_id) {
continue;
}
if !context.config().features.enabled(Feature::Plugins)
if !enabled
|| !context.config().features.enabled(Feature::Plugins)
|| !plugins_config.allows_plugin(&plugin_id)
|| disabled_plugin_ids.contains(&plugin_id)
|| disabled_plugin_ids.iter().any(|id| id == identity.as_str())
{
selected.disabled_plugin_roots.push(selected_root_id);
selected.disabled_plugin_ids.push(plugin_id);
} else {
selected.plugins.push(SelectedPluginIdentity {
selected_root_id: Some(selected_root_id),
plugin_id,
plugin_id: identity,
});
}
}
if context.config().features.enabled(Feature::Plugins) {
for plugin_id in contributor.hosted_plugin_ids(context) {
if !disabled_plugin_ids.contains(&plugin_id) {
selected.plugins.push(SelectedPluginIdentity {
selected_root_id: None,
plugin_id: CatalogPluginIdentity::Remote {
remote_plugin_id: plugin_id,
},
});
}
}
}
}
selected
}
@@ -245,6 +269,7 @@ impl McpManager {
let mut selected_plugin_registrations = Vec::new();
let mut selected_plugins = Vec::new();
let mut disabled_plugin_roots = Vec::new();
let mut suppressed_plugin_ids = disabled_plugin_ids.to_vec();
let mut overlays = Vec::new();
// A contributor can emit multiple ordered actions, so order each action globally rather
// than enumerating contributors.
@@ -256,22 +281,27 @@ impl McpManager {
for SelectedPlugin {
selected_root_id,
plugin_id,
identity,
enabled,
mcp,
} in contributor.selected_plugins(context, plugins_config).await
{
if !plugins_config.allows_plugin(&plugin_id) {
if !enabled || !plugins_config.allows_plugin(&plugin_id) {
disabled_plugin_roots.push(selected_root_id);
suppressed_plugin_ids.push(plugin_id);
continue;
}
let selection_order = plugin_selection_order;
plugin_selection_order += 1;
let disabled = disabled_plugin_ids.contains(&plugin_id);
let disabled = disabled_plugin_ids.contains(&plugin_id)
|| disabled_plugin_ids.iter().any(|id| id == identity.as_str());
if !config.features.enabled(Feature::Plugins) || disabled {
disabled_plugin_roots.push(selected_root_id);
suppressed_plugin_ids.push(plugin_id.clone());
} else {
selected_plugins.push(SelectedPluginIdentity {
selected_root_id: Some(selected_root_id),
plugin_id: plugin_id.clone(),
plugin_id: identity,
});
}
let contribution = mcp.await;
@@ -355,7 +385,9 @@ impl McpManager {
if !disabled_plugin_ids.contains(&plugin_id) {
selected_plugins.push(SelectedPluginIdentity {
selected_root_id: None,
plugin_id: plugin_id.clone(),
plugin_id: CatalogPluginIdentity::Remote {
remote_plugin_id: plugin_id.clone(),
},
});
}
if !connector_ids.is_empty() {
@@ -456,6 +488,7 @@ impl McpManager {
selected_plugins: SelectedPluginSnapshot {
plugins: selected_plugins,
disabled_plugin_roots,
disabled_plugin_ids: suppressed_plugin_ids,
},
}
}
@@ -482,3 +515,26 @@ impl McpManager {
effective_mcp_servers(&mcp_config, auth)
}
}
fn available_plugin_roots(context: McpServerContributionContext<'_, Config>) -> HashSet<String> {
let mut roots = context
.ready_selected_capability_roots()
.unwrap_or_default()
.iter()
.map(|root| root.id.clone())
.collect::<HashSet<_>>();
for snapshot in context
.executor_capability_discovery_v2()
.unwrap_or_default()
{
if let Ok(inventory) = &snapshot.result {
roots.extend(
inventory
.plugins
.iter()
.map(|plugin| format!("{}:{}", snapshot.environment_id, plugin.id)),
);
}
}
roots
}
@@ -79,7 +79,9 @@ async fn active_plugin_ids_select_and_validate_by_source() {
.into_iter()
.map(|plugin_id| SelectedPluginIdentity {
selected_root_id: Some("capability-root".to_string()),
plugin_id: plugin_id.to_string(),
plugin_id: codex_core_plugins::PluginIdentity::Local {
plugin_id: plugin_id.to_string(),
},
})
.collect::<Vec<_>>();
turn_context.active_host_plugin_identities = Some(identities);
@@ -125,7 +127,9 @@ async fn active_plugin_ids_enforce_bounds_after_selection_and_deduplication() {
let id = format!("{}@local", "p".repeat(length - 6));
let selected_plugin = SelectedPluginIdentity {
selected_root_id: Some("capability-root".to_string()),
plugin_id: id.clone(),
plugin_id: codex_core_plugins::PluginIdentity::Local {
plugin_id: id.clone(),
},
};
turn_context.active_host_plugin_identities = Some(Vec::new());
let selected_plugins = SelectedPluginSnapshot {
@@ -154,7 +158,9 @@ async fn active_plugin_ids_enforce_bounds_after_selection_and_deduplication() {
.iter()
.map(|id| SelectedPluginIdentity {
selected_root_id: Some("capability-root".to_string()),
plugin_id: id.clone(),
plugin_id: codex_core_plugins::PluginIdentity::Local {
plugin_id: id.clone(),
},
})
.collect::<Vec<_>>();
turn_context.active_host_plugin_identities = Some(identities);
+18 -5
View File
@@ -597,12 +597,25 @@ impl TurnContext {
const MAX_TELEMETRY_PLUGIN_ID_BYTES: usize = 128;
let mut identities = self.active_host_plugin_identities.clone()?;
// Selected roots provide a package key, not a remote identity for that object.
// Selected catalogs preserve local package keys or canonical remote identities.
if let Some(selected) = selected {
identities.extend(selected.plugins.iter().map(|plugin| PluginIdentity {
plugin_id: plugin.plugin_id.clone(),
remote_plugin_id: None,
}));
identities.extend(
selected
.plugins
.iter()
.map(|plugin| match &plugin.plugin_id {
codex_core_plugins::PluginIdentity::Local { plugin_id } => PluginIdentity {
plugin_id: plugin_id.clone(),
remote_plugin_id: None,
},
codex_core_plugins::PluginIdentity::Remote { remote_plugin_id } => {
PluginIdentity {
plugin_id: String::new(),
remote_plugin_id: Some(remote_plugin_id.clone()),
}
}
}),
);
}
let mut ids = Vec::with_capacity(identities.len());
for identity in identities {
@@ -1675,6 +1675,10 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
vec![codex_extension_api::SelectedPlugin {
selected_root_id: selected_root_id.clone(),
plugin_id: plugin_id.clone(),
identity: codex_core_plugins::PluginIdentity::Local {
plugin_id: plugin_id.clone(),
},
enabled: true,
mcp: Box::pin(async move {
mcp_loaded.fetch_add(1, Ordering::Relaxed);
codex_extension_api::SelectedPluginContribution {
@@ -4,14 +4,19 @@ use anyhow::Context;
use anyhow::Result;
use codex_core::config::Config;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core_plugins::PluginIdentity;
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
use codex_exec_server::HttpRequestBodyDeltaNotification;
use codex_exec_server::HttpRequestParams;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::McpServerContributor;
use codex_extension_api::SelectedPluginSnapshot;
use codex_extension_api::WorldStateContributionInput;
use codex_extension_api::WorldStateSectionContribution;
use codex_features::Feature;
use codex_protocol::config_types::WindowsSandboxLevel;
use core_test_support::responses;
@@ -33,6 +38,7 @@ use tokio_util::task::AbortOnDropHandle;
struct DiscoveryRecorder {
snapshots: Arc<Mutex<Vec<ExecutorCapabilityDiscoveryV2Snapshot>>>,
selected_plugins: Arc<Mutex<Option<SelectedPluginSnapshot>>>,
}
impl McpServerContributor<Config> for DiscoveryRecorder {
@@ -42,12 +48,32 @@ impl McpServerContributor<Config> for DiscoveryRecorder {
fn contribute<'a>(
&'a self,
context: McpServerContributionContext<'a, Config>,
_context: McpServerContributionContext<'a, Config>,
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
Box::pin(async {
vec![McpServerContribution::HostedPluginConnectors {
plugin_id: "plugin_sentinel".to_string(),
plugin_display_name: "Inert sentinel".to_string(),
connector_ids: Vec::new(),
}]
})
}
fn hosted_plugin_ids(&self, _context: McpServerContributionContext<'_, Config>) -> Vec<String> {
vec!["plugin_sentinel".to_string()]
}
fn selected_plugins<'a>(
&'a self,
context: McpServerContributionContext<'a, Config>,
_plugins_config: &'a codex_config::types::PluginsConfigToml,
) -> ExtensionFuture<'a, Vec<codex_extension_api::SelectedPlugin<'a>>> {
Box::pin(async move {
let mut package_roots = vec!["unavailable-root".to_string()];
let selected_roots = context
.ready_selected_capability_roots()
.unwrap_or_default();
package_roots.extend(selected_roots.iter().map(|root| root.id.clone()));
if !selected_roots.is_empty() {
let legacy = context.executor_capability_discovery();
assert_eq!(
@@ -60,6 +86,49 @@ impl McpServerContributor<Config> for DiscoveryRecorder {
}
if let Some(snapshots) = context.executor_capability_discovery_v2() {
self.snapshots.lock().await.extend_from_slice(snapshots);
for snapshot in snapshots {
if let Ok(inventory) = &snapshot.result {
package_roots.extend(
inventory
.plugins
.iter()
.map(|plugin| format!("{}:{}", snapshot.environment_id, plugin.id)),
);
}
}
}
package_roots
.into_iter()
.map(|selected_root_id| codex_extension_api::SelectedPlugin {
selected_root_id,
identity: PluginIdentity::Local {
plugin_id: "inert-sentinel@market".to_string(),
},
plugin_id: "inert-sentinel@market".to_string(),
enabled: true,
mcp: Box::pin(async {
codex_extension_api::SelectedPluginContribution {
plugin_display_name: "Inert sentinel".to_string(),
source_environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID
.to_string(),
servers: Vec::new(),
connector_ids: Vec::new(),
}
}),
})
.collect()
})
}
}
impl ContextContributor for DiscoveryRecorder {
fn contribute_world_state<'a>(
&'a self,
input: WorldStateContributionInput<'a>,
) -> ExtensionFuture<'a, Vec<WorldStateSectionContribution>> {
Box::pin(async move {
if let Some(snapshot) = input.step_store.get::<SelectedPluginSnapshot>() {
*self.selected_plugins.lock().await = Some(snapshot.as_ref().clone());
}
Vec::new()
})
@@ -274,16 +343,20 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> {
let executor = Executor::start(supported, DiscoveryFailure::None).await?;
let model = responses::start_mock_server().await;
let observed = Arc::new(Mutex::new(Vec::new()));
let selected_plugins = Arc::new(Mutex::new(None));
let mut extensions = ExtensionRegistryBuilder::new();
let recorder = Arc::new(DiscoveryRecorder {
snapshots: Arc::clone(&observed),
selected_plugins: Arc::clone(&selected_plugins),
});
extensions.prompt_contributor(recorder.clone());
extensions.mcp_server_contributor(recorder);
let test = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_exec_server_url(executor.url.clone())
.with_config(move |config| {
config.update_plan_enabled = true;
assert!(config.features.enable(Feature::Plugins).is_ok());
assert!(
config
.features
@@ -345,19 +418,24 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> {
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: vec![codex_protocol::capabilities::SelectedCapabilityRoot {
id: "legacy-root".to_string(),
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: cwd.clone(),
},
}],
selected_capability_roots: if acquires_v2 {
Vec::new()
} else {
vec![codex_protocol::capabilities::SelectedCapabilityRoot {
id: "legacy-root".to_string(),
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: cwd.clone(),
},
}]
},
});
// Only the successful lifecycle case needs two turns and multiple model steps.
let turns = if acquires_v2 { 2 } else { 1 };
let mut previous_inventory = None;
for turn in 0..turns {
observed.lock().await.clear();
*selected_plugins.lock().await = None;
let before_turn = calls().await;
let mut bodies = Vec::new();
if acquires_v2 {
@@ -381,6 +459,37 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> {
}
test.submit_turn_with_environments("Say hello.", Some(vec![selection.clone()]))
.await?;
let executor_root = if acquires_v2 {
format!("{}:inert-sentinel@market", selection.environment_id)
} else {
"legacy-root".to_string()
};
let local_identity = PluginIdentity::Local {
plugin_id: "inert-sentinel@market".to_string(),
};
// Both paths publish accepted Cloud packages and available installations only.
let expected_plugins = vec![
(Some(executor_root), local_identity),
(
None,
PluginIdentity::Remote {
remote_plugin_id: "plugin_sentinel".to_string(),
},
),
];
{
let selected_plugins = selected_plugins.lock().await;
let snapshot = selected_plugins.as_ref().context("turn plugin snapshot")?;
assert_eq!(
snapshot
.plugins
.iter()
.map(|plugin| (plugin.selected_root_id.clone(), plugin.plugin_id.clone()))
.collect::<Vec<_>>(),
expected_plugins,
);
assert_eq!(snapshot.disabled_plugin_ids, Vec::<String>::new());
}
let model_requests = responses
.iter()
.flat_map(core_test_support::responses::ResponseMock::requests)
@@ -426,11 +535,13 @@ async fn check_acquisition(enabled: bool, supported: bool) -> Result<()> {
}
}
let requests = executor.requests.lock().await.clone();
assert!(
requests
.iter()
.any(|request| request["method"] == "capabilityRoots/discoverV1")
);
if !acquires_v2 {
assert!(
requests
.iter()
.any(|request| request["method"] == "capabilityRoots/discoverV1")
);
}
let discovery = requests
.iter()
.filter(|request| request["method"] == "capabilities/discoverV2")
+1
View File
@@ -14,6 +14,7 @@ doctest = false
workspace = true
[dependencies]
codex-core-plugin-common = { workspace = true }
codex-api = { workspace = true }
futures = { workspace = true }
codex-guardian-context = { workspace = true }
@@ -90,6 +90,11 @@ pub trait McpServerContributor<C: Sync>: Send + Sync {
context: McpServerContributionContext<'a, C>,
) -> ExtensionFuture<'a, Vec<McpServerContribution>>;
/// Reads canonical hosted-plugin IDs from published state without discovery or MCP I/O.
fn hosted_plugin_ids(&self, _context: McpServerContributionContext<'_, C>) -> Vec<String> {
Vec::new()
}
/// Declares executor plugins, including those without MCP servers or connectors. Each
/// declaration identifies the plugin once; its deferred MCP data cannot change that identity.
/// Return plugins in precedence order: across contributors in registration order, the first
@@ -1,4 +1,5 @@
use codex_config::McpServerConfig;
use codex_core_plugin_common::PluginIdentity;
use codex_exec_server_protocol::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server_protocol::ExecutorCapabilityDiscoveryV2Snapshot;
use codex_protocol::capabilities::SelectedCapabilityRoot;
@@ -151,7 +152,7 @@ impl<'a, C> McpServerContributionContext<'a, C> {
self.ready_selected_capability_roots
}
/// Returns the V2 inventories supplied by the host, when available.
/// `Some` selects V2, including an empty inventory list; `None` selects legacy discovery.
pub fn executor_capability_discovery_v2(
&self,
) -> Option<&'a [ExecutorCapabilityDiscoveryV2Snapshot]> {
@@ -170,6 +171,7 @@ pub struct SelectedPluginSnapshot {
pub plugins: Vec<SelectedPluginIdentity>,
/// Selected plugin roots suppressed by the effective plugin feature policy.
pub disabled_plugin_roots: Vec<String>,
pub disabled_plugin_ids: Vec<String>,
}
/// The configured identity of a selected plugin and, when present, the root owning its skills.
@@ -177,7 +179,7 @@ pub struct SelectedPluginSnapshot {
pub struct SelectedPluginIdentity {
/// Hosted plugins have no executor root and cannot own skills from an executor folder.
pub selected_root_id: Option<String>,
pub plugin_id: String,
pub plugin_id: PluginIdentity,
}
/// An executor plugin and its deferred MCP data. Callers that only need the identity can drop
@@ -186,6 +188,8 @@ pub struct SelectedPluginIdentity {
pub struct SelectedPlugin<'a> {
pub selected_root_id: String,
pub plugin_id: String,
pub identity: PluginIdentity,
pub enabled: bool,
pub mcp: ExtensionFuture<'a, SelectedPluginContribution>,
}
+3 -2
View File
@@ -21,8 +21,8 @@ use std::collections::HashSet;
use std::sync::Arc;
const MAX_PROJECTED_CLOUD_PLUGINS: usize = 100;
const MAX_PLUGIN_DISPLAY_NAME_CHARS: usize = 64;
const MAX_CONNECTOR_IDS_PER_PLUGIN: usize = 64;
pub(crate) const MAX_PLUGIN_DISPLAY_NAME_CHARS: usize = 64;
pub(crate) const MAX_CONNECTOR_IDS_PER_PLUGIN: usize = 64;
impl ThreadLifecycleContributor<Config> for PluginContributor {
fn on_thread_start<'a>(
@@ -123,6 +123,7 @@ pub(crate) fn hosted_plugin_connectors(catalog: &PluginCatalog) -> Vec<McpServer
catalog
.entries
.iter()
.filter(|entry| entry.enabled)
.filter_map(|entry| {
let PluginIdentity::Remote { remote_plugin_id } = &entry.id else {
return None;
+94 -3
View File
@@ -275,7 +275,12 @@ impl ContextContributor for StepPackages {
snapshot
.plugins
.iter()
.map(|plugin| (plugin.plugin_id.clone(), plugin.selected_root_id.clone()))
.map(|plugin| {
(
plugin.plugin_id.as_str().to_string(),
plugin.selected_root_id.clone(),
)
})
.collect()
});
Vec::new()
@@ -331,11 +336,12 @@ impl PluginProvider for CloudProvider {
fn cloud_catalog() -> PluginCatalog {
PluginCatalog {
entries: vec![PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: "plugin_remote".to_string(),
},
display_name: "Cloud Demo".to_string(),
version: Some("1.0.0".to_string()),
version: "1.0.0".to_string(),
mcp_servers: Default::default(),
connector_ids: vec!["calendar".to_string()],
locations: vec![codex_core_plugins::PluginSourceLocation::Cloud {
@@ -343,7 +349,7 @@ fn cloud_catalog() -> PluginCatalog {
bundle_uri: None,
}],
}],
warnings: Vec::new(),
..Default::default()
}
}
@@ -384,6 +390,15 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
};
gmail_catalog.entries[0].display_name = "Gmail Demo".to_string();
gmail_catalog.entries[0].connector_ids = vec!["gmail".to_string()];
let mut disabled_catalog = cloud_catalog();
disabled_catalog.entries[0].enabled = false;
let mut partial_catalog = gmail_catalog.clone();
partial_catalog.is_partial = true;
let partial_empty_catalog = PluginCatalog {
is_partial: true,
warnings: vec!["resource listing was truncated".to_string()],
..Default::default()
};
let provider = Arc::new(CloudProvider::default());
let step_packages = Arc::new(StepPackages::default());
let mut extensions = ExtensionRegistryBuilder::new();
@@ -421,6 +436,30 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
let calendar = ("calendar", "list_events", "Cloud Demo");
let gmail = ("gmail", "search_email", "Gmail Demo");
for (turn, reply, expected_plugin, new_account, tool_call) in [
(
"initial-partial",
Ok(partial_catalog.clone()),
Some("plugin_gmail"),
None,
None,
),
(
"partial-replaces-partial",
Ok(PluginCatalog {
is_partial: true,
..cloud_catalog()
}),
Some("plugin_remote"),
None,
Some(calendar),
),
(
"partial-empty-replaces-partial",
Ok(partial_empty_catalog.clone()),
None,
None,
None,
),
(
"empty",
Ok(PluginCatalog::default()),
@@ -428,6 +467,13 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
None,
Some(calendar),
),
(
"partial-after-complete-empty",
Ok(partial_catalog.clone()),
None,
None,
None,
),
(
"discovered",
Ok(cloud_catalog()),
@@ -435,6 +481,20 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
None,
Some(calendar),
),
(
"partial-refresh",
Ok(partial_catalog.clone()),
Some("plugin_remote"),
None,
Some(calendar),
),
(
"partial-empty-refresh",
Ok(partial_empty_catalog),
Some("plugin_remote"),
None,
None,
),
(
"same-account-failure",
Err("temporarily unavailable".to_string()),
@@ -442,6 +502,30 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
None,
None,
),
(
"complete-with-warning-disables-plugin",
Ok(PluginCatalog {
warnings: vec!["optional bundle hint ignored".to_string()],
..disabled_catalog
}),
None,
None,
None,
),
(
"partial-after-disable",
Ok(partial_catalog.clone()),
None,
None,
None,
),
(
"re-enabled",
Ok(cloud_catalog()),
Some("plugin_remote"),
None,
None,
),
("removed", Ok(PluginCatalog::default()), None, None, None),
(
"restored",
@@ -457,6 +541,13 @@ async fn run_cloud_catalog_refresh_lifecycle() -> anyhow::Result<()> {
Some("account-b"),
None,
),
(
"new-account-partial",
Ok(partial_catalog),
Some("plugin_gmail"),
None,
Some(gmail),
),
(
"new-account-discovered",
Ok(gmail_catalog),
+109
View File
@@ -2,8 +2,12 @@ use codex_config::types::PluginsConfigToml;
use codex_connectors_extension::PluginAppProvider;
use codex_core::config::Config;
use codex_core_plugins::ExecutorPluginProviderError;
use codex_core_plugins::PluginIdentity;
use codex_core_plugins::PluginRootOwnership;
use codex_core_plugins::PluginSourceLocation;
use codex_core_plugins::configured_plugins_from_stack;
use codex_core_plugins::loader::apply_configured_plugin_mcp_server_policies;
use codex_core_plugins::merge_catalogs;
use codex_exec_server::CapabilityRootDiscovery;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::ExtensionFuture;
@@ -210,6 +214,36 @@ impl McpServerContributor<Config> for PluginContributor {
})
}
fn hosted_plugin_ids(&self, context: McpServerContributionContext<'_, Config>) -> Vec<String> {
if self.providers.cloud.is_none() || !context.config().features.enabled(Feature::Plugins) {
return Vec::new();
}
let Some(state) = context
.thread_store()
.and_then(codex_extension_api::ExtensionData::get::<PluginsThreadState>)
else {
return Vec::new();
};
state
.contributor_state()
.cloud_catalog()
.map(|catalog| {
hosted_plugin_connectors(catalog)
.into_iter()
.filter_map(|contribution| {
if let McpServerContribution::HostedPluginConnectors { plugin_id, .. } =
contribution
{
Some(plugin_id)
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
fn selected_plugins<'a>(
&'a self,
context: McpServerContributionContext<'a, Config>,
@@ -228,6 +262,69 @@ impl McpServerContributor<Config> for PluginContributor {
.ready_selected_capability_roots()
.unwrap_or_default();
let mut plugins = Vec::new();
if let Some(snapshots) = context.executor_capability_discovery_v2() {
let merged = {
let mut cache = state.contributor_state();
cache.update_executor_catalog(snapshots);
let cloud = (self.providers.cloud.is_some()
&& context.config().features.enabled(Feature::Plugins))
.then(|| cache.cloud_catalog())
.flatten();
merge_catalogs(cache.executor_catalog.as_ref(), cloud)
};
for warning in &merged.warnings {
tracing::warn!(%warning, "plugin catalog merge reported a warning");
}
let configured = configured_plugins_from_stack(
&context.config().config_layer_stack,
context.config().codex_home.as_path(),
);
for entry in merged.entries {
for location in &entry.locations {
let PluginSourceLocation::Executor {
environment_id,
plugin_id,
..
} = location
else {
continue;
};
let selected_root_id = format!("{environment_id}:{plugin_id}");
let root_id = selected_root_id.clone();
let plugin_id = plugin_id.clone();
let metadata_plugin_id = plugin_id.clone();
let environment_id = environment_id.clone();
let captured = entry.clone();
plugins.push(SelectedPlugin {
selected_root_id,
plugin_id,
identity: entry.id.clone(),
enabled: entry.is_enabled(&configured),
mcp: Box::pin(async move {
let metadata = discovery::v2_metadata_from_catalog(&captured)
.into_iter()
.find(|(id, _)| id == &root_id)
.map(|(_, metadata)| metadata)
.unwrap_or_default();
let contribution = SelectedPluginContribution {
plugin_display_name: metadata.plugin_display_name,
source_environment_id: environment_id,
servers: metadata.servers,
connector_ids: metadata.connector_ids,
};
project_metadata(
context.config(),
plugins_config,
&metadata_plugin_id,
contribution,
)
}),
});
}
}
return plugins;
}
state.contributor_state().update_executor_catalog(&[]);
if let Some(snapshot) = context.executor_capability_discovery() {
for root in snapshot.roots() {
@@ -270,6 +367,10 @@ impl McpServerContributor<Config> for PluginContributor {
plugins.push(SelectedPlugin {
selected_root_id: root.selected_root.id.clone(),
plugin_id: root.selected_root.id.clone(),
identity: PluginIdentity::Local {
plugin_id: root.selected_root.id.clone(),
},
enabled: true,
mcp: Box::pin(async move {
let metadata = discovery::metadata_from_discovery(
&root.selected_root,
@@ -309,6 +410,10 @@ impl McpServerContributor<Config> for PluginContributor {
plugins.push(SelectedPlugin {
selected_root_id: selected_root.id.clone(),
plugin_id: selected_root.id.clone(),
identity: PluginIdentity::Local {
plugin_id: selected_root.id.clone(),
},
enabled: true,
mcp: Box::pin(async move {
project_metadata(
context.config(),
@@ -336,6 +441,10 @@ fn denied_plugin(selected_root: &SelectedCapabilityRoot) -> SelectedPlugin<'stat
SelectedPlugin {
selected_root_id: selected_root.id.clone(),
plugin_id: selected_root.id.clone(),
identity: PluginIdentity::Local {
plugin_id: selected_root.id.clone(),
},
enabled: false,
mcp: Box::pin(async move { metadata }),
}
}
+85 -1
View File
@@ -1,13 +1,21 @@
use crate::cloud_plugin::MAX_CONNECTOR_IDS_PER_PLUGIN;
use crate::cloud_plugin::MAX_PLUGIN_DISPLAY_NAME_CHARS;
use crate::plugin_contributor_state::SelectedPluginMetadata;
use codex_connectors::parse_plugin_app_config;
use codex_core_plugins::PluginCatalogEntry;
use codex_core_plugins::PluginSourceLocation;
use codex_core_plugins::manifest::UriPluginManifest;
use codex_core_plugins::manifest::parse_plugin_manifest_uri;
use codex_exec_server::CapabilityRootDiscovery;
use codex_exec_server::DiscoveredPluginFiles;
use codex_extension_api::SelectedPluginContribution;
use codex_mcp::PluginMcpConfigParseOutcome;
use codex_mcp::normalize_executor_plugin_mcp_declarations;
use codex_mcp::parse_executor_plugin_mcp_config;
use codex_plugin::manifest::PluginManifestMcpServers;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use std::collections::HashSet;
pub(super) fn manifest_from_discovery<'a>(
selected_root: &SelectedCapabilityRoot,
@@ -134,6 +142,71 @@ pub(super) fn metadata_from_discovery(
}
}
pub(super) fn v2_metadata_from_catalog(
entry: &PluginCatalogEntry,
) -> Vec<(String, SelectedPluginMetadata)> {
let plugin_display_name: String = entry
.display_name
.chars()
.take(MAX_PLUGIN_DISPLAY_NAME_CHARS)
.collect();
let mut seen_connector_ids = HashSet::new();
let connector_ids: Vec<String> = entry
.connector_ids
.iter()
.filter(|id| seen_connector_ids.insert(id.as_str()))
.take(MAX_CONNECTOR_IDS_PER_PLUGIN)
.cloned()
.collect();
let installations = entry
.locations
.iter()
.filter_map(|location| match location {
PluginSourceLocation::Executor {
environment_id,
plugin_id,
root,
} => Some((environment_id, plugin_id, root)),
PluginSourceLocation::Cloud { .. } => None,
});
let mut projections = Vec::new();
for (index, (environment_id, plugin_id, root)) in installations.enumerate() {
let root_id = format!("{environment_id}:{plugin_id}");
// Only the first retained installation supplies MCPs; the others retain package attribution.
let servers = if index == 0 {
let parsed = normalize_executor_plugin_mcp_declarations(
root,
entry.mcp_servers.clone(),
environment_id,
);
servers_from_outcome(&root_id, parsed)
} else {
Vec::new()
};
let metadata = SelectedPluginMetadata {
plugin_display_name: plugin_display_name.clone(),
servers,
connector_ids: connector_ids.clone(),
};
projections.push((root_id, metadata));
}
if entry
.locations
.iter()
.any(|location| matches!(location, PluginSourceLocation::Cloud { .. }))
{
projections.push((
format!("cloud:{}", entry.id.as_str()),
SelectedPluginMetadata {
plugin_display_name,
servers: Vec::new(),
connector_ids,
},
));
}
projections
}
fn parse_mcp_servers(
selected_root: &SelectedCapabilityRoot,
plugin_root: &codex_utils_path_uri::PathUri,
@@ -151,9 +224,16 @@ fn parse_mcp_servers(
return Vec::new();
}
};
servers_from_outcome(&selected_root.id, parsed)
}
fn servers_from_outcome(
selected_root_id: &str,
parsed: PluginMcpConfigParseOutcome,
) -> Vec<(String, codex_config::McpServerConfig)> {
for error in parsed.errors {
tracing::warn!(
selected_root = selected_root.id,
selected_root = selected_root_id,
server = error.name,
error = error.message,
"ignoring invalid exec-server-discovered MCP server"
@@ -161,3 +241,7 @@ fn parse_mcp_servers(
}
parsed.servers.into_iter().collect()
}
#[cfg(test)]
#[path = "discovery_tests.rs"]
mod tests;
@@ -0,0 +1,134 @@
//! Verifies merged MCPs use the first retained installation while preserving all package identities.
use super::*;
use codex_config::McpServerTransportConfig;
use codex_config::types::PluginsConfigToml;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginIdentity;
use codex_extension_api::SelectedPluginContribution;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn merged_mcp_declarations_require_an_executor_location() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![("features.plugins".to_string(), true.into())])
.build()
.await?;
let mut entry = PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: "plugin_example".to_string(),
},
display_name: "Example".to_string(),
version: "1".to_string(),
mcp_servers: std::collections::BTreeMap::from([(
"example".to_string(),
serde_json::json!({"command":"node","args":["server.js"],"cwd":"mcp"}),
)]),
connector_ids: vec!["connector_example".to_string()],
locations: vec![PluginSourceLocation::Cloud {
resource_uri: "plugin://plugin_example".to_string(),
bundle_uri: None,
}],
};
let projections = v2_metadata_from_catalog(&entry);
let [(root_id, metadata)] = projections.as_slice() else {
panic!("one cloud projection expected");
};
assert_eq!(root_id, "cloud:plugin_example");
assert!(metadata.servers.is_empty());
assert_eq!(metadata.connector_ids, vec!["connector_example"]);
// Attribution keeps the installation key; server configs distinguish the executors.
let installations = [
("executor-b", "example@acme", "/other/example/1"),
("executor-a", "example@acme", "/plugins/example/1"),
];
// Retained catalog order, not lexical environment order, chooses the executor.
for (environment_id, plugin_id, root) in installations {
entry.locations.push(PluginSourceLocation::Executor {
environment_id: environment_id.to_string(),
plugin_id: plugin_id.to_string(),
root: PathUri::parse(&format!("file://{root}"))?,
});
}
let projections = v2_metadata_from_catalog(&entry);
assert_eq!(projections.len(), installations.len() + 1);
for (index, ((root_id, metadata), (environment_id, plugin_id, root))) in
projections.iter().zip(installations).enumerate()
{
assert_eq!(root_id, &format!("{environment_id}:{plugin_id}"));
let contribution = super::super::project_metadata(
&config,
&PluginsConfigToml::default(),
plugin_id,
SelectedPluginContribution {
plugin_display_name: metadata.plugin_display_name.clone(),
source_environment_id: environment_id.to_string(),
servers: metadata.servers.clone(),
connector_ids: metadata.connector_ids.clone(),
},
);
assert_eq!(contribution.connector_ids, entry.connector_ids);
if index != 0 {
assert!(contribution.servers.is_empty());
continue;
}
let [(name, mcp_config)] = contribution.servers.as_slice() else {
panic!("one MCP contribution expected");
};
assert_eq!(mcp_config.environment_id, environment_id);
assert_eq!(name, "example");
let McpServerTransportConfig::Stdio { command, cwd, .. } = &mcp_config.transport else {
panic!("stdio expected");
};
assert_eq!(command, "node");
assert_eq!(
cwd.as_ref()
.map(codex_utils_path_uri::LegacyAppPathString::as_str),
Some(format!("{root}/mcp").as_str())
);
}
let (root_id, metadata) = &projections[installations.len()];
assert_eq!(root_id, "cloud:plugin_example");
assert!(metadata.servers.is_empty());
assert_eq!(metadata.connector_ids, vec!["connector_example"]);
// Bound projected metadata without mutating the source catalog or losing installations.
entry.display_name = "界".repeat(MAX_PLUGIN_DISPLAY_NAME_CHARS + 1);
entry.connector_ids = (0..=MAX_CONNECTOR_IDS_PER_PLUGIN)
.flat_map(|index| vec![format!("connector_{index}"); 2])
.collect();
let expected_connectors = (0..MAX_CONNECTOR_IDS_PER_PLUGIN)
.map(|index| format!("connector_{index}"))
.collect::<Vec<_>>();
entry.mcp_servers.clear();
let projections = v2_metadata_from_catalog(&entry);
assert_eq!(projections.len(), installations.len() + 1);
assert!(
projections
.iter()
.all(|(_, metadata)| metadata.servers.is_empty())
);
for (_, metadata) in projections {
assert_eq!(
metadata.plugin_display_name,
"界".repeat(MAX_PLUGIN_DISPLAY_NAME_CHARS)
);
assert_eq!(metadata.connector_ids, expected_connectors);
}
assert_eq!(
entry.display_name.chars().count(),
MAX_PLUGIN_DISPLAY_NAME_CHARS + 1
);
assert_eq!(
entry.connector_ids.len(),
(MAX_CONNECTOR_IDS_PER_PLUGIN + 1) * 2
);
Ok(())
}
@@ -2,12 +2,15 @@
use codex_core_plugins::PluginCatalog;
use codex_core_plugins::PluginRootOwnership;
use codex_core_plugins::catalog_from_snapshots;
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::SelectedPluginContribution;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpResourceClient;
use codex_mcp::McpResourceClientAuthKey;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::MutexGuard;
@@ -35,6 +38,8 @@ impl PluginsThreadState {
pub(crate) struct PluginContributorState {
pub(crate) plugins_enabled: bool,
pub(crate) cloud_generation: Option<CloudPluginGeneration>,
pub(crate) executor_catalog: Option<PluginCatalog>,
executor_snapshots: Vec<ExecutorCapabilityDiscoveryV2Snapshot>,
pub(crate) executor_cache: Vec<CachedSelectedRoot>,
}
@@ -67,8 +72,36 @@ impl CloudPluginGeneration {
}
impl PluginContributorState {
/// Reuses unchanged discovery and preserves surviving installations' order on refresh.
pub(crate) fn update_executor_catalog(
&mut self,
snapshots: &[ExecutorCapabilityDiscoveryV2Snapshot],
) {
if self.executor_catalog.is_some() && self.executor_snapshots == snapshots {
return;
}
let mut catalog = catalog_from_snapshots(snapshots);
if let Some(previous) = &self.executor_catalog {
// Preserve installation order to keep MCPs on the same executor while it remains available.
let mut previous_ranks = HashMap::with_capacity(previous.entries.len());
for (rank, entry) in previous.entries.iter().enumerate() {
previous_ranks
.entry((&entry.id, entry.locations.as_slice()))
.or_insert(rank);
}
catalog.entries.sort_by_key(|entry| {
previous_ranks
.get(&(&entry.id, entry.locations.as_slice()))
.copied()
.unwrap_or(usize::MAX)
});
}
self.executor_catalog = Some(catalog);
self.executor_snapshots = snapshots.to_vec();
}
/// Returns the cloud catalog only while it belongs to the live auth generation.
fn cloud_catalog(&self) -> Option<&PluginCatalog> {
pub(crate) fn cloud_catalog(&self) -> Option<&PluginCatalog> {
self.cloud_generation
.as_ref()
.filter(|generation| generation.is_current())
@@ -105,6 +138,14 @@ impl PluginContributorState {
else {
return false;
};
if catalog.is_partial
&& generation
.catalog
.as_ref()
.is_some_and(|previous| !previous.is_partial)
{
return true;
}
generation.catalog = Some(catalog);
true
}
@@ -122,3 +163,15 @@ pub(crate) enum CachedPluginMetadata {
Unloaded,
Loaded(Option<SelectedPluginContribution>),
}
/// Bounded declarations projected from merged plugin metadata.
#[derive(Clone, Default)]
pub(crate) struct SelectedPluginMetadata {
pub(crate) plugin_display_name: String,
pub(crate) servers: Vec<(String, codex_config::McpServerConfig)>,
pub(crate) connector_ids: Vec<String>,
}
#[cfg(test)]
#[path = "plugin_contributor_state_tests.rs"]
mod tests;
@@ -0,0 +1,463 @@
//! Tests thread-owned discovery caching, installation ordering, and contribution projection.
use super::*;
use crate::PluginProviders;
use crate::plugin_contributor::PluginContributor;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::ExecutorPluginProvider;
use codex_core_plugins::PluginCatalog;
use codex_core_plugins::PluginCatalogEntry;
use codex_core_plugins::PluginIdentity;
use codex_core_plugins::PluginProvider;
use codex_core_plugins::PluginSourceLocation;
use codex_exec_server::CapabilityTextFile;
use codex_exec_server::DiscoverV2CapabilitiesResponse;
use codex_exec_server::DiscoveredPluginFiles;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorCapabilityDiscoveryCache;
use codex_exec_server::ExecutorPlugin;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::McpServerContributor;
use codex_features::Feature;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::sync::Arc;
async fn contribution_labels(
contributor: &PluginContributor,
context: McpServerContributionContext<'_, codex_core::config::Config>,
) -> Vec<String> {
let mut labels = Vec::new();
let policy = codex_config::types::PluginsConfigToml::default();
for plugin in contributor.selected_plugins(context, &policy).await {
if !plugin.enabled {
labels.push(format!("disabled:{}", plugin.plugin_id));
continue;
}
let contribution = plugin.mcp.await;
labels.extend(
contribution
.servers
.into_iter()
.map(|(name, _)| format!("mcp:{name}")),
);
labels.push(plugin.selected_root_id);
}
for contribution in contributor.contribute(context).await {
if let McpServerContribution::HostedPluginConnectors { plugin_id, .. } = contribution {
labels.push(format!("cloud:{plugin_id}"));
} else {
panic!("unexpected contribution: {contribution:?}");
}
}
labels
}
fn cloud_plugin(remote_id: &str) -> PluginCatalogEntry {
PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: remote_id.to_string(),
},
display_name: "Example".to_string(),
version: "1.0.0".to_string(),
mcp_servers: BTreeMap::from([(
"example".to_string(),
serde_json::json!({"command":"node","args":["server.js"]}),
)]),
connector_ids: vec!["connector_example".to_string()],
locations: vec![PluginSourceLocation::Cloud {
resource_uri: format!("plugin://{remote_id}"),
bundle_uri: None,
}],
}
}
fn executor_snapshot(
environment_id: &str,
) -> anyhow::Result<ExecutorCapabilityDiscoveryV2Snapshot> {
let root = PathUri::parse("file:///plugins/example/1.0.0")?;
Ok(ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: environment_id.to_string(),
result: Ok(Arc::new(DiscoverV2CapabilitiesResponse {
plugins: vec![ExecutorPlugin {
id: "example@marketplace".to_string(),
display_name: "Example".to_string(),
remote_plugin_id: Some("plugin_a".to_string()),
version: "1.0.0".to_string(),
files: DiscoveredPluginFiles {
manifest: CapabilityTextFile {
path: root.join(".codex-plugin/plugin.json")?,
contents: r#"{"name":"example"}"#.to_string(),
},
mcp_config: None,
apps_config: None,
},
root,
skills: Vec::new(),
}],
..Default::default()
})),
})
}
#[test]
fn executor_refresh_preserves_order_and_removes_stale_entries() -> anyhow::Result<()> {
let mut state = PluginContributorState::default();
// Reuse equal snapshots, including equal responses allocated by a later discovery.
let b = executor_snapshot("executor-b")?;
let c = executor_snapshot("executor-c")?;
state.update_executor_catalog(&[b.clone(), c.clone()]);
state.update_executor_catalog(&[executor_snapshot("executor-b")?, c.clone()]);
assert!(Arc::ptr_eq(
state.executor_snapshots[0].result.as_ref().unwrap(),
b.result.as_ref().unwrap(),
));
// Keep B ahead of C even when discovery reverses them and adds an earlier-named A.
let a = executor_snapshot("executor-a")?;
let mut updated_b = b;
Arc::make_mut(updated_b.result.as_mut().unwrap()).plugins[0].display_name =
"Updated".to_string();
state.update_executor_catalog(&[a.clone(), c.clone(), updated_b.clone()]);
assert_eq!(
state.executor_catalog.as_ref().unwrap(),
&catalog_from_snapshots(&[updated_b.clone(), c.clone(), a.clone()]),
);
// Removing B promotes C; B returning later appends it rather than displacing C.
state.update_executor_catalog(&[a.clone(), c.clone()]);
state.update_executor_catalog(&[updated_b.clone(), a.clone(), c.clone()]);
assert_eq!(
state.executor_catalog.as_ref().unwrap(),
&catalog_from_snapshots(&[c.clone(), a.clone(), updated_b]),
);
// A failed scan removes that environment's entries, and recovery rebuilds them.
let failed = ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: c.environment_id.clone(),
result: Err("discovery failed".to_string()),
};
state.update_executor_catalog(&[failed.clone(), a.clone()]);
assert_eq!(
state.executor_catalog.as_ref().unwrap(),
&catalog_from_snapshots(&[failed, a.clone()]),
);
state.update_executor_catalog(&[c.clone(), a.clone()]);
assert_eq!(
state.executor_catalog.as_ref().unwrap(),
&catalog_from_snapshots(&[a, c]),
);
Ok(())
}
#[tokio::test]
async fn projection_respects_discovery_mode_and_preserves_enabled_plugins() -> anyhow::Result<()> {
struct CloudProvider;
impl PluginProvider for CloudProvider {}
let home = tempfile::tempdir()?;
let root = home.path().join("legacy");
std::fs::create_dir_all(root.join(".codex-plugin"))?;
std::fs::write(
root.join(".codex-plugin/plugin.json"),
r#"{"name":"legacy","mcpServers":{"legacy":{"command":"legacy-command"}}}"#,
)?;
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await?;
config.features.enable(Feature::Plugins)?;
let environments = Arc::new(EnvironmentManager::default_for_tests());
let roots = vec![SelectedCapabilityRoot {
id: "legacy-root".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
path: PathUri::from_host_native_path(&root)?,
},
}];
let v1 = ExecutorCapabilityDiscoveryCache::new(Arc::clone(&environments))
.snapshot(&roots, &Default::default())
.await;
let mut contributor = PluginContributor {
providers: PluginProviders::new(Arc::new(ExecutorPluginProvider::new(environments)))
.with_cloud_provider(Arc::new(CloudProvider)),
};
let thread = ExtensionData::new("projection-test");
let init = ExtensionDataInit::default();
let state = thread.get_or_init(PluginsThreadState::default);
{
let mut state = state.contributor_state();
let key = state.prepare_cloud_generation(/*mcp_resources*/ None);
assert!(state.publish_cloud_catalog(
key.as_ref(),
PluginCatalog {
entries: vec![cloud_plugin("plugin_a")],
..Default::default()
}
));
}
// A different environment ensures per-root filtering cannot hide a legacy fallback.
let mut valid = [executor_snapshot("executor-a")?];
Arc::make_mut(valid[0].result.as_mut().unwrap()).plugins[0]
.files
.mcp_config = Some(CapabilityTextFile {
path: PathUri::parse("file:///plugins/example/1.0.0/.mcp.json")?,
contents: r#"{"executor-only":{"command":"node"}}"#.to_string(),
});
let mut local = [
executor_snapshot("executor-a")?,
executor_snapshot("executor-b")?,
];
for snapshot in &mut local {
Arc::make_mut(snapshot.result.as_mut().unwrap()).plugins[0].remote_plugin_id = None;
}
let empty = [ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: "executor-a".to_string(),
result: Ok(Arc::new(DiscoverV2CapabilitiesResponse::default())),
}];
let failed = [ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: "executor-a".to_string(),
result: Err("discovery failed".to_string()),
}];
for (case, v2_enabled, snapshots, expected) in [
(
"snapshot selects V2 independently of the acquisition flag",
false,
Some(valid.as_slice()),
vec![
"mcp:example",
"executor-a:example@marketplace",
"cloud:plugin_a",
],
),
(
"legacy",
false,
None,
vec!["mcp:legacy", "legacy-root", "cloud:plugin_a"],
),
(
"no V2 snapshot",
true,
None,
vec!["mcp:legacy", "legacy-root", "cloud:plugin_a"],
),
(
"empty snapshots",
true,
Some([].as_slice()),
vec!["cloud:plugin_a"],
),
(
"empty inventory",
true,
Some(empty.as_slice()),
vec!["cloud:plugin_a"],
),
(
"failed",
true,
Some(failed.as_slice()),
vec!["cloud:plugin_a"],
),
(
"valid",
true,
Some(valid.as_slice()),
vec![
"mcp:example",
"executor-a:example@marketplace",
"cloud:plugin_a",
],
),
(
"unconfigured local",
true,
Some(local.as_slice()),
vec![
"disabled:example@marketplace",
"disabled:example@marketplace",
"cloud:plugin_a",
],
),
] {
config
.features
.set_enabled(Feature::ExecutorCapabilityDiscoveryV2, v2_enabled)?;
for legacy_snapshot in [None, Some(&v1)] {
state.contributor_state().update_executor_catalog(&valid);
let contributions = contribution_labels(
&contributor,
McpServerContributionContext::for_step(
&config,
&init,
&thread,
"test",
&roots,
legacy_snapshot,
snapshots,
),
)
.await;
assert_eq!(
contributions,
expected,
"{case}, V2={v2_enabled}, V1={}",
legacy_snapshot.is_some()
);
let expected_catalog = catalog_from_snapshots(snapshots.unwrap_or_default());
assert_eq!(
state.contributor_state().executor_catalog.as_ref(),
Some(&expected_catalog),
"{case}"
);
}
}
std::fs::write(
home.path().join("config.toml"),
"[plugins.\"example@marketplace\"]\nenabled = true\n",
)?;
let mut config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.fallback_cwd(Some(home.path().to_path_buf()))
.build()
.await?;
config.features.enable(Feature::Plugins)?;
config
.features
.enable(Feature::ExecutorCapabilityDiscoveryV2)?;
// Cloud counterparts control enablement without deleting the executor installation.
let mut disabled_cloud = cloud_plugin("plugin_a");
disabled_cloud.enabled = false;
for (case, cloud_enabled, catalog, enabled) in [
("no cloud provider", false, None, true),
("no cloud catalog", true, None, true),
("absent", true, Some(PluginCatalog::default()), false),
(
"partial absent",
true,
Some(PluginCatalog {
is_partial: true,
warnings: vec!["partial cloud listing".to_string()],
..Default::default()
}),
false,
),
(
"disabled",
true,
Some(PluginCatalog {
entries: vec![disabled_cloud],
..Default::default()
}),
false,
),
(
"re-enabled",
true,
Some(PluginCatalog {
entries: vec![cloud_plugin("plugin_a")],
..Default::default()
}),
true,
),
(
"partial counterpart",
true,
Some(PluginCatalog {
entries: vec![cloud_plugin("plugin_a")],
is_partial: true,
warnings: vec!["partial cloud listing".to_string()],
}),
true,
),
] {
let cloud_available = catalog.is_some();
contributor.providers.cloud = if cloud_enabled {
Some(Arc::new(CloudProvider))
} else {
None
};
{
let mut cache = state.contributor_state();
cache.cloud_generation = None;
let key = cache.prepare_cloud_generation(/*mcp_resources*/ None);
if let Some(catalog) = catalog {
assert!(cache.publish_cloud_catalog(key.as_ref(), catalog));
}
}
let contributions = contribution_labels(
&contributor,
McpServerContributionContext::for_step(
&config,
&init,
&thread,
case,
&roots,
/*executor_capability_discovery*/ None,
Some(&valid),
),
)
.await;
let expected = if !cloud_enabled || !cloud_available {
vec!["mcp:executor-only", "executor-a:example@marketplace"]
} else if enabled {
vec![
"mcp:example",
"executor-a:example@marketplace",
"cloud:plugin_a",
]
} else {
vec!["disabled:example@marketplace"]
};
assert_eq!(contributions, expected, "{case}");
assert_eq!(
state.contributor_state().executor_catalog,
Some(catalog_from_snapshots(&valid)),
"{case}"
);
}
// A full Cloud catalog must not crowd out enabled executor-local plugins.
let cloud_plugin_count = 100;
{
let mut state = state.contributor_state();
let key = state.prepare_cloud_generation(/*mcp_resources*/ None);
let entries = (0..cloud_plugin_count)
.map(|index| cloud_plugin(&format!("plugin_{index}")))
.collect::<Vec<_>>();
assert!(state.publish_cloud_catalog(
key.as_ref(),
PluginCatalog {
entries,
..Default::default()
}
));
}
let contributions = contribution_labels(
&contributor,
McpServerContributionContext::for_step(
&config,
&init,
&thread,
"merged",
&roots,
/*executor_capability_discovery*/ None,
Some(&local[..1]),
),
)
.await;
assert_eq!(contributions.len(), cloud_plugin_count + 1);
assert_eq!(contributions[0], "executor-a:example@marketplace");
for (index, contribution) in contributions[1..].iter().enumerate() {
assert_eq!(contribution, &format!("cloud:plugin_{index}"));
}
Ok(())
}
+5 -8
View File
@@ -662,10 +662,6 @@ async fn raw_selected_plugin_contributions(
None
};
let v2 = [codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
result: Err("V2 must not change existing contributions".to_string()),
}];
let selected = registry.mcp_server_contributors()[0]
.selected_plugins(
McpServerContributionContext::for_step(
@@ -675,7 +671,7 @@ async fn raw_selected_plugin_contributions(
"test_originator",
&selected_capability_roots,
executor_capability_discovery.as_ref(),
Some(&v2),
/*executor_capability_discovery_v2*/ None,
),
&config.plugins,
)
@@ -752,19 +748,20 @@ async fn run_cloud_plugin_projection_scenarios() -> anyhow::Result<()> {
} else if cloud_available {
Ok(PluginCatalog {
entries: vec![PluginCatalogEntry {
enabled: true,
id: PluginIdentity::Remote {
remote_plugin_id: "plugin_notes".into(),
},
display_name: "Cloud Notes".into(),
version: Some("1".into()),
mcp_servers: [("notes".into(), declaration.to_string())].into(),
version: "1".into(),
mcp_servers: [("notes".into(), declaration.clone())].into(),
connector_ids: vec!["calendar".into()],
locations: vec![PluginSourceLocation::Cloud {
resource_uri: "plugin://plugin_notes".into(),
bundle_uri: None,
}],
}],
warnings: Vec::new(),
..Default::default()
})
} else {
Err("plugin-service unavailable".into())
+1 -1
View File
@@ -78,7 +78,7 @@ pub(crate) fn attribute_executor_plugins(
.iter()
.find(|plugin| plugin.selected_root_id.as_ref() == Some(&skill.authority.id))
{
skill.plugin_id = Some(plugin.plugin_id.clone());
skill.plugin_id = Some(plugin.plugin_id.as_str().to_string());
skill.analytics_scope = Some(SkillScope::User);
}
}