From 654b0a77d0d2f81aa21f61caf7af4be88fe550bb Mon Sep 17 00:00:00 2001 From: Nick Steele Date: Fri, 11 Sep 2026 14:14:47 +0000 Subject: [PATCH] Add trusted enterprise MCP auth configuration (#44832) ## Why Enterprise MCP authorization must remain controlled by host, user, or managed configuration. Project settings and plugin declarations must not redirect enterprise credentials or downgrade the selected authentication mode. ## What changed - Add `auth = "ema_auth"`, shared `mcp_enterprise_managed_auth` IdP configuration, and per-plugin enterprise registration settings. - Require enterprise registrations to come from one non-project configuration layer, preserve managed IdP precedence, and reject project overrides that change authorization or re-enable disabled enterprise servers. - Add the disabled-by-default `use_xaa` feature flag and require non-project opt-in or a managed requirement. - Reject plugin declarations that select `ema_auth`. Keep EMA connections unsupported and block ordinary MCP OAuth login and credential fallback for this mode. - Extend the configuration schema and serialization for the new settings. ## Testing Add coverage for IdP precedence, incomplete registrations, alternate credential validation, project-only opt-in, plugin self-declaration rejection, configuration round-tripping, and trusted-project attempts to downgrade enterprise auth to OAuth or ChatGPT auth. GitOrigin-RevId: f193bdfda74208c98e496a23bf2916955b131a1c --- .../src/request_processors/mcp_processor.rs | 5 + .../src/request_processors/plugins.rs | 4 +- codex-rs/cli/src/mcp_cmd.rs | 7 + codex-rs/codex-mcp/src/mcp/auth.rs | 26 +- codex-rs/codex-mcp/src/mcp/mod.rs | 2 +- codex-rs/codex-mcp/src/plugin_config.rs | 7 + codex-rs/codex-mcp/src/plugin_config_tests.rs | 28 ++ codex-rs/codex-mcp/src/rmcp_client.rs | 5 + codex-rs/codex-mcp/src/server.rs | 1 + codex-rs/config/src/config_toml.rs | 5 + codex-rs/config/src/lib.rs | 4 + codex-rs/config/src/mcp_ema.rs | 351 ++++++++++++++++++ codex-rs/config/src/mcp_ema_tests.rs | 113 ++++++ codex-rs/config/src/mcp_types.rs | 45 ++- codex-rs/config/src/mcp_types_tests.rs | 1 + codex-rs/config/src/types.rs | 23 +- codex-rs/core-plugins/src/loader.rs | 6 + codex-rs/core-plugins/src/manager_tests.rs | 1 + codex-rs/core/config.schema.json | 108 +++++- codex-rs/core/src/config/config_tests.rs | 51 +++ .../core/src/config/edit/document_helpers.rs | 9 +- codex-rs/core/src/config/edit_tests.rs | 16 + codex-rs/core/src/config/mod.rs | 7 + codex-rs/core/src/mcp_skill_dependencies.rs | 1 + codex-rs/core/tests/suite/mcp_ema_config.rs | 67 ++++ .../suite/mcp_startup_refresh_http_proxy.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/features/src/lib.rs | 8 + 28 files changed, 872 insertions(+), 31 deletions(-) create mode 100644 codex-rs/config/src/mcp_ema.rs create mode 100644 codex-rs/config/src/mcp_ema_tests.rs create mode 100644 codex-rs/core/tests/suite/mcp_ema_config.rs diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index c445ac24de..f11f5b212c 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -170,6 +170,11 @@ impl McpRequestProcessor { StreamableHttpRedirectMode::Legacy }; let server = server.config(); + if matches!(server.auth, codex_config::McpServerAuth::EmaAuth) { + return Err(invalid_request( + "EMA MCP connections are not enabled in this version", + )); + } let (url, http_headers, env_http_headers) = match &server.transport { McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 77fc4c43fe..770b808b75 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1806,7 +1806,9 @@ impl PluginRequestProcessor { config.cwd.to_path_buf(), ); for (name, server) in plugin_mcp_servers { - if !server.enabled { + // EMA uses the account's enterprise grant, never per-plugin OAuth fallback. + if !server.enabled || matches!(server.auth, codex_config::types::McpServerAuth::EmaAuth) + { continue; } if !server.is_local_environment() { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 1b7c0815f5..7c01fb96cb 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -406,6 +406,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re client_id: Some(client_id), callback_url: callback_url.clone(), callback_port: None, + ..Default::default() }), oauth_resource: oauth_resource.clone(), tools: HashMap::new(), @@ -525,6 +526,12 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { bail!("No MCP server named '{name}' found."); }; + if matches!(server.auth, codex_config::types::McpServerAuth::EmaAuth) { + bail!( + "Enterprise MCP authorization is managed by Codex account sign-in. Open Codex to sign in." + ); + } + let (url, http_headers, env_http_headers) = match &server.transport { McpServerTransportConfig::StreamableHttp { url, diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index 137f0ba3ba..8ecfe156d9 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -213,22 +213,13 @@ where }; let config = server.config().clone(); let runtime_context = runtime_context.clone(); - let has_runtime_auth = matches!(&config.auth, McpServerAuth::ChatGpt) - && auth.is_some_and(CodexAuth::uses_codex_backend) - && matches!( - &config.transport, - McpServerTransportConfig::StreamableHttp { - bearer_token_env_var: None, - .. - } - ); async move { let auth_state = match compute_auth_status( &name, &config, store_mode, keyring_backend_kind, - has_runtime_auth, + auth, &runtime_context, redirect_mode, ) @@ -256,13 +247,26 @@ async fn compute_auth_status( config: &McpServerConfig, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, - has_runtime_auth: bool, + auth: Option<&CodexAuth>, runtime_context: &McpRuntimeContext, redirect_mode: StreamableHttpRedirectMode, ) -> Result { if !config.enabled { return Ok(McpAuthState::Unsupported); } + if matches!(config.auth, McpServerAuth::EmaAuth) { + // EMA connections are not enabled until the runtime stage of the stack. + return Ok(McpAuthState::Unsupported); + } + let has_runtime_auth = matches!(config.auth, McpServerAuth::ChatGpt) + && auth.is_some_and(CodexAuth::uses_codex_backend) + && matches!( + &config.transport, + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var: None, + .. + } + ); if matches!(config.auth, McpServerAuth::ChatGpt) && !config.is_local_environment() { return Ok(if has_explicit_http_authorization(config) { diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index fae3a86974..180a1efb5f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -398,7 +398,7 @@ pub fn effective_mcp_servers_from_configured( server.auth = McpServerAuth::OAuth; } } - McpServerAuth::OAuth => {} + McpServerAuth::OAuth | McpServerAuth::EmaAuth => {} } let agent_plugin = config .mcp_server_catalog diff --git a/codex-rs/codex-mcp/src/plugin_config.rs b/codex-rs/codex-mcp/src/plugin_config.rs index bdd9c27b83..f55ebf977d 100644 --- a/codex-rs/codex-mcp/src/plugin_config.rs +++ b/codex-rs/codex-mcp/src/plugin_config.rs @@ -1,3 +1,4 @@ +use codex_config::McpServerAuth; use codex_config::McpServerConfig; use codex_config::McpServerEnvVar; use codex_config::McpServerTransportConfig; @@ -155,6 +156,12 @@ fn normalize_plugin_mcp_server( let mut config = serde_json::from_value::(JsonValue::Object(object)) .map_err(|err| err.to_string())?; + if matches!(config.auth, McpServerAuth::EmaAuth) { + return Err( + "plugin MCP declarations cannot select ema_auth; configure enterprise authentication in host policy" + .to_string(), + ); + } if matches!(source, PluginMcpSource::Environment { .. }) { bind_environment_env_vars(&mut config)?; } diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index c09345afa4..b6836e55c6 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -584,6 +584,7 @@ fn declared_placement_preserves_local_plugin_normalization() { client_id: Some("client-id".to_string()), callback_url: Some("http://127.0.0.1/callback/registered".to_string()), callback_port: Some(9876), + ..Default::default() }), oauth_resource: None, tools: HashMap::new(), @@ -632,6 +633,33 @@ fn declared_placement_preserves_local_plugin_normalization() { ); } +#[test] +fn native_plugin_mcp_cannot_self_declare_ema_auth() { + let server = serde_json::json!({ + "type": "http", + "url": "https://resource.example/mcp", + "auth": "ema_auth" + }); + for contents in [ + serde_json::json!({"enterprise": server}), + serde_json::json!({"mcpServers": {"enterprise": server}}), + ] { + let outcome = parse_plugin_mcp_config(&plugin_root(), &contents.to_string()) + .expect("parse plugin MCP config"); + + assert_eq!( + outcome, + PluginMcpConfigParseOutcome { + servers: BTreeMap::new(), + errors: vec![PluginMcpServerParseError { + name: "enterprise".to_string(), + message: "plugin MCP declarations cannot select ema_auth; configure enterprise authentication in host policy".to_string(), + }], + } + ); + } +} + #[test] fn environment_placement_forces_authority_and_defaults_null_cwd() { let plugin_root = plugin_root(); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 8f801aadad..c0ac4b3a2e 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -1145,6 +1145,11 @@ pub(crate) async fn make_rmcp_client( protocol_mode: McpProtocolMode, ) -> Result { let config = server.config().clone(); + if matches!(config.auth, McpServerAuth::EmaAuth) { + return Err(StartupOutcomeError::from(anyhow!( + "EMA MCP connections are not enabled in this version" + ))); + } if matches!(config.auth, McpServerAuth::ChatGpt) && !config.is_local_environment() && !has_explicit_http_authorization(&config) diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index 328080f207..1b6c1ecb5b 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -139,6 +139,7 @@ impl McpServerConnectionIdentity { .all(|byte| byte == b'\t' || (byte >= b' ' && byte != 0x7f)) }; let stored_oauth_url = if runtime_auth_provider.is_none() + && !matches!(config.auth, McpServerAuth::EmaAuth) && (!matches!(config.auth, McpServerAuth::ChatGpt) || config.is_local_environment()) { match &config.transport { diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index cca6bcb326..ffc748f7ab 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -17,6 +17,7 @@ use crate::types::AuthCredentialsStoreMode; use crate::types::FeedbackConfigToml; use crate::types::History; use crate::types::MarketplaceConfig; +use crate::types::McpEnterpriseManagedAuthConfig; use crate::types::McpServerConfig; use crate::types::MemoriesToml; use crate::types::Notice; @@ -276,6 +277,10 @@ pub struct ConfigToml { #[schemars(schema_with = "crate::schema::mcp_servers_schema")] pub mcp_servers: HashMap, + /// Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins. + #[serde(default)] + pub mcp_enterprise_managed_auth: Option, + /// Preferred backend for storing MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 06fb796c80..949e883837 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -21,6 +21,7 @@ mod key_aliases; pub mod loader; mod marketplace_edit; mod mcp_edit; +mod mcp_ema; mod mcp_requirements; mod mcp_types; mod merge; @@ -156,6 +157,9 @@ pub use marketplace_edit::record_user_marketplace; pub use marketplace_edit::remove_user_marketplace; pub use marketplace_edit::remove_user_marketplace_config; pub use mcp_edit::load_global_mcp_servers; +pub use mcp_ema::McpEmaRegistration; +pub use mcp_ema::McpEnterpriseManagedAuthConfig; +pub use mcp_ema::McpServerIdpOAuthConfig; pub use mcp_types::AppToolApproval; pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; pub use mcp_types::McpServerAuth; diff --git a/codex-rs/config/src/mcp_ema.rs b/codex-rs/config/src/mcp_ema.rs new file mode 100644 index 0000000000..6c5e4a863e --- /dev/null +++ b/codex-rs/config/src/mcp_ema.rs @@ -0,0 +1,351 @@ +//! Enterprise MCP configuration and registration provenance. +//! +//! Only host, user, or managed configuration selects the IdP. Plugin declarations +//! and project settings may not redirect the enterprise credential source. + +use std::io; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +use codex_features::Feature; + +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use crate::McpServerConfig; +use crate::McpServerTransportConfig; +use crate::types::PluginMcpServerEmaAuthConfig; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpServerIdpOAuthConfig { + /// Issuer used for enterprise OAuth discovery and identity validation. + pub issuer: String, + /// Public OAuth client registered with that enterprise IdP. + pub client_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpEnterpriseManagedAuthConfig { + /// Shared enterprise authorization, independent of Codex account credentials. + pub idp: McpServerIdpOAuthConfig, +} + +/// Immutable authorization selected after trusted policy and catalog resolution. +/// This cannot be deserialized from a server or plugin declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpEmaRegistration { + idp: McpServerIdpOAuthConfig, + server_url: String, + resource: Option, + client_id: Option, + authorization_server_issuer: Option, + scopes: Vec, +} + +impl McpEmaRegistration { + pub fn idp(&self) -> &McpServerIdpOAuthConfig { + &self.idp + } + + pub fn server_url(&self) -> &str { + &self.server_url + } + + pub fn resource(&self) -> Option<&str> { + self.resource.as_deref() + } + + pub fn client_id(&self) -> Option<&str> { + self.client_id.as_deref() + } + + pub fn authorization_server_issuer(&self) -> Option<&str> { + self.authorization_server_issuer.as_deref() + } + + pub fn scopes(&self) -> &[String] { + &self.scopes + } +} + +impl McpEnterpriseManagedAuthConfig { + /// Resolves the trusted profile and checks provenance at a config load boundary. + /// Plugin endpoints are checked later, when their declarations are materialized. + pub fn resolve( + stack: &ConfigLayerStack, + fallback: Option<&Self>, + servers: &std::collections::HashMap, + xaa_enabled: bool, + ) -> io::Result> { + validate_ema_auth_sources(stack, servers)?; + validate_xaa_opt_in_source(stack, xaa_enabled)?; + Self::from_config_layers(stack, fallback) + } + + /// Selects one complete trusted registration rather than merging issuer/client pairs. + fn from_config_layers( + stack: &ConfigLayerStack, + fallback: Option<&Self>, + ) -> io::Result> { + if stack.layers_high_to_low().next().is_none() { + return Ok(fallback.cloned()); + } + let sections = || { + stack.layers_high_to_low().filter_map(|layer| { + layer + .config + .get("mcp_enterprise_managed_auth") + .map(|section| (&layer.name, section)) + }) + }; + let selected = sections() + .find(|(source, _)| { + matches!( + source, + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::System { .. } + | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm + ) + }) + .or_else(|| { + sections().find(|(source, _)| !matches!(source, ConfigLayerSource::Project { .. })) + }); + selected + .map(|(_, section)| { + section.clone().try_into().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "enterprise IdP must be complete in one trusted config layer: {error}" + ), + ) + }) + }) + .transpose() + } +} + +impl McpServerConfig { + pub fn oauth_idp(&self) -> Option<&McpServerIdpOAuthConfig> { + self.ema_registration().map(McpEmaRegistration::idp) + } + + pub fn ema_registration(&self) -> Option<&McpEmaRegistration> { + self.oauth.as_ref()?.ema_registration.as_ref() + } + + /// EMA never falls back to an unrelated bearer or executor-owned credential. + pub fn validate_ema_auth_transport(&self) -> Result<(), &'static str> { + if !self.is_local_environment() { + return Err("ema_auth requires a host-owned MCP connection"); + } + let McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + http_headers, + env_http_headers, + http_headers_helper, + .. + } = &self.transport + else { + return Err("ema_auth requires streamable HTTP"); + }; + if bearer_token_env_var.is_some() + || http_headers + .as_ref() + .is_some_and(|headers| !headers.is_empty()) + || env_http_headers + .as_ref() + .is_some_and(|headers| !headers.is_empty()) + || http_headers_helper.is_some() + { + return Err("ema_auth cannot be combined with alternate HTTP credentials"); + } + Ok(()) + } +} + +fn plugin_ema_registration( + config: &toml::Value, + plugin_name: &str, + server_name: &str, +) -> Option { + config + .get("plugins")? + .get(plugin_name)? + .get("mcp_servers")? + .get(server_name)? + .get("ema_auth")? + .clone() + .try_into() + .ok() +} + +fn has_trusted_atomic_registration( + stack: &ConfigLayerStack, + non_project: &toml::Value, + matches: impl Fn(&toml::Value) -> bool, +) -> bool { + matches(non_project) + && stack + .layers_high_to_low() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. })) + .any(|layer| matches(&layer.config)) +} + +fn reenabled_over_non_project_denial( + effective_enabled: bool, + non_project: Option<&toml::Value>, +) -> bool { + effective_enabled + && non_project + .and_then(|server| server.get("enabled")) + .and_then(toml::Value::as_bool) + == Some(false) +} + +/// Projects cannot add, replace, or downgrade a non-project enterprise registration. +fn validate_ema_auth_sources( + stack: &ConfigLayerStack, + servers: &std::collections::HashMap, +) -> io::Result<()> { + if stack.layers_high_to_low().next().is_none() { + return Ok(()); + } + let mut non_project = toml::Value::Table(Default::default()); + for layer in stack + .layers_low_to_high() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. })) + { + crate::merge_toml_values(&mut non_project, &layer.config); + } + + for (name, effective) in servers { + let non_project_server = non_project + .get("mcp_servers") + .and_then(|servers| servers.get(name)); + let inherited_ema = non_project_server + .and_then(|server| server.get("auth")) + .and_then(toml::Value::as_str) + == Some("ema_auth"); + if !matches!(effective.auth, crate::McpServerAuth::EmaAuth) && !inherited_ema { + continue; + } + if reenabled_over_non_project_denial(effective.enabled, non_project_server) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("project configuration cannot re-enable enterprise MCP server `{name}`"), + )); + } + let matches_effective_authorization = |server: &McpServerConfig| { + matches!(server.auth, crate::McpServerAuth::EmaAuth) + && server.auth == effective.auth + && server.transport == effective.transport + && server.scopes == effective.scopes + && server.oauth == effective.oauth + && server.oauth_resource == effective.oauth_resource + }; + if !has_trusted_atomic_registration(stack, &non_project, |config| { + config + .get("mcp_servers") + .and_then(|servers| servers.get(name)) + .and_then(|server| server.clone().try_into::().ok()) + .is_some_and(|server| matches_effective_authorization(&server)) + }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "enterprise MCP server `{name}` must define its transport, authorization, scopes, and resource in one non-project config layer" + ), + )); + } + } + + let effective_config = stack.effective_config(); + let Some(plugins) = effective_config + .get("plugins") + .and_then(toml::Value::as_table) + else { + return Ok(()); + }; + for (plugin_name, plugin) in plugins { + let Some(plugin_servers) = plugin.get("mcp_servers").and_then(toml::Value::as_table) else { + continue; + }; + for (server_name, server) in plugin_servers { + let Some(ema_auth) = server.get("ema_auth") else { + continue; + }; + let non_project_server = non_project + .get("plugins") + .and_then(|plugins| plugins.get(plugin_name)) + .and_then(|plugin| plugin.get("mcp_servers")) + .and_then(|servers| servers.get(server_name)); + if reenabled_over_non_project_denial( + server.get("enabled").and_then(toml::Value::as_bool) != Some(false), + non_project_server, + ) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "project configuration cannot re-enable enterprise MCP plugin `{plugin_name}` server `{server_name}`" + ), + )); + } + let effective = ema_auth + .clone() + .try_into::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + if !has_trusted_atomic_registration(stack, &non_project, |config| { + plugin_ema_registration(config, plugin_name, server_name) + .is_some_and(|registration| registration == effective) + }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "enterprise MCP registration for plugin `{plugin_name}` server `{server_name}` must be defined in one non-project config layer" + ), + )); + } + } + } + Ok(()) +} + +/// Enabling XAA requires an explicit non-project setting or managed requirement. +fn validate_xaa_opt_in_source(stack: &ConfigLayerStack, xaa_enabled: bool) -> io::Result<()> { + if !xaa_enabled || stack.layers_high_to_low().next().is_none() { + return Ok(()); + } + let required = stack + .requirements() + .feature_requirements + .as_ref() + .and_then(|requirements| requirements.value.entries.get(Feature::UseXaa.key())) + .copied(); + let configured = stack + .layers_high_to_low() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::Project { .. })) + .find_map(|layer| { + layer + .config + .get("features")? + .get(Feature::UseXaa.key())? + .as_bool() + }); + if required.or(configured) == Some(true) { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "`[features].use_xaa = true` must be selected in a non-project config layer", + )) +} + +#[cfg(test)] +#[path = "mcp_ema_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/mcp_ema_tests.rs b/codex-rs/config/src/mcp_ema_tests.rs new file mode 100644 index 0000000000..5b3f50eed7 --- /dev/null +++ b/codex-rs/config/src/mcp_ema_tests.rs @@ -0,0 +1,113 @@ +//! Enterprise registration provenance checks. + +use super::*; +use crate::AbsolutePathBuf; +use crate::ConfigLayerEntry; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use pretty_assertions::assert_eq; + +fn stack(layers: Vec<(ConfigLayerSource, &str)>) -> ConfigLayerStack { + ConfigLayerStack::new( + layers + .into_iter() + .map(|(source, value)| ConfigLayerEntry::new(source, toml::from_str(value).unwrap())) + .collect(), + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .unwrap() +} + +fn local_sources(name: &str) -> (ConfigLayerSource, ConfigLayerSource, ConfigLayerSource) { + let path = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)).unwrap(); + ( + ConfigLayerSource::System { file: path.clone() }, + ConfigLayerSource::User { + file: path.clone(), + profile: None, + }, + ConfigLayerSource::Project { + dot_codex_folder: path, + }, + ) +} + +#[test] +fn ema_profiles_and_auth_modes_preserve_non_project_authority() { + let (system, user, project) = local_sources("ema-config"); + let trusted = "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://idp.example'\nclient_id = 'enterprise'"; + let replacement = + "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://other.example'\nclient_id = 'other'"; + let expected = McpEnterpriseManagedAuthConfig { + idp: McpServerIdpOAuthConfig { + issuer: "https://idp.example".into(), + client_id: "enterprise".into(), + }, + }; + for (layers, selected) in [ + ( + vec![ + (system.clone(), trusted), + (user.clone(), replacement), + (project.clone(), replacement), + ], + Some(expected.clone()), + ), + ( + vec![(user.clone(), trusted), (project.clone(), replacement)], + Some(expected.clone()), + ), + (vec![(project, trusted)], None), + ( + vec![ + (system, trusted), + ( + user, + "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'", + ), + ], + Some(expected), + ), + ] { + assert_eq!( + McpEnterpriseManagedAuthConfig::from_config_layers( + &stack(layers), + /*fallback*/ None + ) + .unwrap(), + selected + ); + } + let incomplete = stack(vec![( + ConfigLayerSource::SessionFlags, + "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'", + )]); + assert!( + McpEnterpriseManagedAuthConfig::from_config_layers(&incomplete, /*fallback*/ None).is_err() + ); + + let server: McpServerConfig = toml::from_str("url='https://resource.example'\nauth='ema_auth'\n[oauth.idp]\nissuer='https://other.example'\nclient_id='other'").unwrap(); + assert_eq!(server.oauth_idp(), None); +} + +#[test] +fn ema_rejects_alternate_credentials_and_executor_custody() { + for extra in [ + "bearer_token_env_var='TOKEN'", + "http_headers.Authorization='secret'", + "http_headers.Accept='application/json'", + "env_http_headers.X-Key='TOKEN'", + "http_headers_helper='get-headers'", + "environment_id='remote'", + ] { + let server: McpServerConfig = toml::from_str(&format!( + "url='https://resource.example'\nauth='ema_auth'\n{extra}" + )) + .unwrap(); + assert!(server.validate_ema_auth_transport().is_err(), "{extra}"); + } + let server: McpServerConfig = + toml::from_str("url='https://resource.example'\nauth='ema_auth'\nhttp_headers={}").unwrap(); + assert_eq!(server.validate_ema_auth_transport(), Ok(())); +} diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 5d18433be1..38d559df51 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -16,6 +16,7 @@ use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; +use crate::McpEmaRegistration; use crate::RequirementSource; /// Effective MCP environment id when config omits `environment_id`. @@ -150,8 +151,8 @@ impl AsRef for McpServerEnvVar { } } -/// OAuth client settings used when Codex launches an MCP OAuth flow. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +/// Client settings for MCP OAuth login or enterprise token exchange. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct McpServerOAuthConfig { /// Explicit OAuth client identifier to present during authorization and token exchange. @@ -165,13 +166,19 @@ pub struct McpServerOAuthConfig { /// Fixed callback port that takes precedence over Codex's global OAuth callback port. #[serde(default, skip_serializing_if = "Option::is_none")] pub callback_port: Option, + + /// Expected resource authorization server issuer for EMA token exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_server_issuer: Option, + + /// Host-resolved authorization; never accepted from a server or plugin declaration. + #[serde(skip)] + #[schemars(skip)] + pub ema_registration: Option, } -/// Authentication flow Codex attempts after resolving an HTTP MCP server's -/// configured bearer token and authorization headers, which always take -/// precedence. ChatGPT authentication falls back to stored OAuth credentials -/// when its session provider is unavailable; both modes ultimately fall back -/// to an unauthenticated connection. +/// Authentication flow for an HTTP MCP server. Explicit credentials take +/// precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum McpServerAuth { @@ -185,6 +192,10 @@ pub enum McpServerAuth { /// still fall back to stored OAuth credentials. #[serde(rename = "chatgpt")] ChatGpt, + /// Exchange an enterprise IdP refresh token for resource-specific authorization. + /// Alternate credentials and ordinary OAuth fallback are not permitted. + #[serde(rename = "ema_auth")] + EmaAuth, } impl McpServerAuth { @@ -198,7 +209,7 @@ pub struct McpServerConfig { #[serde(flatten)] pub transport: McpServerTransportConfig, - /// Authentication flow to use when no configured authorization resolves. + /// Authentication flow, including an explicit no-fallback EMA mode. #[serde(default, skip_serializing_if = "McpServerAuth::is_default")] pub auth: McpServerAuth, @@ -250,15 +261,15 @@ pub struct McpServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_tools: Option>, - /// Optional OAuth scopes to request during MCP login. + /// Optional scopes requested during MCP login or EMA token exchange. #[serde(default, skip_serializing_if = "Option::is_none")] pub scopes: Option>, - /// Optional OAuth client settings for MCP login. + /// Optional client settings for MCP login or EMA token exchange. #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth: Option, - /// Optional OAuth resource parameter to include during MCP login (RFC 8707). + /// Optional resource parameter for MCP login or EMA token exchange (RFC 8707). #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth_resource: Option, @@ -490,10 +501,20 @@ impl TryFrom for McpServerConfig { let environment_id = environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()); + let auth = auth.unwrap_or_default(); + if !matches!(auth, McpServerAuth::EmaAuth) + && oauth + .as_ref() + .is_some_and(|oauth| oauth.authorization_server_issuer.is_some()) + { + return Err( + "oauth.authorization_server_issuer requires auth = \"ema_auth\"".to_string(), + ); + } Ok(Self { transport, - auth: auth.unwrap_or_default(), + auth, environment_id, startup_timeout_sec, tool_timeout_sec, diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index f7a70787c5..2ccb848b19 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -376,6 +376,7 @@ fn deserialize_streamable_http_server_config_with_oauth_client_id() { client_id: Some("eci-prd-pub-codex-123".to_string()), callback_url: Some("http://127.0.0.1/callback/registered".to_string()), callback_port: Some(9876), + ..Default::default() }) ); } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index abd8ee058f..6fc4b09146 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -3,6 +3,8 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. +pub use crate::mcp_ema::McpEnterpriseManagedAuthConfig; +pub use crate::mcp_ema::McpServerIdpOAuthConfig; pub use crate::mcp_types::AppToolApproval; pub use crate::mcp_types::McpServerAuth; pub use crate::mcp_types::McpServerConfig; @@ -923,7 +925,7 @@ pub struct PluginConfig { /// Policy settings for a plugin-provided MCP server. /// /// This intentionally excludes transport settings: plugin manifests own how the -/// MCP server is launched, while user config owns enablement and tool policy. +/// MCP server is launched, while host config owns enablement, auth, and tool policy. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PluginMcpServerConfig { @@ -931,6 +933,10 @@ pub struct PluginMcpServerConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Host-configured EMA registration; the plugin still owns its endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ema_auth: Option, + /// Approval mode for tools in this server unless a tool override exists. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_tools_approval_mode: Option, @@ -952,6 +958,7 @@ impl Default for PluginMcpServerConfig { fn default() -> Self { Self { enabled: true, + ema_auth: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, @@ -960,6 +967,20 @@ impl Default for PluginMcpServerConfig { } } +/// Resource registration applied through an existing per-plugin policy overlay. +/// The enterprise IdP is selected separately by trusted host configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PluginMcpServerEmaAuthConfig { + /// Exact plugin endpoint approved by the host; never overrides the declaration. + pub url: String, + pub client_id: String, + pub authorization_server_issuer: String, + #[serde(default)] + pub scopes: Vec, + pub resource: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct MarketplaceConfig { diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 1ccd60066d..889174891a 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -969,6 +969,9 @@ async fn load_plugin( fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) { config.enabled = policy.enabled; + if policy.ema_auth.is_some() { + config.auth = codex_config::McpServerAuth::EmaAuth; + } if let Some(approval_mode) = policy.default_tools_approval_mode { config.default_tools_approval_mode = Some(approval_mode); } @@ -1435,6 +1438,9 @@ pub fn apply_configured_plugin_mcp_server_policies( ) { for (name, server) in servers { if let Some(policy) = policies.get(name) { + if policy.ema_auth.is_some() { + server.auth = codex_config::McpServerAuth::EmaAuth; + } let declared_approval_mode = server.default_tools_approval_mode.unwrap_or_default(); server.enabled &= policy.enabled; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index e7963d443f..50d3598399 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -999,6 +999,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { client_id: Some("client-id".to_string()), callback_url: None, callback_port: Some(3118), + ..Default::default() }), oauth_resource: None, tools: HashMap::new(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 350723936d..fa65106c6f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1073,6 +1073,9 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, + "use_xaa": { + "type": "boolean" + }, "view_image": { "type": "boolean" }, @@ -2073,8 +2076,25 @@ }, "type": "object" }, + "McpEnterpriseManagedAuthConfig": { + "additionalProperties": false, + "properties": { + "idp": { + "allOf": [ + { + "$ref": "#/definitions/McpServerIdpOAuthConfig" + } + ], + "description": "Shared enterprise authorization, independent of Codex account credentials." + } + }, + "required": [ + "idp" + ], + "type": "object" + }, "McpServerAuth": { - "description": "Authentication flow Codex attempts after resolving an HTTP MCP server's configured bearer token and authorization headers, which always take precedence. ChatGPT authentication falls back to stored OAuth credentials when its session provider is unavailable; both modes ultimately fall back to an unauthenticated connection.", + "description": "Authentication flow for an HTTP MCP server. Explicit credentials take precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback.", "oneOf": [ { "description": "Use stored MCP OAuth credentials when available. Starting an OAuth login is a separate operation.", @@ -2089,6 +2109,13 @@ "chatgpt" ], "type": "string" + }, + { + "description": "Exchange an enterprise IdP refresh token for resource-specific authorization. Alternate credentials and ordinary OAuth fallback are not permitted.", + "enum": [ + "ema_auth" + ], + "type": "string" } ] }, @@ -2114,10 +2141,32 @@ } ] }, + "McpServerIdpOAuthConfig": { + "additionalProperties": false, + "properties": { + "client_id": { + "description": "Public OAuth client registered with that enterprise IdP.", + "type": "string" + }, + "issuer": { + "description": "Issuer used for enterprise OAuth discovery and identity validation.", + "type": "string" + } + }, + "required": [ + "client_id", + "issuer" + ], + "type": "object" + }, "McpServerOAuthConfig": { "additionalProperties": false, - "description": "OAuth client settings used when Codex launches an MCP OAuth flow.", + "description": "Client settings for MCP OAuth login or enterprise token exchange.", "properties": { + "authorization_server_issuer": { + "description": "Expected resource authorization server issuer for EMA token exchange.", + "type": "string" + }, "callback_port": { "description": "Fixed callback port that takes precedence over Codex's global OAuth callback port.", "format": "uint16", @@ -3167,7 +3216,7 @@ }, "PluginMcpServerConfig": { "additionalProperties": false, - "description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while user config owns enablement and tool policy.", + "description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while host config owns enablement, auth, and tool policy.", "properties": { "default_tools_approval_mode": { "allOf": [ @@ -3184,6 +3233,14 @@ }, "type": "array" }, + "ema_auth": { + "allOf": [ + { + "$ref": "#/definitions/PluginMcpServerEmaAuthConfig" + } + ], + "description": "Host-configured EMA registration; the plugin still owns its endpoint." + }, "enabled": { "default": true, "description": "When `false`, Codex skips initializing this plugin MCP server.", @@ -3206,6 +3263,39 @@ }, "type": "object" }, + "PluginMcpServerEmaAuthConfig": { + "additionalProperties": false, + "description": "Resource registration applied through an existing per-plugin policy overlay. The enterprise IdP is selected separately by trusted host configuration.", + "properties": { + "authorization_server_issuer": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "scopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "url": { + "description": "Exact plugin endpoint approved by the host; never overrides the declaration.", + "type": "string" + } + }, + "required": [ + "authorization_server_issuer", + "client_id", + "resource", + "url" + ], + "type": "object" + }, "ProfileTui": { "additionalProperties": false, "description": "TUI settings supported inside a named profile.", @@ -6598,6 +6688,9 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, + "use_xaa": { + "type": "boolean" + }, "view_image": { "type": "boolean" }, @@ -6740,6 +6833,15 @@ "description": "User-level marketplace entries keyed by marketplace name.", "type": "object" }, + "mcp_enterprise_managed_auth": { + "allOf": [ + { + "$ref": "#/definitions/McpEnterpriseManagedAuthConfig" + } + ], + "default": null, + "description": "Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins." + }, "mcp_oauth_callback_port": { "description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.", "format": "uint16", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0f5d81643d..99c91f2f30 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6152,6 +6152,50 @@ async fn config_applies_managed_auth_store_and_chatgpt_base_url() -> std::io::Re Ok(()) } +#[tokio::test] +async fn project_cannot_be_the_only_xaa_opt_in_source() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: codex_home.path().join("project/.codex").abs(), + }, + toml::toml! { + [features] + use_xaa = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let cfg = config_layer_stack + .effective_config() + .try_into() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + + let error = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + config_layer_stack, + ) + .await + .expect_err("project-only XAA opt-in must fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!( + error + .to_string() + .contains("must be selected in a non-project config layer") + ); + Ok(()) +} + #[tokio::test] async fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -7952,6 +7996,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh client_id: Some("eci-prd-pub-codex-123".to_string()), callback_url: None, callback_port: None, + ..Default::default() }), oauth_resource: Some("https://resource.example.com".to_string()), tools: HashMap::new(), @@ -11126,6 +11171,7 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R [features] personality = true shell_tool = false +use_xaa = true "#, ), ) @@ -11149,6 +11195,10 @@ shell_tool = false #[tokio::test] async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "[features]\nuse_xaa = true\n", + )?; let mut config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) @@ -11159,6 +11209,7 @@ async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Resul unified_exec = false shell_tool = true unified_exec_zsh_fork = false +use_xaa = false "#, ), ) diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index acf598bca1..0c804412db 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -101,8 +101,10 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result {} + McpServerAuth::ChatGpt => entry["auth"] = value("chatgpt"), + McpServerAuth::EmaAuth => entry["auth"] = value("ema_auth"), } if !config.enabled { entry["enabled"] = value(false); @@ -159,6 +161,9 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result Result<()> { + let home = tempdir()?; + let workspace = tempdir()?; + std::fs::create_dir_all(workspace.path().join(".git"))?; + std::fs::create_dir_all(workspace.path().join(".codex"))?; + set_project_trust_level(home.path(), workspace.path(), TrustLevel::Trusted)?; + let managed_config = r#" +[features] +use_xaa = true +[mcp_enterprise_managed_auth.idp] +issuer = "https://idp.example" +client_id = "idp-client" +[mcp_servers.enterprise] +url = "https://resource.example/mcp" +auth = "ema_auth" +scopes = ["files.read"] +oauth_resource = "https://resource.example/mcp" +[mcp_servers.enterprise.oauth] +client_id = "mcp-client" +"#; + let builder = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .codex_home(home.path().to_path_buf()) + .fallback_cwd(Some(workspace.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::enterprise_config(managed_config.to_string()) + .add_enterprise_requirement( + "[mcp_servers.enterprise.identity]\nurl = \"https://resource.example/mcp\"\n", + ) + .into_loader(), + ); + assert_eq!( + builder.clone().build().await?.mcp_servers.get()["enterprise"].auth, + McpServerAuth::EmaAuth, + ); + std::fs::write( + workspace.path().join(".codex/config.toml"), + format!("[mcp_servers.enterprise]\nauth = {project_auth:?}\n"), + )?; + let error = builder + .build() + .await + .expect_err("a trusted project must not downgrade EMA before MCP startup"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + let message = format!("{error:#}"); + assert!( + message.ends_with("one non-project config layer"), + "{message}" + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index a22bbb7195..bd001a7578 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -419,6 +419,7 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()> client_id: None, callback_url: None, callback_port: Some(skill_callback_port), + ..Default::default() }) ); assert!(!servers.contains_key(DENIED_SERVER_NAME)); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 610d96f869..566cee6083 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -100,6 +100,7 @@ mod json_result; mod live_cli; mod mcp_auth_elicitation; mod mcp_auth_refresh; +mod mcp_ema_config; mod mcp_extension_protocol; mod mcp_optional_startup_grace; #[cfg(unix)] diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 1a31316b79..0ae811690e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -215,6 +215,8 @@ pub enum Feature { CodexAppsMcp20260728, /// Let RMCP coordinate OAuth refresh through the configured credential store. McpOAuthRefreshCoordination, + /// Enable enterprise refresh-token authorization for configured MCP resources. + UseXaa, /// Removed compatibility flag for the legacy Apps MCP path override. AppsMcpPathOverride, /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. @@ -1325,6 +1327,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::UseXaa, + key: "use_xaa", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::AppsMcpPathOverride, key: "apps_mcp_path_override",