mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## 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
508 lines
18 KiB
Rust
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(())
|
|
}
|