[codex] Gate plugin MCP servers by auth route (#27459)

## Context

Some plugins expose both Apps and MCP servers. This PR moves auth-aware
surface projection into `core-plugins::PluginsManager`, so callers get a
consistent effective plugin view. Later PRs narrow the conflict rule and
update listing/install paths.

The high level goal of this PR is to set up the plumbing to
conditionally filter App/MCP in the plugin manager layer. We start by
removing MCP servers when using SIWC/Codex-backend auth, and removing
Apps when using API-key-style auth.

This PR is now stacked on #27652, which contains only the constructor
plumbing for seeding `PluginsManager` with the current auth mode.

## Stack

- PR1: #27652 seed plugin manager auth at construction.
- PR2: #27459 route plugin surfaces by auth mode.
- PR3: #27607 dedupe plugin MCP servers by App declaration name.
- PR4: #27602 preserve plugin Apps in connector listings.
- PR5: #27461 skip install-time plugin MCP OAuth for matching App
routes.

## Summary

- API-key/non-ChatGPT routes hide plugin Apps and keep plugin MCPs.
- ChatGPT/SIWC with Apps enabled keeps plugin Apps and suppresses MCPs
for dual-surface plugins.
- MCP-only plugins stay available for ChatGPT/SIWC sessions.
- Cached plugin load outcomes are re-projected when auth mode changes.

## Validation

```bash
cargo test -p codex-core-plugins plugin_auth_projection
cargo test -p codex-core list_tool_suggest_discoverable_plugins
git diff --check
```
This commit is contained in:
felixxia-oai
2026-06-13 03:42:11 +01:00
committed by GitHub
Unverified
parent 5c8136f48a
commit 5d7db08b61
6 changed files with 348 additions and 55 deletions
@@ -56,6 +56,17 @@ impl AuthMode {
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}
/// Returns whether this mode is backed by Codex services rather than a direct model API.
pub fn uses_codex_backend(self) -> bool {
match self {
Self::Chatgpt
| Self::ChatgptAuthTokens
| Self::AgentIdentity
| Self::PersonalAccessToken => true,
Self::ApiKey | Self::BedrockApiKey => false,
}
}
}
macro_rules! experimental_reason_expr {
+13 -2
View File
@@ -207,9 +207,20 @@ fn featured_plugin_ids_cache_key(
fn project_plugin_load_outcome_for_auth(
outcome: PluginLoadOutcome,
_auth_mode: Option<AuthMode>,
auth_mode: Option<AuthMode>,
) -> PluginLoadOutcome {
outcome
let apps_route_available = auth_mode.is_some_and(AuthMode::uses_codex_backend);
let mut plugins = outcome.plugins().to_vec();
for plugin in &mut plugins {
if apps_route_available {
if plugin.is_active() && !plugin.apps.is_empty() {
plugin.mcp_servers.clear();
}
} else {
plugin.apps.clear();
}
}
PluginLoadOutcome::from_plugins(plugins)
}
#[derive(Debug, Clone, PartialEq, Eq)]
+210 -20
View File
@@ -70,6 +70,187 @@ fn plugins_manager_tracks_auth_mode() {
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
}
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
let plugin_root = codex_home
.join("plugins/cache")
.join("test")
.join(name)
.join("local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(r#"{{"name":"{name}"}}"#),
);
write_file(
&plugin_root.join(".mcp.json"),
&format!(
r#"{{
"mcpServers": {{
"{name}": {{
"type": "stdio",
"command": "{name}-mcp"
}}
}}
}}"#
),
);
if include_app {
write_file(
&plugin_root.join(".app.json"),
&format!(r#"{{"apps":{{"{name}":{{"id":"connector_{name}"}}}}}}"#),
);
}
}
async fn auth_projection_config(codex_home: &Path) -> PluginsConfigInput {
let config_toml = r#"[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."docs@test"]
enabled = true
"#
.to_string();
write_file(&codex_home.join(CONFIG_TOML_FILE), &config_toml);
load_config(codex_home, codex_home).await
}
fn sorted_effective_mcp_server_names(outcome: &PluginLoadOutcome) -> Vec<String> {
let mut names = outcome
.effective_mcp_servers()
.keys()
.cloned()
.collect::<Vec<_>>();
names.sort();
names
}
#[tokio::test]
async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::ApiKey),
);
let outcome = manager.plugins_for_config(&config).await;
assert!(outcome.effective_apps().is_empty());
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string(), "sample".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]);
assert!(sample.app_connector_ids.is_empty());
}
#[tokio::test]
async fn plugin_auth_projection_hides_dual_surface_mcp_with_chatgpt_apps_route() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert!(sample.mcp_server_names.is_empty());
assert_eq!(
sample.app_connector_ids,
vec![AppConnectorId("connector_sample".to_string())]
);
let docs = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "docs@test")
.expect("docs plugin summary should exist");
assert_eq!(docs.mcp_server_names, vec!["docs".to_string()]);
assert!(docs.app_connector_ids.is_empty());
}
#[tokio::test]
async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::AgentIdentity),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string()]
);
}
#[tokio::test]
async fn plugin_auth_projection_reprojects_cached_outcome_when_auth_changes() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let chatgpt_outcome = manager.plugins_for_config(&config).await;
assert_eq!(
sorted_effective_mcp_server_names(&chatgpt_outcome),
vec!["docs".to_string()]
);
assert_eq!(
chatgpt_outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
let api_key_outcome = manager.plugins_for_config(&config).await;
assert_eq!(
sorted_effective_mcp_server_names(&api_key_outcome),
vec!["docs".to_string(), "sample".to_string()]
);
assert!(api_key_outcome.effective_apps().is_empty());
}
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
@@ -145,10 +326,14 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
}
async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome {
async fn load_plugins_from_config(
config_toml: &str,
codex_home: &Path,
auth_mode: Option<AuthMode>,
) -> PluginLoadOutcome {
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let config = load_config(codex_home, codex_home).await;
PluginsManager::new(codex_home.to_path_buf())
PluginsManager::new_with_options(codex_home.to_path_buf(), Some(Product::Codex), auth_mode)
.plugins_for_config(&config)
.await
}
@@ -242,6 +427,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -285,7 +471,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
tools: HashMap::new(),
},
)]),
apps: vec![AppConnectorId("connector_example".to_string())],
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
@@ -299,7 +485,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
description: Some("Plugin that includes the sample MCP server and Skills".to_string(),),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
app_connector_ids: Vec::new(),
}]
);
assert_eq!(
@@ -307,10 +493,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
vec![plugin_root.join("skills").abs()]
);
assert_eq!(outcome.effective_mcp_servers().len(), 1);
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_example".to_string())]
);
assert!(outcome.effective_apps().is_empty());
}
#[tokio::test]
@@ -360,7 +543,8 @@ disabled_tools = ["delete"]
approval_mode = "approve"
"#;
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
let server = outcome.plugins()[0]
.mcp_servers
.get("sample")
@@ -620,7 +804,8 @@ enabled = false
[plugins."sample@test"]
enabled = true
"#;
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
let skill_path = std::fs::canonicalize(skill_path)
.expect("skill path should canonicalize")
.abs();
@@ -660,7 +845,8 @@ enabled = false
[plugins."sample@test"]
enabled = true
"#;
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
assert!(outcome.plugins()[0].has_enabled_skills);
@@ -745,6 +931,7 @@ async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -784,6 +971,7 @@ async fn capability_summary_truncates_overlong_plugin_descriptions() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -868,6 +1056,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -906,10 +1095,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
},
)])
);
assert_eq!(
outcome.plugins()[0].apps,
vec![AppConnectorId("connector_custom".to_string())]
);
assert!(outcome.plugins()[0].apps.is_empty());
}
#[tokio::test]
@@ -983,6 +1169,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -1018,10 +1205,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
},
)])
);
assert_eq!(
outcome.plugins()[0].apps,
vec![AppConnectorId("connector_default".to_string())]
);
assert!(outcome.plugins()[0].apps.is_empty());
}
#[tokio::test]
@@ -1051,6 +1235,7 @@ async fn load_plugins_ignores_invalid_manifest_skills_shape() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -1090,6 +1275,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
/*enabled*/ false, /*plugins_feature_enabled*/ true,
),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -1162,6 +1348,7 @@ async fn effective_apps_dedupes_connector_ids_across_plugins() {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
features.insert("apps".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugins = toml::map::Map::new();
@@ -1178,7 +1365,8 @@ async fn effective_apps_dedupes_connector_ids_across_plugins() {
let config_toml =
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize");
let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await;
let outcome =
load_plugins_from_config(&config_toml, codex_home.path(), Some(AuthMode::Chatgpt)).await;
assert_eq!(
outcome.effective_apps(),
@@ -1221,6 +1409,7 @@ async fn effective_apps_preserves_app_config_order() {
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
@@ -1484,6 +1673,7 @@ async fn load_plugins_rejects_invalid_plugin_keys() {
let outcome = load_plugins_from_config(
&toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
@@ -6,6 +6,7 @@ use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_protocol::protocol::Product;
use codex_tools::DiscoverablePluginInfo;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -23,7 +24,11 @@ async fn list_discoverable_plugins_with_auth(
auth: Option<&codex_login::CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_manager = PluginsManager::new_with_options(
config.codex_home.to_path_buf(),
Some(Product::Codex),
auth.map(codex_login::CodexAuth::api_auth_mode),
);
list_discoverable_plugins_with_manager_and_auth(
config,
&plugins_manager,
+105 -16
View File
@@ -8,6 +8,7 @@ use std::time::Instant;
use anyhow::Result;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use core_test_support::apps_test_server::AppsTestServer;
@@ -226,7 +227,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
@@ -252,6 +253,105 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
.await?;
let codex = Arc::clone(&test_codex.codex);
wait_for_mcp_server(&codex, CODEX_APPS_MCP_SERVER_NAME).await?;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Mention {
name: "sample".into(),
path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = mock.single_request();
let developer_messages = request.message_input_texts("developer");
assert!(
developer_messages
.iter()
.any(|text| text.contains("Skills from this plugin")),
"expected plugin skills guidance: {developer_messages:?}"
);
assert!(
!developer_messages
.iter()
.any(|text| text.contains("MCP servers from this plugin")),
"expected plugin MCP guidance to be suppressed for ChatGPT auth: {developer_messages:?}"
);
assert!(
developer_messages
.iter()
.any(|text| text.contains("Apps from this plugin")),
"expected visible plugin app guidance: {developer_messages:?}"
);
let request_body = request.body_json();
let request_tools = tool_names(&request_body);
assert!(
request_tools
.iter()
.any(|name| name == "mcp__codex_apps__google_calendar"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
);
assert!(
request.tool_by_name("mcp__sample", "echo").is_none(),
"expected plugin MCP tool to be suppressed for ChatGPT auth"
);
let calendar_tool = request
.tool_by_name("mcp__codex_apps__google_calendar", "_create_event")
.expect("plugin app tool should be present");
let calendar_description = calendar_tool
.get("description")
.and_then(serde_json::Value::as_str)
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let codex_home = Arc::new(TempDir::new()?);
let rmcp_test_server_bin = match stdio_server_bin() {
Ok(bin) => bin,
Err(err) => {
eprintln!("test_stdio_server binary not available, skipping test: {err}");
return Ok(());
}
};
write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
write_plugin_app_plugin(codex_home.as_ref());
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
});
let test_codex = builder
.build(&server)
.await
.expect("create new conversation");
let codex = Arc::clone(&test_codex.codex);
wait_for_mcp_server(&codex, "sample").await?;
codex
@@ -283,18 +383,18 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
"expected visible plugin MCP guidance: {developer_messages:?}"
);
assert!(
developer_messages
!developer_messages
.iter()
.any(|text| text.contains("Apps from this plugin")),
"expected visible plugin app guidance: {developer_messages:?}"
"expected plugin app guidance to be suppressed for API-key auth: {developer_messages:?}"
);
let request_body = request.body_json();
let request_tools = tool_names(&request_body);
assert!(
request_tools
!request_tools
.iter()
.any(|name| name == "mcp__codex_apps__google_calendar"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
"expected plugin app tools to be hidden for API-key auth: {request_tools:?}"
);
let echo_tool = request
.tool_by_name("mcp__sample", "echo")
@@ -307,17 +407,6 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
echo_description.contains("This tool is part of plugin `sample`."),
"expected plugin MCP provenance in tool description: {echo_description:?}"
);
let calendar_tool = request
.tool_by_name("mcp__codex_apps__google_calendar", "_create_event")
.expect("plugin app tool should be present");
let calendar_description = calendar_tool
.get("description")
.and_then(serde_json::Value::as_str)
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"
);
Ok(())
}
+3 -16
View File
@@ -347,13 +347,7 @@ impl CodexAuth {
}
pub fn uses_codex_backend(&self) -> bool {
matches!(
self,
Self::Chatgpt(_)
| Self::ChatgptAuthTokens(_)
| Self::AgentIdentity(_)
| Self::PersonalAccessToken(_)
)
self.api_auth_mode().uses_codex_backend()
}
pub fn is_external_chatgpt_tokens(&self) -> bool {
@@ -2079,15 +2073,8 @@ impl AuthManager {
}
pub fn current_auth_uses_codex_backend(&self) -> bool {
matches!(
self.auth_mode(),
Some(
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::AgentIdentity
| AuthMode::PersonalAccessToken
)
)
self.get_api_auth_mode()
.is_some_and(AuthMode::uses_codex_backend)
}
fn should_refresh_proactively(auth: &CodexAuth) -> bool {