Files
codex/codex-rs/ext/mcp/src/plugin_contributor_state_tests.rs
T
Anthony Tafoya a309f9206a Integrate discovery V2 with step-scoped skill catalogs (#55322)
## What changed

- Build executor skill catalogs from discovery V2 inventories without reopening host paths or falling back to legacy discovery.
- Merge Cloud and executor skills by remote plugin identity and skill name, retaining Cloud metadata and accepted provider read routes. Apply plugin selection and disablement before merging.
- Share a stable catalog across each step's rendering, list/read tools, and explicit and implicit skill invocations.
- Preserve unmatched executor entries when Cloud catalogs are unavailable or partial; only complete Cloud catalogs disable unmatched remote entries.
- Bind discovery caches to captured environments and executor sessions, and refresh MCP when V2 inventories change. Preserve pending invalidations when forced MCP refreshes fail.

## Testing

Add regression coverage for V2 inventory sharing and refresh, environment isolation, catalog merging, partial Cloud results, disabled plugins, and catalog consistency across steps and read routes.

GitOrigin-RevId: 842bdcbc9e19364dd69a9abf9e48330b7425a76c
2026-10-19 17:02:20 +00:00

508 lines
18 KiB
Rust

//! 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 partial_refresh_retains_entries_without_excluding_new_executor_plugins() -> anyhow::Result<()> {
let mut state = PluginContributorState::default();
let auth_key = state.prepare_cloud_generation(/*mcp_resources*/ None);
let original = PluginCatalog {
entries: vec![cloud_plugin("old_plugin")],
..Default::default()
};
assert!(state.publish_cloud_catalog(auth_key.as_ref(), original.clone()));
assert!(state.publish_cloud_catalog(
auth_key.as_ref(),
PluginCatalog {
entries: vec![cloud_plugin("plugin_a")],
is_partial: true,
..Default::default()
},
));
let mut expected = original;
expected.is_partial = true;
assert_eq!(state.cloud_catalog(), Some(&expected));
state.update_executor_catalog(&[executor_snapshot("executor-a")?]);
let merged =
codex_core_plugins::merge_catalogs(state.executor_catalog.as_ref(), state.cloud_catalog());
assert!(
merged
.entries
.iter()
.any(|entry| { entry.id.as_str() == "plugin_a" && entry.enabled })
);
Ok(())
}
#[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 resolved_roots = environments
.resolve_selected_capability_roots(
&roots,
&std::collections::HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
environments.get_environment(LOCAL_ENVIRONMENT_ID),
)]),
)
.await;
let v1 = ExecutorCapabilityDiscoveryCache::default()
.snapshot(&resolved_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()
}),
true,
),
(
"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 has_cloud_counterpart = catalog
.as_ref()
.is_some_and(|catalog| !catalog.entries.is_empty());
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 enabled && !has_cloud_counterpart {
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(())
}