diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ef3380706..847ec8622 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -305,6 +305,7 @@ mod tests { ]), }), mcp_servers: None, + apps: None, rules: None, enforce_residency: Some(CoreResidencyRequirement::Us), network: Some(CoreNetworkRequirementsToml { @@ -375,6 +376,7 @@ mod tests { allowed_web_search_modes: Some(Vec::new()), feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index c6852200e..18c95c3d3 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -805,6 +805,7 @@ mod tests { use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; + use std::collections::BTreeMap; use std::collections::VecDeque; use std::future::pending; use std::path::Path; @@ -1104,6 +1105,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1147,6 +1149,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1154,6 +1157,31 @@ mod tests { ); } + #[tokio::test] + async fn fetch_cloud_requirements_parses_apps_requirements_toml() { + let result = parse_for_fetch(Some( + r#" +[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] +enabled = false +"#, + )); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + apps: Some(codex_core::config_loader::AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), + codex_core::config_loader::AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }) + ); + } + #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { let auth_manager = auth_manager_with_plan("enterprise"); @@ -1201,6 +1229,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1251,6 +1280,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1301,6 +1331,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1461,6 +1492,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1489,6 +1521,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1537,6 +1570,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1584,6 +1618,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1635,6 +1670,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1687,6 +1723,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1739,6 +1776,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1824,6 +1862,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -1848,6 +1887,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 7e6a97657..f1cb9336b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -245,6 +245,43 @@ impl FeatureRequirementsToml { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppRequirementToml { + pub enabled: Option, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppsRequirementsToml { + #[serde(default, flatten)] + pub apps: BTreeMap, +} + +impl AppsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.apps.values().all(|app| app.enabled.is_none()) + } +} + +/// Merge `enabled` configs from a lower-precedence source into an existing higher-precedence set. +/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers. +/// Implemented with AppsRequirementsToml for now, could be abstracted if we have more enablement-style configs in the future. +pub(crate) fn merge_enablement_settings_descending( + base: &mut AppsRequirementsToml, + incoming: AppsRequirementsToml, +) { + for (app_id, incoming_requirement) in incoming.apps { + let base_requirement = base.apps.entry(app_id).or_default(); + let higher_precedence = base_requirement.enabled; + let lower_precedence = incoming_requirement.enabled; + base_requirement.enabled = + if higher_precedence == Some(false) || lower_precedence == Some(false) { + Some(false) + } else { + higher_precedence.or(lower_precedence) + }; + } +} + /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { @@ -254,6 +291,7 @@ pub struct ConfigRequirementsToml { #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub mcp_servers: Option>, + pub apps: Option, pub rules: Option, pub enforce_residency: Option, #[serde(rename = "experimental_network")] @@ -289,6 +327,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_web_search_modes: Option>>, pub feature_requirements: Option>, pub mcp_servers: Option>>, + pub apps: Option>, pub rules: Option>, pub enforce_residency: Option>, pub network: Option>, @@ -300,10 +339,6 @@ impl ConfigRequirementsWithSources { // in `self` is `None`, copy the value from `other` into `self`. macro_rules! fill_missing_take { ($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => { - // Destructure without `..` so adding fields to `ConfigRequirementsToml` - // forces this merge logic to be updated. - let ConfigRequirementsToml { $($field: _,)+ } = &$other; - $( if $base.$field.is_none() && let Some(value) = $other.$field.take() @@ -314,6 +349,20 @@ impl ConfigRequirementsWithSources { }; } + // Destructure without `..` so adding fields to `ConfigRequirementsToml` + // forces this merge logic to be updated. + let ConfigRequirementsToml { + allowed_approval_policies: _, + allowed_sandbox_modes: _, + allowed_web_search_modes: _, + feature_requirements: _, + mcp_servers: _, + apps: _, + rules: _, + enforce_residency: _, + network: _, + } = &other; + let mut other = other; fill_missing_take!( self, @@ -330,6 +379,14 @@ impl ConfigRequirementsWithSources { network, } ); + + if let Some(incoming_apps) = other.apps.take() { + if let Some(existing_apps) = self.apps.as_mut() { + merge_enablement_settings_descending(&mut existing_apps.value, incoming_apps); + } else { + self.apps = Some(Sourced::new(incoming_apps, source)); + } + } } pub fn into_toml(self) -> ConfigRequirementsToml { @@ -339,6 +396,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes, feature_requirements, mcp_servers, + apps, rules, enforce_residency, network, @@ -349,6 +407,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), + apps: apps.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), network: network.map(|sourced| sourced.value), @@ -399,6 +458,10 @@ impl ConfigRequirementsToml { .as_ref() .is_none_or(FeatureRequirementsToml::is_empty) && self.mcp_servers.is_none() + && self + .apps + .as_ref() + .is_none_or(AppsRequirementsToml::is_empty) && self.rules.is_none() && self.enforce_residency.is_none() && self.network.is_none() @@ -415,6 +478,7 @@ impl TryFrom for ConfigRequirements { allowed_web_search_modes, feature_requirements, mcp_servers, + apps: _apps, rules, enforce_residency, network, @@ -622,6 +686,7 @@ mod tests { allowed_web_search_modes, feature_requirements, mcp_servers, + apps, rules, enforce_residency, network, @@ -636,6 +701,7 @@ mod tests { feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), + apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), enforce_residency: enforce_residency .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -671,6 +737,7 @@ mod tests { allowed_web_search_modes: Some(allowed_web_search_modes.clone()), feature_requirements: Some(feature_requirements.clone()), mcp_servers: None, + apps: None, rules: None, enforce_residency: Some(enforce_residency), network: None, @@ -695,6 +762,7 @@ mod tests { enforce_source.clone(), )), mcp_servers: None, + apps: None, rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), network: None, @@ -728,6 +796,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -769,6 +838,7 @@ mod tests { allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -777,6 +847,174 @@ mod tests { Ok(()) } + #[test] + fn deserialize_apps_requirements() -> Result<()> { + let toml_str = r#" + [apps.connector_123123] + enabled = false + "#; + let requirements: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + requirements.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }) + ); + Ok(()) + } + + fn apps_requirements(entries: &[(&str, Option)]) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: entries + .iter() + .map(|(app_id, enabled)| { + ( + (*app_id).to_string(), + AppRequirementToml { enabled: *enabled }, + ) + }) + .collect(), + } + } + + #[test] + fn merge_enablement_settings_descending_unions_distinct_apps() { + let mut merged = apps_requirements(&[("connector_high", Some(false))]); + let lower = apps_requirements(&[("connector_low", Some(true))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[ + ("connector_high", Some(false)), + ("connector_low", Some(true)) + ]), + ); + } + + #[test] + fn merge_enablement_settings_descending_prefers_false_from_lower_precedence() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", Some(false))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_keeps_higher_true_when_lower_is_unset() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", None)]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_uses_lower_value_when_higher_missing() { + let mut merged = apps_requirements(&[]); + let lower = apps_requirements(&[("connector_123123", Some(true))]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_enablement_settings_descending_preserves_higher_false_when_lower_missing_app() { + let mut merged = apps_requirements(&[("connector_123123", Some(false))]); + let lower = apps_requirements(&[]); + + merge_enablement_settings_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { + let higher_source = RequirementSource::CloudRequirements; + let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + higher_source.clone(), + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_shared", Some(true)), + ])), + ..Default::default() + }, + ); + target.merge_unset_fields( + lower_source, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ])), + ..Default::default() + }, + ); + + let apps = target.apps.expect("apps should be present"); + assert_eq!( + apps.value, + apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ]) + ); + assert_eq!(apps.source, higher_source); + } + + #[test] + fn merge_unset_fields_apps_empty_higher_source_does_not_block_lower_disables() { + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + RequirementSource::CloudRequirements, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[])), + ..Default::default() + }, + ); + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[("connector_123123", Some(false))])), + ..Default::default() + }, + ); + + assert_eq!( + target.apps.map(|apps| apps.value), + Some(apps_requirements(&[("connector_123123", Some(false))])), + ); + } + #[test] fn constraint_error_includes_requirement_source() -> Result<()> { let source: ConfigRequirementsToml = from_str( diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e6355c6cc..995a5b8db 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -13,6 +13,8 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; +pub use config_requirements::AppRequirementToml; +pub use config_requirements::AppsRequirementsToml; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::ConfigRequirementsWithSources; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2eb183b71..bf4285421 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4579,6 +4579,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any ]), feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -5177,6 +5178,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 60f9d5d7e..576a390f5 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -24,6 +24,8 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; +pub use codex_config::AppRequirementToml; +pub use codex_config::AppsRequirementsToml; pub use codex_config::CloudRequirementsLoadError; pub use codex_config::CloudRequirementsLoadErrorCode; pub use codex_config::CloudRequirementsLoader; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 452c5da98..021ff1145 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -605,6 +605,7 @@ allowed_approval_policies = ["on-request"] allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -654,6 +655,7 @@ allowed_approval_policies = ["on-request"] allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, @@ -692,6 +694,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index cced54174..af1b5c5ab 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -28,6 +28,7 @@ use crate::SandboxState; use crate::config::Config; use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; +use crate::config_loader::AppsRequirementsToml; use crate::default_client::create_client; use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; @@ -592,12 +593,28 @@ pub fn merge_plugin_apps_with_accessible( } pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { - let apps_config = read_apps_config(config); - if let Some(apps_config) = apps_config.as_ref() { - for connector in &mut connectors { + let user_apps_config = read_user_apps_config(config); + let requirements_apps_config = config.config_layer_stack.requirements_toml().apps.as_ref(); + if user_apps_config.is_none() && requirements_apps_config.is_none() { + return connectors; + } + + for connector in &mut connectors { + if let Some(apps_config) = user_apps_config.as_ref() + && (apps_config.default.is_some() + || apps_config.apps.contains_key(connector.id.as_str())) + { connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); } + + if requirements_apps_config + .and_then(|apps| apps.apps.get(connector.id.as_str())) + .is_some_and(|app| app.enabled == Some(false)) + { + connector.is_enabled = false; + } } + connectors } @@ -691,9 +708,45 @@ fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: } fn read_apps_config(config: &Config) -> Option { - let effective_config = config.config_layer_stack.effective_config(); - let apps_config = effective_config.as_table()?.get("apps")?.clone(); - AppsConfigToml::deserialize(apps_config).ok() + let apps_config = read_user_apps_config(config); + let had_apps_config = apps_config.is_some(); + let mut apps_config = apps_config.unwrap_or_default(); + apply_requirements_apps_constraints( + &mut apps_config, + config.config_layer_stack.requirements_toml().apps.as_ref(), + ); + if had_apps_config || apps_config.default.is_some() || !apps_config.apps.is_empty() { + Some(apps_config) + } else { + None + } +} + +fn read_user_apps_config(config: &Config) -> Option { + config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .cloned() + .and_then(|value| AppsConfigToml::deserialize(value).ok()) +} + +fn apply_requirements_apps_constraints( + apps_config: &mut AppsConfigToml, + requirements_apps_config: Option<&AppsRequirementsToml>, +) { + let Some(requirements_apps_config) = requirements_apps_config else { + return; + }; + + for (app_id, requirement) in &requirements_apps_config.apps { + if requirement.enabled != Some(false) { + continue; + } + let app = apps_config.apps.entry(app_id.clone()).or_default(); + app.enabled = false; + } } fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool { diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 3743fb9f8..3b731e508 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -4,12 +4,21 @@ use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsDefaultConfig; +use crate::config_loader::AppRequirementToml; +use crate::config_loader::AppsRequirementsToml; +use crate::config_loader::CloudRequirementsLoader; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; +use codex_config::CONFIG_TOML_FILE; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; +use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use tempfile::tempdir; @@ -410,6 +419,270 @@ fn app_is_enabled_prefers_per_app_override_over_default() { assert!(!app_is_enabled(&apps_config, Some("drive"))); } +#[test] +fn requirements_disabled_connector_overrides_enabled_connector() { + let mut effective_apps = AppsConfigToml { + default: None, + apps: HashMap::from([( + "connector_123123".to_string(), + AppConfig { + enabled: true, + ..Default::default() + }, + )]), + }; + let requirements_apps = AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }; + + apply_requirements_apps_constraints(&mut effective_apps, Some(&requirements_apps)); + + assert_eq!( + effective_apps + .apps + .get("connector_123123") + .map(|app| app.enabled), + Some(false) + ); +} + +#[test] +fn requirements_enabled_does_not_override_disabled_connector() { + let mut effective_apps = AppsConfigToml { + default: None, + apps: HashMap::from([( + "connector_123123".to_string(), + AppConfig { + enabled: false, + ..Default::default() + }, + )]), + }; + let requirements_apps = AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(true), + }, + )]), + }; + + apply_requirements_apps_constraints(&mut effective_apps, Some(&requirements_apps)); + + assert_eq!( + effective_apps + .apps + .get("connector_123123") + .map(|app| app.enabled), + Some(false) + ); +} + +#[tokio::test] +async fn cloud_requirements_disable_connector_overrides_user_apps_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[apps.connector_123123] +enabled = true +"#, + ) + .expect("write config"); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await + .expect("config should build"); + + let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[tokio::test] +async fn cloud_requirements_disable_connector_applies_without_user_apps_table() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config"); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await + .expect("config should build"); + + let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[tokio::test] +async fn local_requirements_disable_connector_overrides_user_apps_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let config_toml_path = + AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)).expect("abs path"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack") + .with_user_config( + &config_toml_path, + toml::from_str::( + r#" +[apps.connector_123123] +enabled = true +"#, + ) + .expect("apps config"), + ); + + let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[tokio::test] +async fn local_requirements_disable_connector_applies_without_user_apps_table() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack"); + + let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); +} + +#[tokio::test] +async fn with_app_enabled_state_preserves_unrelated_disabled_connector() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_drive".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack"); + + let mut slack = app("connector_slack"); + slack.is_enabled = false; + + let mut drive = app("connector_drive"); + drive.is_enabled = false; + + assert_eq!( + with_app_enabled_state(vec![slack.clone(), app("connector_drive")], &config), + vec![slack, drive] + ); +} + #[test] fn app_tool_policy_honors_default_app_enabled_false() { let apps_config = AppsConfigToml { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1e16eccf6..c3b73659b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5293,6 +5293,44 @@ mod tests { assert_eq!(mention.path, Some("app://connector_1".to_string())); } + #[test] + fn set_connector_mentions_skips_disabled_connectors() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + let connectors = vec![AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); + + assert!( + matches!(composer.active_popup, ActivePopup::None), + "disabled connectors should not appear in the mention popup" + ); + } + #[test] fn set_plugin_mentions_refreshes_open_mention_popup() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4162bbfe7..e9fcdeee0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -25,6 +25,11 @@ use codex_core::config::ConstraintError; use codex_core::config::types::Notifications; #[cfg(target_os = "windows")] use codex_core::config::types::WindowsSandboxModeToml; +use codex_core::config_loader::AppRequirementToml; +use codex_core::config_loader::AppsRequirementsToml; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigRequirements; +use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::features::FEATURES; use codex_core::features::Feature; @@ -7171,6 +7176,144 @@ async fn apps_initial_load_applies_enabled_state_from_config() { ); } +#[tokio::test] +async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_override() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + set_chatgpt_auth(&mut chat); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + chat.bottom_pane.set_connectors_enabled(true); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_1".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + let temp = tempdir().expect("tempdir"); + let config_toml_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + chat.config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack") + .with_user_config( + &config_toml_path, + toml::from_str::( + "[apps.connector_1]\nenabled = true\ndisabled_reason = \"user\"\n", + ) + .expect("apps config"), + ); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }], + }), + true, + ); + + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) + if snapshot + .connectors + .iter() + .find(|connector| connector.id == "connector_1") + .is_some_and(|connector| !connector.is_enabled) + ); + + chat.add_connectors_output(); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Installed · Disabled. Press Enter to open the app page"), + "expected requirements-disabled connector to render as disabled, got:\n{popup}" + ); +} + +#[tokio::test] +async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_entry() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + set_chatgpt_auth(&mut chat); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + chat.bottom_pane.set_connectors_enabled(true); + + let requirements = ConfigRequirementsToml { + apps: Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_1".to_string(), + AppRequirementToml { + enabled: Some(false), + }, + )]), + }), + ..Default::default() + }; + chat.config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack"); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }], + }), + true, + ); + + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) + if snapshot + .connectors + .iter() + .find(|connector| connector.id == "connector_1") + .is_some_and(|connector| !connector.is_enabled) + ); + + chat.add_connectors_output(); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Installed · Disabled. Press Enter to open the app page"), + "expected requirements-disabled connector to render as disabled, got:\n{popup}" + ); +} + #[tokio::test] async fn apps_refresh_preserves_toggled_enabled_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index d4b19a97b..c9ec48a68 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -534,6 +534,7 @@ mod tests { }, }, )])), + apps: None, rules: None, enforce_residency: Some(ResidencyRequirement::Us), network: None, @@ -653,6 +654,7 @@ approval_policy = "never" allowed_web_search_modes: Some(Vec::new()), feature_requirements: None, mcp_servers: None, + apps: None, rules: None, enforce_residency: None, network: None,