diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 53864851ae0..8eac13fd2ef 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2050,6 +2050,7 @@ trust_level = "trusted" managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }; let cwd = AbsolutePathBuf::try_from(codex_home.path())?; @@ -2170,6 +2171,7 @@ trust_level = "trusted" managed_config_path: Some(managed_path), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }; let cwd = AbsolutePathBuf::try_from(codex_home.path())?; diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index bc6d96bcb84..211a12fa037 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -755,6 +755,7 @@ remote_compaction = true managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }, ); @@ -835,6 +836,7 @@ remote_compaction = true managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }, ); @@ -937,6 +939,7 @@ remote_compaction = true managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }, ); @@ -984,6 +987,7 @@ remote_compaction = true managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }, ); @@ -1029,6 +1033,7 @@ remote_compaction = true managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }, ); diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index d4312729680..84a29a6119c 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -33,11 +33,13 @@ pub(super) async fn load_config_layers_internal( let LoaderOverrides { managed_config_path, managed_preferences_base64, + .. } = overrides; #[cfg(not(target_os = "macos"))] let LoaderOverrides { managed_config_path, + .. } = overrides; let managed_config_path = AbsolutePathBuf::from_absolute_path( diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs index 4a80267b907..8d2289e9158 100644 --- a/codex-rs/core/src/config_loader/macos.rs +++ b/codex-rs/core/src/config_loader/macos.rs @@ -1,3 +1,4 @@ +use super::config_requirements::ConfigRequirementsToml; use base64::Engine; use base64::prelude::BASE64_STANDARD; use core_foundation::base::TCFType; @@ -10,6 +11,7 @@ use toml::Value as TomlValue; const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex"; const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64"; +const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64"; pub(crate) async fn load_managed_admin_config_layer( override_base64: Option<&str>, @@ -19,82 +21,126 @@ pub(crate) async fn load_managed_admin_config_layer( return if trimmed.is_empty() { Ok(None) } else { - parse_managed_preferences_base64(trimmed).map(Some) + parse_managed_config_base64(trimmed).map(Some) }; } - const LOAD_ERROR: &str = "Failed to load managed preferences configuration"; - match task::spawn_blocking(load_managed_admin_config).await { Ok(result) => result, Err(join_err) => { if join_err.is_cancelled() { - tracing::error!("Managed preferences load task was cancelled"); + tracing::error!("Managed config load task was cancelled"); } else { - tracing::error!("Managed preferences load task failed: {join_err}"); + tracing::error!("Managed config load task failed: {join_err}"); } - Err(io::Error::other(LOAD_ERROR)) + Err(io::Error::other("Failed to load managed config")) } } } fn load_managed_admin_config() -> io::Result> { + load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? + .as_deref() + .map(str::trim) + .map(parse_managed_config_base64) + .transpose() +} + +pub(crate) async fn load_managed_admin_requirements_toml( + target: &mut ConfigRequirementsToml, + override_base64: Option<&str>, +) -> io::Result<()> { + if let Some(encoded) = override_base64 { + let trimmed = encoded.trim(); + if !trimmed.is_empty() { + target.merge_unset_fields(parse_managed_requirements_base64(trimmed)?); + } + return Ok(()); + } + + match task::spawn_blocking(load_managed_admin_requirements).await { + Ok(result) => { + if let Some(requirements) = result? { + target.merge_unset_fields(requirements); + } + Ok(()) + } + Err(join_err) => { + if join_err.is_cancelled() { + tracing::error!("Managed requirements load task was cancelled"); + } else { + tracing::error!("Managed requirements load task failed: {join_err}"); + } + Err(io::Error::other("Failed to load managed requirements")) + } + } +} + +fn load_managed_admin_requirements() -> io::Result> { + load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? + .as_deref() + .map(str::trim) + .map(parse_managed_requirements_base64) + .transpose() +} + +fn load_managed_preference(key_name: &str) -> io::Result> { #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void; } - let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID); - let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY); - let value_ref = unsafe { CFPreferencesCopyAppValue( - key.as_concrete_TypeRef(), - application_id.as_concrete_TypeRef(), + CFString::new(key_name).as_concrete_TypeRef(), + CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(), ) }; if value_ref.is_null() { tracing::debug!( - "Managed preferences for {} key {} not found", - MANAGED_PREFERENCES_APPLICATION_ID, - MANAGED_PREFERENCES_CONFIG_KEY + "Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found", ); return Ok(None); } - let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }; - let contents = value.to_string(); - let trimmed = contents.trim(); - - parse_managed_preferences_base64(trimmed).map(Some) + let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string(); + Ok(Some(value)) } -fn parse_managed_preferences_base64(encoded: &str) -> io::Result { - let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| { - tracing::error!("Failed to decode managed preferences as base64: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - })?; - - let decoded_str = String::from_utf8(decoded).map_err(|err| { - tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - })?; - - match toml::from_str::(&decoded_str) { +fn parse_managed_config_base64(encoded: &str) -> io::Result { + match toml::from_str::(&decode_managed_preferences_base64(encoded)?) { Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)), Ok(other) => { - tracing::error!( - "Managed preferences TOML must have a table at the root, found {other:?}", - ); + tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); Err(io::Error::new( io::ErrorKind::InvalidData, - "managed preferences root must be a table", + "managed config root must be a table", )) } Err(err) => { - tracing::error!("Failed to parse managed preferences TOML: {err}"); + tracing::error!("Failed to parse managed config TOML: {err}"); Err(io::Error::new(io::ErrorKind::InvalidData, err)) } } } + +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( + |err| { + tracing::error!("Failed to parse managed requirements TOML: {err}"); + io::Error::new(io::ErrorKind::InvalidData, err) + }, + ) +} + +fn decode_managed_preferences_base64(encoded: &str) -> io::Result { + String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| { + tracing::error!("Failed to decode managed value as base64: {err}",); + io::Error::new(io::ErrorKind::InvalidData, err) + })?) + .map_err(|err| { + tracing::error!("Managed value base64 contents were not valid UTF-8: {err}",); + io::Error::new(io::ErrorKind::InvalidData, err) + }) +} diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 73624c83c7a..2dbba678acf 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -78,8 +78,14 @@ pub async fn load_config_layers_state( ) -> io::Result { let mut config_requirements_toml = ConfigRequirementsToml::default(); - // TODO(gt): Support an entry in MDM for config requirements and use it - // with `config_requirements_toml.merge_unset_fields(...)`, if present. + #[cfg(target_os = "macos")] + macos::load_managed_admin_requirements_toml( + &mut config_requirements_toml, + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; // Honor /etc/codex/requirements.toml. if cfg!(unix) { @@ -101,8 +107,6 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); - // TODO(gt): Honor managed preferences (macOS only). - // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = if cfg!(unix) { diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index efb33dfac5b..0ef14403d6d 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -12,11 +12,14 @@ use std::collections::HashMap; use std::path::PathBuf; use toml::Value as TomlValue; +/// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { pub managed_config_path: Option, + //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. #[cfg(target_os = "macos")] pub managed_preferences_base64: Option, + pub macos_managed_config_requirements_base64: Option, } #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index bb8898129c9..b80f00c71c9 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -9,6 +9,8 @@ use crate::config_loader::config_requirements::ConfigRequirementsToml; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; use codex_protocol::protocol::AskForApproval; +#[cfg(target_os = "macos")] +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -43,6 +45,7 @@ extra = true managed_config_path: Some(managed_path), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }; let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); @@ -73,10 +76,12 @@ extra = true async fn returns_empty_when_all_layers_missing() { let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); + let overrides = LoaderOverrides { managed_config_path: Some(managed_path), #[cfg(target_os = "macos")] managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, }; let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); @@ -141,12 +146,6 @@ async fn returns_empty_when_all_layers_missing() { async fn managed_preferences_take_highest_precedence() { use base64::Engine; - let managed_payload = r#" -[nested] -value = "managed" -flag = false -"#; - let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes()); let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); @@ -168,7 +167,17 @@ flag = true let overrides = LoaderOverrides { managed_config_path: Some(managed_path), - managed_preferences_base64: Some(encoded), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +[nested] +value = "managed" +flag = false +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, }; let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); @@ -192,6 +201,108 @@ flag = true assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false))); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { + use base64::Engine; + + let tmp = tempdir()?; + + let state = load_config_layers_state( + tmp.path(), + Some(AbsolutePathBuf::try_from(tmp.path())?), + &[] as &[(String, TomlValue)], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +allowed_approval_policies = ["never"] +allowed_sandbox_modes = ["read-only"] +"# + .as_bytes(), + ), + ), + }, + ) + .await?; + + assert_eq!( + state.requirements().approval_policy.value(), + AskForApproval::Never + ); + assert_eq!( + *state.requirements().sandbox_policy.get(), + SandboxPolicy::ReadOnly + ); + assert!( + state + .requirements() + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_err() + ); + assert!( + state + .requirements() + .sandbox_policy + .can_set(&SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + .is_err() + ); + + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> { + use base64::Engine; + + let tmp = tempdir()?; + let managed_path = tmp.path().join("managed_config.toml"); + + tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?; + + let state = load_config_layers_state( + tmp.path(), + Some(AbsolutePathBuf::try_from(tmp.path())?), + &[] as &[(String, TomlValue)], + LoaderOverrides { + managed_config_path: Some(managed_path), + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +allowed_approval_policies = ["never"] +"# + .as_bytes(), + ), + ), + }, + ) + .await?; + + assert_eq!( + state.requirements().approval_policy.value(), + AskForApproval::Never + ); + assert!( + state + .requirements() + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_err() + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Result<()> { let tmp = tempdir()?;