mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Enable app routing for remote curated Agent Plugins (#55667)
## Why Agent Plugin manifests previously skipped app declarations, including service-generated declarations installed with remote curated plugins. This prevented those plugins from using app routing and suppressing matching direct MCP connections. ## What changed - Recognize app declarations for Agent Plugins from `openai-curated-remote` across loading, capability summaries, plugin details, tool suggestions, and install authentication. - Apply app routing to suppress same-name MCP servers when an app route is available. Keep unrelated MCP servers available and preserve direct MCP access in API-key mode. - Keep app declarations inactive for Agent Plugins from other marketplaces. ## Testing Add coverage for marketplace and authentication routing, service-generated app declarations during installation with both manifest formats, and app tools reaching turns. Verify that disabling Apps does not restore matching direct MCP connections. GitOrigin-RevId: 791739569f0e01c898040bc2d028cf517f767e00
This commit is contained in:
@@ -1575,7 +1575,8 @@ impl PluginRequestProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
|
||||
let plugin_app_declarations =
|
||||
load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await;
|
||||
let apps_needing_auth = self
|
||||
.plugin_apps_needing_auth_for_install(
|
||||
&config,
|
||||
@@ -1737,7 +1738,8 @@ impl PluginRequestProcessor {
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
|
||||
let plugin_app_declarations =
|
||||
load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await;
|
||||
self.plugin_apps_needing_auth_for_install(
|
||||
&config,
|
||||
auth.as_ref(),
|
||||
|
||||
@@ -2302,23 +2302,51 @@ async fn plugin_install_skips_remote_mcp_oauth_disabled_by_plugin_config() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(false; "legacy manifest")]
|
||||
#[test_case(true; "agent plugin manifest")]
|
||||
#[tokio::test]
|
||||
async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Result<()> {
|
||||
async fn plugin_install_skips_remote_mcp_oauth_for_service_generated_app(
|
||||
agent_plugin: bool,
|
||||
) -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let oauth_server = MockServer::start().await;
|
||||
let plugin_manifest = if agent_plugin {
|
||||
json!({
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "linear",
|
||||
"extensions": {"com.openai": {"apps": "./.app.json"}}
|
||||
})
|
||||
} else {
|
||||
json!({"name": "linear"})
|
||||
};
|
||||
let mcp_config = json!({
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"mcpServers": {"sample-mcp": {
|
||||
"type": if agent_plugin { "streamable-http" } else { "http" },
|
||||
"url": format!("{}/mcp", oauth_server.uri())
|
||||
}}
|
||||
});
|
||||
let service_app_manifest = json!({"apps": {"sample-mcp": {"id": "alpha"}}});
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config(
|
||||
"linear",
|
||||
r#"{"apps":{"sample-mcp":{"id":"alpha"}}}"#,
|
||||
&oauth_server.uri(),
|
||||
remote_plugin_bundle_tar_gz_bytes_with_entries(
|
||||
&plugin_manifest.to_string(),
|
||||
Some(r#"{"apps":{"bundled":{"id":"bundled-app"}}}"#),
|
||||
Some(&mcp_config.to_string()),
|
||||
)?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_with_apps_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_remote_plugin_detail_with_app_manifest(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
"1.2.3",
|
||||
Some(&bundle_url),
|
||||
service_app_manifest.clone(),
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await;
|
||||
|
||||
@@ -2345,7 +2373,21 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(oauth_discovery_request_count(&oauth_server).await, 0);
|
||||
assert_eq!(
|
||||
oauth_server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("recorded MCP endpoint requests")
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
let installed_app_manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.2.3/.app.json"),
|
||||
)?)?;
|
||||
assert_eq!(installed_app_manifest, service_app_manifest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3403,29 +3445,6 @@ fn remote_plugin_bundle_tar_gz_bytes_with_mcp_config(
|
||||
)
|
||||
}
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config(
|
||||
plugin_name: &str,
|
||||
app_manifest: &str,
|
||||
mcp_base_url: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
||||
let mcp_config = format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"sample-mcp": {{
|
||||
"type": "http",
|
||||
"url": "{mcp_base_url}/mcp"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
remote_plugin_bundle_tar_gz_bytes_with_entries(
|
||||
&manifest,
|
||||
Some(app_manifest),
|
||||
Some(mcp_config.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes_with_contents(
|
||||
plugin_manifest: &str,
|
||||
app_manifest: Option<&str>,
|
||||
@@ -3442,6 +3461,10 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries(
|
||||
app_manifest: Option<&str>,
|
||||
mcp_config: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let agent_plugin =
|
||||
serde_json::from_str::<serde_json::Value>(plugin_manifest).is_ok_and(|manifest| {
|
||||
manifest["$schema"] == "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||
});
|
||||
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
@@ -3457,11 +3480,23 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries(
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
];
|
||||
if agent_plugin {
|
||||
entries.push((
|
||||
"plugin.json",
|
||||
plugin_manifest.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
));
|
||||
}
|
||||
if let Some(app_manifest) = app_manifest {
|
||||
entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644));
|
||||
}
|
||||
if let Some(mcp_config) = mcp_config {
|
||||
entries.push((".mcp.json", mcp_config.as_bytes(), /*mode*/ 0o644));
|
||||
let path = if agent_plugin {
|
||||
"mcp.json"
|
||||
} else {
|
||||
".mcp.json"
|
||||
};
|
||||
entries.push((path, mcp_config.as_bytes(), /*mode*/ 0o644));
|
||||
}
|
||||
for (path, contents, mode) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use codex_plugin::AppDeclaration;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::auth::AuthMode;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -7,6 +10,13 @@ pub fn apps_route_available(auth_mode: Option<AuthMode>) -> bool {
|
||||
auth_mode.is_some_and(AuthMode::uses_codex_backend)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_supports_apps(format: PluginManifestFormat, plugin_id: &PluginId) -> bool {
|
||||
// The remote curated installer overwrites app declarations with plugin-service metadata.
|
||||
// Keep portable app declarations inactive for other Agent Plugins.
|
||||
format == PluginManifestFormat::Legacy
|
||||
|| plugin_id.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME
|
||||
}
|
||||
|
||||
pub(crate) fn apply_app_mcp_routing_policy<M>(
|
||||
apps: &mut Vec<AppDeclaration>,
|
||||
mcp_servers: &mut HashMap<String, M>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::PluginGitMode;
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::app_mcp_routing::apps_route_available;
|
||||
use crate::app_mcp_routing::plugin_supports_apps;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::manifest::ManifestCache;
|
||||
use crate::manifest::PluginManifest;
|
||||
@@ -962,7 +963,7 @@ async fn load_plugin(
|
||||
loaded_manifest.format,
|
||||
)
|
||||
.await;
|
||||
if loaded_manifest.format == PluginManifestFormat::Legacy {
|
||||
if plugin_supports_apps(loaded_manifest.format, &loaded_plugin_id) {
|
||||
loaded_plugin.apps =
|
||||
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await;
|
||||
}
|
||||
@@ -1151,9 +1152,9 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
paths
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
|
||||
pub async fn load_plugin_apps(plugin_root: &Path, plugin_id: &PluginId) -> Vec<AppDeclaration> {
|
||||
if let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) {
|
||||
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
if !plugin_supports_apps(loaded_manifest.format, plugin_id) {
|
||||
return Vec::new();
|
||||
}
|
||||
return load_plugin_apps_from_manifest(plugin_root, &loaded_manifest.manifest.paths).await;
|
||||
@@ -1397,10 +1398,10 @@ pub(crate) async fn plugin_capability_summary_from_root(
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
let app_declarations = if manifest_format == PluginManifestFormat::AgentPlugin {
|
||||
Vec::new()
|
||||
} else {
|
||||
let app_declarations = if plugin_supports_apps(manifest_format, plugin_id) {
|
||||
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let app_connector_ids = app_connector_ids_from_declarations(&app_declarations);
|
||||
|
||||
@@ -1418,9 +1419,16 @@ pub(crate) async fn plugin_capability_summary_from_root(
|
||||
/// Loads plugin MCP servers without applying user-specific policy overrides.
|
||||
pub async fn load_plugin_mcp_servers(
|
||||
plugin_root: &Path,
|
||||
plugin_id: &PluginId,
|
||||
auth_mode: Option<AuthMode>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, /*plugin_policy*/ None).await
|
||||
load_plugin_mcp_servers_with_policy(
|
||||
plugin_root,
|
||||
plugin_id,
|
||||
auth_mode,
|
||||
/*plugin_policy*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Loads plugin MCP servers with the effective configuration policy for an installed plugin.
|
||||
@@ -1432,12 +1440,11 @@ pub async fn load_configured_plugin_mcp_servers(
|
||||
codex_home: &Path,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let configured_plugins = configured_plugins_from_stack(config_layer_stack, codex_home);
|
||||
let plugin_id = plugin_id.as_key();
|
||||
let plugin_policy = configured_plugins
|
||||
.get(&plugin_id)
|
||||
.get(&plugin_id.as_key())
|
||||
.map(|plugin| &plugin.mcp_servers);
|
||||
|
||||
load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, plugin_policy).await
|
||||
load_plugin_mcp_servers_with_policy(plugin_root, plugin_id, auth_mode, plugin_policy).await
|
||||
}
|
||||
|
||||
/// Applies user policy without widening the selected plugin's declared restrictions.
|
||||
@@ -1505,6 +1512,7 @@ pub fn apply_configured_plugin_mcp_server_policies(
|
||||
|
||||
async fn load_plugin_mcp_servers_with_policy(
|
||||
plugin_root: &Path,
|
||||
plugin_id: &PluginId,
|
||||
auth_mode: Option<AuthMode>,
|
||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
@@ -1513,7 +1521,7 @@ async fn load_plugin_mcp_servers_with_policy(
|
||||
return mcp_servers;
|
||||
}
|
||||
|
||||
let mut app_declarations = load_plugin_apps(plugin_root).await;
|
||||
let mut app_declarations = load_plugin_apps(plugin_root, plugin_id).await;
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
|
||||
@@ -45,7 +45,11 @@ async fn agent_plugin_overlay_apps_are_not_runtime_active() {
|
||||
r#"{"apps":{"example":{"id":"connector_example"}}}"#,
|
||||
);
|
||||
|
||||
assert!(load_plugin_apps(&plugin_root).await.is_empty());
|
||||
assert!(
|
||||
load_plugin_apps(&plugin_root, &PluginId::parse("plugin@test").unwrap())
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -79,7 +83,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_
|
||||
}"#,
|
||||
);
|
||||
|
||||
let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
|
||||
let mut expected = load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None,
|
||||
)
|
||||
.await;
|
||||
let Some(McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio { env, env_vars, .. },
|
||||
..
|
||||
@@ -111,7 +120,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await,
|
||||
load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None
|
||||
)
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
@@ -137,7 +151,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p
|
||||
}"#,
|
||||
);
|
||||
|
||||
let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
|
||||
let mut expected = load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None,
|
||||
)
|
||||
.await;
|
||||
let Some(McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio { env_vars, .. },
|
||||
..
|
||||
@@ -158,7 +177,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await,
|
||||
load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None
|
||||
)
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
@@ -440,11 +464,17 @@ async fn legacy_ema_policy_disables_installed_and_selected_plugin_servers() {
|
||||
let policy = &config.plugins.plugins["plugin@market"].mcp_servers;
|
||||
let installed = load_plugin_mcp_servers_with_policy(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None,
|
||||
Some(policy),
|
||||
)
|
||||
.await;
|
||||
let mut selected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
|
||||
let mut selected = load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse("plugin@test").unwrap(),
|
||||
/*auth_mode*/ None,
|
||||
)
|
||||
.await;
|
||||
assert!(selected["example"].enabled);
|
||||
apply_configured_plugin_mcp_server_policies(policy, &mut selected);
|
||||
assert!(!installed["example"].enabled);
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use crate::PluginGitMode;
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::app_mcp_routing::plugin_supports_apps;
|
||||
use crate::http_client_selector::HttpClientSelector;
|
||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
@@ -2762,7 +2763,7 @@ impl PluginsManager {
|
||||
})
|
||||
.collect();
|
||||
let auth_mode = self.auth_mode();
|
||||
let mut app_declarations = if manifest_format == PluginManifestFormat::Legacy {
|
||||
let mut app_declarations = if plugin_supports_apps(manifest_format, &plugin_id) {
|
||||
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -2777,14 +2778,12 @@ impl PluginsManager {
|
||||
manifest_format,
|
||||
)
|
||||
.await;
|
||||
if manifest_format == PluginManifestFormat::Legacy {
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
auth_mode,
|
||||
/*plugin_active*/ true,
|
||||
);
|
||||
}
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
auth_mode,
|
||||
/*plugin_active*/ true,
|
||||
);
|
||||
let apps = app_connector_ids_from_declarations(&app_declarations);
|
||||
let mut seen_app_connector_ids = HashSet::new();
|
||||
let mut app_category_by_id = HashMap::new();
|
||||
|
||||
@@ -530,6 +530,73 @@ async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() {
|
||||
assert!(docs.app_connector_ids.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_plugin_app_routing_respects_marketplace_and_auth() {
|
||||
for (marketplace, uses_apps, apps_enabled) in [
|
||||
(REMOTE_GLOBAL_MARKETPLACE_NAME, true, true),
|
||||
(REMOTE_GLOBAL_MARKETPLACE_NAME, true, false),
|
||||
("test", false, true),
|
||||
("test", false, false),
|
||||
] {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace)
|
||||
.join("sample/local");
|
||||
write_file(
|
||||
&plugin_root.join("plugin.json"),
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"sample":{"id":"connector_sample"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("mcp.json"),
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"sample":{"type":"stdio","command":"sample-mcp"},"unrelated":{"type":"stdio","command":"other-mcp"}}}"#,
|
||||
);
|
||||
let outcome = load_plugins_from_config(
|
||||
&format!(
|
||||
"[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"sample@{marketplace}\"]\nenabled = true\n"
|
||||
),
|
||||
codex_home.path(),
|
||||
Some(AuthMode::Chatgpt),
|
||||
)
|
||||
.await;
|
||||
let expected_apps = if uses_apps {
|
||||
vec![AppConnectorId("connector_sample".to_string())]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let expected_mcp = if uses_apps {
|
||||
vec!["unrelated".to_string()]
|
||||
} else {
|
||||
vec!["sample".to_string(), "unrelated".to_string()]
|
||||
};
|
||||
assert_eq!(outcome.effective_apps(), expected_apps);
|
||||
assert_eq!(sorted_effective_mcp_server_names(&outcome), expected_mcp);
|
||||
let [summary] = outcome.capability_summaries() else {
|
||||
panic!("expected one installed plugin");
|
||||
};
|
||||
assert_eq!(summary.app_connector_ids, expected_apps);
|
||||
assert_eq!(summary.mcp_server_names, expected_mcp);
|
||||
|
||||
// Remote curated plugins are not selected in API-key mode, but the standalone
|
||||
// loader must still preserve direct MCP when no app route is available.
|
||||
let mut direct_servers = crate::loader::load_plugin_mcp_servers(
|
||||
&plugin_root,
|
||||
&PluginId::parse(&format!("sample@{marketplace}")).unwrap(),
|
||||
Some(AuthMode::ApiKey),
|
||||
)
|
||||
.await
|
||||
.into_keys()
|
||||
.collect::<Vec<_>>();
|
||||
direct_servers.sort();
|
||||
assert_eq!(direct_servers, vec!["sample", "unrelated"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_utils_plugins::PluginSkillRoot;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::app_mcp_routing::plugin_supports_apps;
|
||||
use crate::loader::PluginSkillInventory;
|
||||
use crate::loader::load_plugin_apps_from_manifest;
|
||||
use crate::loader::load_plugin_mcp_servers_from_manifest_with_format;
|
||||
@@ -24,7 +25,6 @@ use crate::loader::load_plugin_skill_inventory;
|
||||
use crate::manager::ConfiguredMarketplacePlugin;
|
||||
use crate::manager::remote_plugin_install_required_description;
|
||||
use crate::manifest::ManifestCache;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::marketplace::MarketplaceError;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
|
||||
@@ -257,10 +257,10 @@ async fn load_plugin_metadata(
|
||||
.collect::<Vec<_>>();
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
let app_declarations = if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
Vec::new()
|
||||
} else {
|
||||
let app_declarations = if plugin_supports_apps(loaded_manifest.format, &plugin_id) {
|
||||
load_plugin_apps_from_manifest(plugin_root.as_path(), &manifest.paths).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Arc::new(ToolSuggestMetadataFragment {
|
||||
|
||||
@@ -1756,6 +1756,97 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(true; "apps enabled")]
|
||||
#[test_case(false; "apps disabled without direct MCP fallback")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn curated_agent_plugin_app_routing_reaches_turns(apps_enabled: bool) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let mock = mount_plugin_tool_search_turn(&server).await;
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated-remote/sample/local");
|
||||
std::fs::create_dir_all(&plugin_root)?;
|
||||
let command_name = format!("test_stdio_server{}", std::env::consts::EXE_SUFFIX);
|
||||
codex_utils_cargo_bin::copy_executable(
|
||||
std::path::Path::new(&stdio_server_bin()?),
|
||||
&plugin_root.join(&command_name),
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join("plugin.json"),
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"calendar":{"id":"calendar"}}}"#,
|
||||
)?;
|
||||
let mcp_server = serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": format!("./{command_name}"),
|
||||
});
|
||||
std::fs::write(
|
||||
plugin_root.join("mcp.json"),
|
||||
serde_json::json!({
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"mcpServers": {"calendar": mcp_server, "sample": mcp_server},
|
||||
})
|
||||
.to_string(),
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||
),
|
||||
)?;
|
||||
let mut builder = test_codex()
|
||||
.with_home(codex_home)
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.set_enabled(Feature::Apps, apps_enabled)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = apps_server.chatgpt_base_url;
|
||||
});
|
||||
let test = builder.build_with_remote_and_local_env(&server).await?;
|
||||
assert!(test.config.features.enabled(Feature::Plugins));
|
||||
assert_eq!(test.config.features.enabled(Feature::Apps), apps_enabled);
|
||||
wait_for_mcp_server(&test.codex, "sample").await?;
|
||||
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Mention {
|
||||
name: "sample".into(),
|
||||
path: format!("plugin://{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}"),
|
||||
}]))
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = mock.requests();
|
||||
let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]);
|
||||
assert_eq!(calendar_tool.is_some(), apps_enabled);
|
||||
if let Some(calendar_tool) = calendar_tool {
|
||||
assert_plugin_provenance(&calendar_tool);
|
||||
}
|
||||
assert_plugin_provenance(&echo_tool.expect("unrelated MCP tool remains available"));
|
||||
// Disabling Apps must not restore the matching direct MCP connection.
|
||||
assert!(requests[0].tool_by_name("mcp__calendar", "echo").is_none());
|
||||
let mcp_output = requests[1].tool_search_output(PLUGIN_MCP_SEARCH_CALL_ID);
|
||||
assert!(namespace_child_tool(&mcp_output, "mcp__calendar", "echo").is_none());
|
||||
let apps_connected = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("recorded app endpoint requests")
|
||||
.iter()
|
||||
.any(|request| request.url.path().trim_end_matches('/') == "/api/codex/ps/mcp");
|
||||
assert_eq!(apps_connected, apps_enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(true; "enabled app")]
|
||||
#[test_case(false; "disabled app")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
Reference in New Issue
Block a user