From 232618fa9ef3f8fa1d54759840f5fbbb3bdefef4 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 15 Jun 2026 23:02:15 +0000 Subject: [PATCH 01/10] Enforce exact managed config values --- codex-rs/config/src/config_requirements.rs | 152 ++++++++++++++---- .../config/src/requirements_layers/stack.rs | 15 ++ codex-rs/core/src/config/config_tests.rs | 80 +++++++++ codex-rs/core/src/config/mod.rs | 29 +++- codex-rs/core/src/config/requirements.rs | 125 ++++++++++++++ .../tui/src/chatwidget/tests/permissions.rs | 1 + codex-rs/tui/src/debug_config.rs | 2 + codex-rs/tui/src/lib.rs | 7 +- 8 files changed, 372 insertions(+), 39 deletions(-) create mode 100644 codex-rs/core/src/config/requirements.rs diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ca4407ce43cd..8fa184a87343 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -20,6 +20,7 @@ use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; +use crate::types::FeedbackConfigToml; use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,10 +143,17 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, pub windows_sandbox_mode: ConstrainedWithSource>, + pub windows_sandbox_private_desktop: Option>, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -168,6 +176,12 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { Self { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, approval_policy: ConstrainedWithSource::new( Constrained::allow_any_from_default(), /*source*/ None, @@ -184,6 +198,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(/*initial_value*/ None), /*source*/ None, ), + windows_sandbox_private_desktop: None, web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, @@ -716,11 +731,12 @@ impl ComputerUseRequirementsToml { #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct WindowsRequirementsToml { pub allowed_sandbox_implementations: Option>, + pub sandbox_private_desktop: Option, } impl WindowsRequirementsToml { pub fn is_empty(&self) -> bool { - self.allowed_sandbox_implementations.is_none() + self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none() } } @@ -821,6 +837,12 @@ pub(crate) fn merge_app_requirements_descending( /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, @@ -877,6 +899,12 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, @@ -919,6 +947,12 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, @@ -956,6 +990,12 @@ impl ConfigRequirementsWithSources { other, source, { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -990,6 +1030,12 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1013,6 +1059,12 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; ConfigRequirementsToml { + sqlite_home: sqlite_home.map(|sourced| sourced.value), + log_dir: log_dir.map(|sourced| sourced.value), + model_catalog_json: model_catalog_json.map(|sourced| sourced.value), + check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value), + allow_login_shell: allow_login_shell.map(|sourced| sourced.value), + feedback: feedback.map(|sourced| sourced.value), allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), @@ -1103,7 +1155,16 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.allowed_approval_policies.is_none() + self.sqlite_home.is_none() + && self.log_dir.is_none() + && self.model_catalog_json.is_none() + && self.check_for_update_on_startup.is_none() + && self.allow_login_shell.is_none() + && self + .feedback + .as_ref() + .is_none_or(|feedback| feedback == &FeedbackConfigToml::default()) + && self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_permission_profiles.is_none() @@ -1157,6 +1218,12 @@ impl TryFrom for ConfigRequirements { // config loading and requirements API projection. The normalized // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1277,42 +1344,60 @@ impl TryFrom for ConfigRequirements { /*source*/ None, ), }; - let windows_sandbox_mode = match windows { + let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows { Some(Sourced { value: WindowsRequirementsToml { - allowed_sandbox_implementations: Some(implementations), + allowed_sandbox_implementations, + sandbox_private_desktop, }, source: requirement_source, }) => { - if implementations.is_empty() { - return Err(ConstraintError::empty_field( - "windows.allowed_sandbox_implementations", - )); - } - // Prefer elevated when both Windows sandbox implementations are allowed. - let initial_value = if implementations.contains(&WindowsSandboxModeToml::Elevated) { - WindowsSandboxModeToml::Elevated - } else { - WindowsSandboxModeToml::Unelevated + let sandbox_private_desktop = sandbox_private_desktop + .map(|value| Sourced::new(value, requirement_source.clone())); + let sandbox_mode = match allowed_sandbox_implementations { + Some(implementations) => { + if implementations.is_empty() { + return Err(ConstraintError::empty_field( + "windows.allowed_sandbox_implementations", + )); + } + // Prefer elevated when both Windows sandbox implementations are allowed. + let initial_value = + if implementations.contains(&WindowsSandboxModeToml::Elevated) { + WindowsSandboxModeToml::Elevated + } else { + WindowsSandboxModeToml::Unelevated + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new( + Some(initial_value), + move |candidate| match candidate { + Some(candidate) if implementations.contains(candidate) => Ok(()), + _ => Err(ConstraintError::InvalidValue { + field_name: "windows.sandbox", + candidate: format!("{candidate:?}"), + allowed: format!("{implementations:?}"), + requirement_source: requirement_source_for_error.clone(), + }), + }, + )?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), }; - - let requirement_source_for_error = requirement_source.clone(); - let constrained = - Constrained::new(Some(initial_value), move |candidate| match candidate { - Some(candidate) if implementations.contains(candidate) => Ok(()), - _ => Err(ConstraintError::InvalidValue { - field_name: "windows.sandbox", - candidate: format!("{candidate:?}"), - allowed: format!("{implementations:?}"), - requirement_source: requirement_source_for_error.clone(), - }), - })?; - ConstrainedWithSource::new(constrained, Some(requirement_source)) + (sandbox_mode, sandbox_private_desktop) } - Some(_) | None => ConstrainedWithSource::new( - Constrained::allow_any(/*initial_value*/ None), - /*source*/ None, + None => ( + ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + None, ), }; let exec_policy = match rules { @@ -1437,10 +1522,17 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, approval_policy, approvals_reviewer, permission_profile, windows_sandbox_mode, + windows_sandbox_private_desktop, web_search_mode, allow_managed_hooks_only, allow_appshots, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index a7bcab279f95..b76762dbd71c 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -173,6 +173,12 @@ fn populate_merged_regular_fields_with_sources( // Destructure without `..` so every new requirements field must choose // whether it belongs in the regular TOML merge path or in a special merger. let ConfigRequirementsToml { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -197,6 +203,15 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + set_sourced!(sqlite_home, &["sqlite_home"]); + set_sourced!(log_dir, &["log_dir"]); + set_sourced!(model_catalog_json, &["model_catalog_json"]); + set_sourced!( + check_for_update_on_startup, + &["check_for_update_on_startup"] + ); + set_sourced!(allow_login_shell, &["allow_login_shell"]); + set_sourced!(feedback, &["feedback"]); set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]); set_sourced!( allowed_approvals_reviewers, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 38f04d23b63c..d431e00877e7 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8330,6 +8330,12 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, @@ -10848,3 +10854,77 @@ fn test_tui_notification_condition_rejects_unknown_value() { "unexpected error: {err}" ); } + +async fn load_with_enterprise_requirement( + codex_home: &TempDir, + requirements: impl Into, +) -> std::io::Result { + ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), + ) + .build() + .await +} + +#[tokio::test] +async fn exact_requirements_override_only_managed_leaves() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("required-models.json"); + let mut catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + catalog.models = catalog.models.into_iter().take(1).collect(); + std::fs::write( + &catalog_path, + serde_json::to_string(&catalog).expect("serialize catalog"), + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +check_for_update_on_startup = true +allow_login_shell = true + +[feedback] +enabled = true + +[windows] +sandbox_private_desktop = true +"#, + )?; + + let required_sqlite_home = codex_home.path().join("required-state"); + let required_log_dir = codex_home.path().join("required-logs"); + let requirements = format!( + r#" +sqlite_home = {:?} +log_dir = {:?} +model_catalog_json = {:?} +check_for_update_on_startup = false +allow_login_shell = false + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + required_sqlite_home.display(), + required_log_dir.display(), + catalog_path.display(), + ); + let config = load_with_enterprise_requirement(&codex_home, requirements).await?; + + assert_eq!(config.sqlite_home, required_sqlite_home); + assert_eq!(config.log_dir, required_log_dir); + assert_eq!(config.model_catalog, Some(catalog)); + assert!(!config.check_for_update_on_startup); + assert!(!config.permissions.allow_login_shell); + assert!(!config.feedback_enabled); + assert!(!config.permissions.windows_sandbox_private_desktop); + assert!(config.startup_warnings.iter().any(|warning| { + warning.contains("Configured value for `check_for_update_on_startup` is overridden") + })); + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1dd4117e6545..8ba4437851dd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -145,6 +145,7 @@ mod managed_features; mod network_proxy_spec; mod otel; mod permissions; +mod requirements; mod resolved_permission_profile; #[cfg(test)] mod schema; @@ -2642,7 +2643,7 @@ impl Config { pub(crate) async fn load_config_with_layer_stack( fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, + mut cfg: ConfigToml, overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, @@ -2658,13 +2659,30 @@ impl Config { validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - // Ensure that every field of ConfigRequirements is applied to the final - // Config. + let mut startup_warnings = config_layer_stack + .startup_warnings() + .unwrap_or_default() + .to_vec(); + requirements::apply_to_config( + &mut cfg, + config_layer_stack.requirements(), + &mut startup_warnings, + )?; + + // Destructure every field to ensure ConfigRequirements additions are + // either applied above or handled while constructing the final Config. let ConfigRequirements { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, approval_policy: mut constrained_approval_policy, approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, windows_sandbox_mode: mut constrained_windows_sandbox_mode, + windows_sandbox_private_desktop: _, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, allow_appshots: _, @@ -2681,11 +2699,6 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let mut startup_warnings = config_layer_stack - .startup_warnings() - .unwrap_or_default() - .to_vec(); - // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { model, diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs new file mode 100644 index 000000000000..21eb64b5a435 --- /dev/null +++ b/codex-rs/core/src/config/requirements.rs @@ -0,0 +1,125 @@ +use codex_config::ConfigRequirements; +use codex_config::RequirementSource; +use codex_config::Sourced; +use codex_config::config_toml::ConfigToml; +use codex_config::types::FeedbackConfigToml; + +/// Applies managed requirements to regular config before final config construction. +/// +/// Managed values replace their configured counterparts, and conflicts produce +/// source-aware startup warnings. +pub(super) fn apply_to_config( + config: &mut ConfigToml, + requirements: &ConfigRequirements, + startup_warnings: &mut Vec, +) -> std::io::Result<()> { + macro_rules! apply_exact { + ($field:ident) => { + apply_exact_requirement( + stringify!($field), + &mut config.$field, + requirements.$field.as_ref(), + startup_warnings, + ); + }; + } + + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + apply_feedback_requirement( + &mut config.feedback, + requirements.feedback.as_ref(), + startup_warnings, + ); + if let Some(requirement) = requirements.windows_sandbox_private_desktop.as_ref() { + apply_exact_requirement( + "windows.sandbox_private_desktop", + &mut config + .windows + .get_or_insert_default() + .sandbox_private_desktop, + Some(requirement), + startup_warnings, + ); + } + + Ok(()) +} + +fn apply_exact_requirement( + field_name: &'static str, + configured_value: &mut Option, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) where + T: Clone + PartialEq + std::fmt::Debug, +{ + let Some(Sourced { value, source }) = requirement else { + return; + }; + if configured_value + .as_ref() + .is_some_and(|configured| configured != value) + { + tracing::warn!( + ?source, + ?value, + "configured value is overridden by an exact requirement for {field_name}" + ); + startup_warnings.push(format!( + "Configured value for `{field_name}` is overridden by the required value {value:?} from {source}." + )); + } + *configured_value = Some(value.clone()); +} + +pub(super) fn replace_required_leaf( + configured: &mut Option, + required: &Option, +) -> bool { + let Some(required) = required else { + return false; + }; + let conflict = configured + .as_ref() + .is_some_and(|configured| configured != required); + *configured = Some(required.clone()); + conflict +} + +fn apply_feedback_requirement( + configured: &mut Option, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) { + let Some(Sourced { value, source }) = requirement else { + return; + }; + let FeedbackConfigToml { enabled } = value; + let configured = configured.get_or_insert_default(); + let conflict = replace_required_leaf(&mut configured.enabled, enabled); + push_structured_requirement_override_warning("feedback", conflict, source, startup_warnings); +} + +/// Emits one source-aware warning when a structured requirement replaces one +/// or more configured values. +pub(super) fn push_structured_requirement_override_warning( + field_name: &str, + conflict: bool, + source: &RequirementSource, + startup_warnings: &mut Vec, +) { + if !conflict { + return; + } + tracing::warn!( + ?source, + "configured values are overridden by requirements for {field_name}" + ); + startup_warnings.push(format!( + "Configured values under `{field_name}` are overridden by requirements from {source}." + )); +} diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe114..bd407379862f 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -54,6 +54,7 @@ fn windows_sandbox_requirements_stack( let requirements_toml = codex_config::ConfigRequirementsToml { windows: Some(codex_config::WindowsRequirementsToml { allowed_sandbox_implementations: Some(allowed_sandbox_implementations), + sandbox_private_desktop: None, }), ..Default::default() }; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index faacca458a27..8cd32fa010d8 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -790,6 +790,7 @@ mod tests { enforce_residency: Some(ResidencyRequirement::Us), network: None, permissions: None, + ..ConfigRequirementsToml::default() }; let user_file = if cfg!(windows) { @@ -1147,6 +1148,7 @@ approval_policy = "never" enforce_residency: None, network: None, permissions: None, + ..ConfigRequirementsToml::default() }; let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3ec8bef7c08b..f092a92fd7ab 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1124,7 +1124,12 @@ pub async fn run_main( .config_layer_stack .effective_config() .as_table() - .is_some_and(|table| table.contains_key("log_dir")); + .is_some_and(|table| table.contains_key("log_dir")) + || config + .config_layer_stack + .requirements_toml() + .log_dir + .is_some(); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { From 705707ae0cc200d531d99e9c599130654a516c06 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 15 Jun 2026 23:09:08 +0000 Subject: [PATCH 02/10] Update requirements fixtures for managed fields --- codex-rs/config/src/config_requirements.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8fa184a87343..bf761bc8444a 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1619,6 +1619,12 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1643,6 +1649,15 @@ mod tests { guardian_policy_config, } = toml; ConfigRequirementsWithSources { + sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)), + log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)), + model_catalog_json: model_catalog_json + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + check_for_update_on_startup: check_for_update_on_startup + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_login_shell: allow_login_shell + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_approval_policies: allowed_approval_policies .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_approvals_reviewers: allowed_approvals_reviewers @@ -1852,8 +1867,6 @@ mod tests { let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); - // Intentionally constructed without `..Default::default()` so adding a new field to - // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), @@ -1877,6 +1890,7 @@ mod tests { network: None, permissions: None, guardian_policy_config: Some(guardian_policy_config.clone()), + ..Default::default() }; target.merge_unset_fields(source.clone(), other); @@ -1926,6 +1940,7 @@ mod tests { network: None, permissions: None, guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), + ..Default::default() } ); } @@ -1972,6 +1987,7 @@ mod tests { network: None, permissions: None, guardian_policy_config: None, + ..Default::default() } ); Ok(()) @@ -2026,6 +2042,7 @@ mod tests { network: None, permissions: None, guardian_policy_config: None, + ..Default::default() } ); Ok(()) From 6427c0c6b7b8c1521fccaafb3d4b5501ea33968d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 15 Jun 2026 23:58:56 +0000 Subject: [PATCH 03/10] Fix Windows requirements test fixture --- codex-rs/app-server/src/request_processors/config_processor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 83701810752c..2a0e09ef58c8 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -654,6 +654,7 @@ mod tests { codex_config::types::WindowsSandboxModeToml::Elevated, codex_config::types::WindowsSandboxModeToml::Unelevated, ]), + sandbox_private_desktop: None, }), ..ConfigRequirementsToml::default() }); From a3edbe96be578ca97bd26127372fef7b72e7e84d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 16 Jun 2026 06:15:42 +0000 Subject: [PATCH 04/10] Honor managed login shell restrictions consistently --- codex-rs/config/src/config_toml.rs | 5 ---- codex-rs/core/config.schema.json | 1 - codex-rs/core/src/config/config_tests.rs | 15 ++++++++++++ codex-rs/core/src/session/tests.rs | 30 ++++++++++++++++++++++++ codex-rs/core/src/tasks/user_shell.rs | 2 +- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 7270c3f32996..9d04ec220c70 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -67,10 +67,6 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; -const fn default_allow_login_shell() -> Option { - Some(true) -} - fn default_history() -> Option { Some(History::default()) } @@ -178,7 +174,6 @@ pub struct ConfigToml { /// If `false`, the model can never use a login shell: `login = true` /// requests are rejected, and omitting `login` defaults to a non-login /// shell. - #[serde(default = "default_allow_login_shell")] pub allow_login_shell: Option, /// Sandbox mode to use. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 22034964a05c..9554cdcbac18 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4407,7 +4407,6 @@ "description": "Agent-related settings (thread limits, etc.)." }, "allow_login_shell": { - "default": true, "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d431e00877e7..e0e29094eb60 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10928,3 +10928,18 @@ sandbox_private_desktop = false })); Ok(()) } + +#[tokio::test] +async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = load_with_enterprise_requirement(&codex_home, "allow_login_shell = false").await?; + + assert!(!config.permissions.allow_login_shell); + assert!( + config + .startup_warnings + .iter() + .all(|warning| !warning.contains("allow_login_shell")) + ); + Ok(()) +} diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5086fd63bd57..bded60222338 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1150,6 +1150,36 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R Ok(()) } +#[tokio::test] +async fn user_shell_commands_honor_disabled_login_shells() -> anyhow::Result<()> { + let (session, rx) = make_session_with_config_and_rx(|config| { + config.permissions.allow_login_shell = false; + }) + .await?; + let turn_context = session.new_default_turn().await; + let command = "echo managed-login-shell".to_string(); + let expected_command = session.user_shell().derive_exec_args(&command, false); + + execute_user_shell_command( + Arc::clone(&session), + turn_context, + command, + CancellationToken::new(), + UserShellCommandMode::StandaloneTurn, + ) + .await; + + loop { + let event = rx.recv().await.expect("channel open"); + if let EventMsg::ExecCommandBegin(event) = event.msg { + assert_eq!(event.command, expected_command); + break; + } + } + + Ok(()) +} + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6a01a87a0e06..5ddb8781d8ce 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -142,7 +142,7 @@ pub(crate) async fn execute_user_shell_command( // Execute the user's script under the environment's shell; this // allows commands that use shell features (pipes, &&, redirects, etc.). // We do not source rc files or otherwise reformat the script. - let use_login_shell = true; + let use_login_shell = turn_context.config.permissions.allow_login_shell; let display_command = environment_shell.derive_exec_args(&command, use_login_shell); let shell_snapshot_location = turn_environment.shell_snapshot(turn_environment.cwd()); let mut exec_env_map = create_env( From 85a7b9d64625e66543ec192082327fe8e16c86ad Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 16 Jun 2026 07:39:42 +0000 Subject: [PATCH 05/10] Label managed shell test argument --- codex-rs/core/src/session/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bded60222338..c2e252cc9c3c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1158,7 +1158,9 @@ async fn user_shell_commands_honor_disabled_login_shells() -> anyhow::Result<()> .await?; let turn_context = session.new_default_turn().await; let command = "echo managed-login-shell".to_string(); - let expected_command = session.user_shell().derive_exec_args(&command, false); + let expected_command = session + .user_shell() + .derive_exec_args(&command, /*use_login_shell*/ false); execute_user_shell_command( Arc::clone(&session), From 79dd7b63ee7e58e350b9e5a2f3aacfa624279695 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 17 Jun 2026 00:49:49 +0000 Subject: [PATCH 06/10] Restore exhaustive requirements merge test --- codex-rs/config/src/config_requirements.rs | 37 +++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index bf761bc8444a..9871df9c4c5e 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1863,11 +1863,33 @@ mod tests { let computer_use = ComputerUseRequirementsToml { allow_locked_computer_use: Some(false), }; + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state")) + .expect("managed sqlite home should be absolute"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs")) + .expect("managed log dir should be absolute"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) + .expect("managed model catalog path should be absolute"); + let feedback = FeedbackConfigToml { + enabled: Some(false), + }; + let windows = WindowsRequirementsToml { + allowed_sandbox_implementations: None, + sandbox_private_desktop: Some(true), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); + // Intentionally constructed without `..Default::default()` so adding a new field to + // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { + sqlite_home: Some(sqlite_home.clone()), + log_dir: Some(log_dir.clone()), + model_catalog_json: Some(model_catalog_json.clone()), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(feedback.clone()), allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), @@ -1879,7 +1901,7 @@ mod tests { allow_appshots: Some(false), allow_remote_control: Some(false), computer_use: Some(computer_use.clone()), - windows: None, + windows: Some(windows.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1890,7 +1912,6 @@ mod tests { network: None, permissions: None, guardian_policy_config: Some(guardian_policy_config.clone()), - ..Default::default() }; target.merge_unset_fields(source.clone(), other); @@ -1898,6 +1919,15 @@ mod tests { assert_eq!( target, ConfigRequirementsWithSources { + sqlite_home: Some(Sourced::new(sqlite_home, source.clone())), + log_dir: Some(Sourced::new(log_dir, source.clone())), + model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())), + check_for_update_on_startup: Some(Sourced::new( + /*value*/ false, + source.clone(), + )), + allow_login_shell: Some(Sourced::new(/*value*/ false, source.clone())), + feedback: Some(Sourced::new(feedback, source.clone())), allowed_approval_policies: Some(Sourced::new( allowed_approval_policies, source.clone() @@ -1926,7 +1956,7 @@ mod tests { enforce_source.clone(), )), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), - windows: None, + windows: Some(Sourced::new(windows, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1940,7 +1970,6 @@ mod tests { network: None, permissions: None, guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), - ..Default::default() } ); } From e7efba1fc3542abd9ba309aa6ddc297f88e79905 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 17 Jun 2026 02:36:52 +0000 Subject: [PATCH 07/10] Expose exact requirements through app server --- .../codex_app_server_protocol.schemas.json | 69 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 69 ++++++++++++++++++ .../v2/ConfigRequirementsReadResponse.json | 73 +++++++++++++++++++ .../typescript/v2/ConfigRequirements.ts | 4 +- .../typescript/v2/FeedbackRequirements.ts | 5 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/config.rs | 14 ++++ .../src/protocol/v2/tests.rs | 7 ++ .../request_processors/config_processor.rs | 55 +++++++++++++- 9 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 7e431cea8c25..b1a2231512c0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8160,6 +8160,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -8217,6 +8223,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -8251,6 +8263,52 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/v2/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -9249,6 +9307,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2733bc271979..f92aa553769c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4473,6 +4473,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -4530,6 +4536,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -4564,6 +4576,52 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -5562,6 +5620,17 @@ ], "type": "string" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "FeedbackUploadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e57bd48b796f..c29d432b3d5f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -79,6 +83,12 @@ "null" ] }, + "allowLoginShell": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -136,6 +146,12 @@ "null" ] }, + "checkForUpdateOnStartup": { + "type": [ + "boolean", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -170,6 +186,52 @@ "object", "null" ] + }, + "feedback": { + "anyOf": [ + { + "$ref": "#/definitions/FeedbackRequirements" + }, + { + "type": "null" + } + ] + }, + "logDir": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "modelCatalogJson": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "sqliteHome": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "windowsSandboxPrivateDesktop": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" @@ -274,6 +336,17 @@ ], "type": "object" }, + "FeedbackRequirements": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 3eb9a65aa65d..20b89107bbc6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,11 +1,13 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; +import type { FeedbackRequirements } from "./FeedbackRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, sqliteHome: AbsolutePathBuf | null, logDir: AbsolutePathBuf | null, modelCatalogJson: AbsolutePathBuf | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts new file mode 100644 index 000000000000..8d0a2002ee88 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackRequirements.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FeedbackRequirements = { enabled: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 5aa66a56b5f4..1cc18a2465d0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -117,6 +117,7 @@ export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImp export type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult"; export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +export type { FeedbackRequirements } from "./FeedbackRequirements"; export type { FeedbackUploadParams } from "./FeedbackUploadParams"; export type { FeedbackUploadResponse } from "./FeedbackUploadResponse"; export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index c4d113d1c75b..081b4fb4ab98 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -389,6 +389,20 @@ pub struct ConfigRequirements { pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, + pub windows_sandbox_private_desktop: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackRequirements { + pub enabled: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2567e386c839..25a501c95077 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1719,6 +1719,13 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() hooks: None, enforce_residency: None, network: None, + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, + windows_sandbox_private_desktop: None, }); assert_eq!(reason, Some("askForApproval.granular")); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 2a0e09ef58c8..fd4b142b70be 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfiguredHookHandler; use codex_app_server_protocol::ConfiguredHookMatcherGroup; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::FeedbackRequirements; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ManagedHooksRequirements; use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; @@ -314,6 +315,11 @@ impl ConfigRequestProcessor { } fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { + let windows_sandbox_private_desktop = requirements + .windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop); + ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { policies @@ -376,6 +382,15 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .enforce_residency .map(map_residency_requirement_to_api), network: requirements.network.map(map_network_requirements_to_api), + sqlite_home: requirements.sqlite_home, + log_dir: requirements.log_dir, + model_catalog_json: requirements.model_catalog_json, + check_for_update_on_startup: requirements.check_for_update_on_startup, + allow_login_shell: requirements.allow_login_shell, + feedback: requirements.feedback.map(|feedback| FeedbackRequirements { + enabled: feedback.enabled, + }), + windows_sandbox_private_desktop, } } @@ -566,10 +581,13 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::map_requirements_toml_to_api; + use codex_app_server_protocol::FeedbackRequirements; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; use codex_config::WindowsRequirementsToml; + use codex_config::types::FeedbackConfigToml; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -654,7 +672,7 @@ mod tests { codex_config::types::WindowsSandboxModeToml::Elevated, codex_config::types::WindowsSandboxModeToml::Unelevated, ]), - sandbox_private_desktop: None, + sandbox_private_desktop: Some(false), }), ..ConfigRequirementsToml::default() }); @@ -666,5 +684,40 @@ mod tests { WindowsSandboxSetupMode::Unelevated, ]) ); + assert_eq!(mapped.windows_sandbox_private_desktop, Some(false)); + } + + #[test] + fn requirements_api_includes_exact_managed_values() { + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state")) + .expect("managed sqlite home should be absolute"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs")) + .expect("managed log dir should be absolute"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) + .expect("managed model catalog path should be absolute"); + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + sqlite_home: Some(sqlite_home.clone()), + log_dir: Some(log_dir.clone()), + model_catalog_json: Some(model_catalog_json.clone()), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(FeedbackConfigToml { + enabled: Some(false), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.sqlite_home, Some(sqlite_home)); + assert_eq!(mapped.log_dir, Some(log_dir)); + assert_eq!(mapped.model_catalog_json, Some(model_catalog_json)); + assert_eq!(mapped.check_for_update_on_startup, Some(false)); + assert_eq!(mapped.allow_login_shell, Some(false)); + assert_eq!( + mapped.feedback, + Some(FeedbackRequirements { + enabled: Some(false), + }) + ); } } From 21d891f7b8af3aa66488823df2c03f1cb5dab36e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 17 Jun 2026 03:29:19 +0000 Subject: [PATCH 08/10] Preserve requirement path layer bases --- .../config/src/requirements_layers/layer.rs | 25 ++++++++++ .../src/requirements_layers/stack_tests.rs | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index d92024e11708..7f21db00a27d 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -71,6 +71,7 @@ impl ComposableRequirementsLayer { }; requirements.apply_remote_sandbox_config(hostname); + materialize_resolved_path_requirements(&mut regular_toml, &requirements)?; materialize_remote_sandbox_config(&mut regular_toml, &requirements)?; strip_special_fields(&mut regular_toml); @@ -134,6 +135,30 @@ fn parse_layer_requirements( } } +fn materialize_resolved_path_requirements( + layer_toml: &mut TomlValue, + requirements: &ConfigRequirementsToml, +) -> Result<(), RequirementsCompositionError> { + let Some(table) = layer_toml.as_table_mut() else { + return Ok(()); + }; + + for (key, value) in [ + ("sqlite_home", requirements.sqlite_home.as_ref()), + ("log_dir", requirements.log_dir.as_ref()), + ( + "model_catalog_json", + requirements.model_catalog_json.as_ref(), + ), + ] { + if let Some(value) = value { + table.insert(key.to_string(), toml_value_from_serializable(value)?); + } + } + + Ok(()) +} + fn materialize_remote_sandbox_config( layer_toml: &mut TomlValue, requirements: &ConfigRequirementsToml, diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 7be51e2b33cc..f085fdaa6d78 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use tempfile::tempdir; fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry { RequirementsLayerEntry::from_toml( @@ -106,6 +107,51 @@ allow_remote_control = false ); } +#[test] +fn relative_paths_resolve_against_their_own_layer_base() { + let low_dir = tempdir().expect("low-priority requirements directory"); + let high_dir = tempdir().expect("high-priority requirements directory"); + let low_base = AbsolutePathBuf::from_absolute_path(low_dir.path()).expect("absolute low base"); + let high_base = + AbsolutePathBuf::from_absolute_path(high_dir.path()).expect("absolute high base"); + + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +sqlite_home = "state" +log_dir = "low-logs" +"#, + ) + .with_base_dir(low_base), + layer( + "req_high", + "High", + r#" +log_dir = "high-logs" +model_catalog_json = "models.json" +"#, + ) + .with_base_dir(high_base), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.sqlite_home.as_deref(), + Some(low_dir.path().join("state").as_path()) + ); + assert_eq!( + composed.log_dir.as_deref(), + Some(high_dir.path().join("high-logs").as_path()) + ); + assert_eq!( + composed.model_catalog_json.as_deref(), + Some(high_dir.path().join("models.json").as_path()) + ); +} + #[test] fn composition_strategy_applies_to_non_cloud_layers() { let mdm_source = RequirementSource::MdmManagedPreferences { From 498a1b3c288623c8586486daa07b23d827e87c44 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 17 Jun 2026 03:57:18 +0000 Subject: [PATCH 09/10] Reflect exact requirements in config APIs and locks --- .../src/protocol/v2/config.rs | 1 + codex-rs/app-server/README.md | 6 +- .../app-server/src/config_manager_service.rs | 14 ++- .../src/config_manager_service_tests.rs | 101 ++++++++++++++++++ codex-rs/config/src/config_requirements.rs | 87 +++++++++++++++ codex-rs/core/src/session/config_lock.rs | 57 ++++++++++ 6 files changed, 262 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 081b4fb4ab98..3e4c3e702081 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -335,6 +335,7 @@ pub struct ConfigWriteResponse { #[ts(export_to = "v2/")] pub enum ConfigWriteErrorCode { ConfigLayerReadonly, + ConfigRequirementReadonly, ConfigVersionConflict, ConfigValidationError, ConfigPathNotFound, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9188877384c1..650bc8039672 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -230,12 +230,12 @@ Example with notification opt-out: - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. +- `config/read` — fetch the runtime-effective config after resolving config layering and managed requirements, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Returns an `importId` used to correlate the completion notification. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes with type-level `itemResults` containing each migrated type's success count, error count, successes, and raw errors (immediately after the response when everything completed synchronously, or after background imports finish). -- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. +- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 4b42c28aa904..700274640829 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -125,9 +125,12 @@ impl ConfigManager { }; let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective + let mut effective_config_toml: ConfigToml = effective .try_into() .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; + layers + .requirements_toml() + .apply_exact_to_config(&mut effective_config_toml); let json_value = serde_json::to_value(&effective_config_toml) .map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?; @@ -237,6 +240,15 @@ impl ConfigManager { let segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; + if let Some(field) = layers + .requirements_toml() + .exact_requirement_for_config_path(&segments) + { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigRequirementReadonly, + format!("`{field}` is managed by requirements and cannot be changed"), + )); + } if !value.is_null() { match segments.as_slice() { [segment] if segment == "profile" => { diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 8deae8b0658c..0eeb16875044 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -742,6 +742,107 @@ personality = true ); } +#[tokio::test] +async fn write_value_rejects_exact_managed_requirement() { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "allow_login_shell = true\n").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement("allow_login_shell = false"), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "allow_login_shell".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("managed exact field should be read-only"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigRequirementReadonly) + ); + assert!(error.to_string().contains("`allow_login_shell`")); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "allow_login_shell = true\n" + ); +} + +#[tokio::test] +async fn read_reflects_exact_managed_requirement() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "allow_login_shell = true\n", + ) + .unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement("allow_login_shell = false"), + ); + + let response = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read should succeed"); + + assert_eq!( + response.config.additional.get("allow_login_shell"), + Some(&serde_json::json!(false)) + ); +} + +#[tokio::test] +async fn write_value_allows_unmanaged_sibling_of_exact_requirement() { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "").unwrap(); + + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[windows] +sandbox_private_desktop = false +"#, + ), + ); + + service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "windows.sandbox".to_string(), + value: serde_json::json!("elevated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("unmanaged sibling should remain writable"); + + assert!( + std::fs::read_to_string(path) + .unwrap() + .contains("sandbox = \"elevated\"") + ); +} + #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9871df9c4c5e..9776e1484f5b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -18,6 +18,7 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; +use crate::config_toml::ConfigToml; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; use crate::types::FeedbackConfigToml; @@ -1208,6 +1209,92 @@ impl ConfigRequirementsToml { .as_deref() .is_none_or(|value| value.trim().is_empty()) } + + /// Applies the requirements whose values replace config values. + /// + /// This projection is shared by config/read and config-lock export so + /// both surfaces describe the same behavior as the final runtime config. + pub fn apply_exact_to_config(&self, config: &mut ConfigToml) { + macro_rules! apply_exact { + ($field:ident) => { + if let Some(value) = self.$field.as_ref() { + config.$field = Some(value.clone()); + } + }; + } + + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + + if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) { + config.feedback.get_or_insert_default().enabled = Some(enabled); + } + if let Some(sandbox_private_desktop) = self + .windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + { + config + .windows + .get_or_insert_default() + .sandbox_private_desktop = Some(sandbox_private_desktop); + } + } + + /// Returns the exact managed field affected by editing `segments`. + pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> { + let managed_fields: [(bool, &[&str], &'static str); 7] = [ + (self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"), + (self.log_dir.is_some(), &["log_dir"], "log_dir"), + ( + self.model_catalog_json.is_some(), + &["model_catalog_json"], + "model_catalog_json", + ), + ( + self.check_for_update_on_startup.is_some(), + &["check_for_update_on_startup"], + "check_for_update_on_startup", + ), + ( + self.allow_login_shell.is_some(), + &["allow_login_shell"], + "allow_login_shell", + ), + ( + self.feedback + .as_ref() + .and_then(|feedback| feedback.enabled) + .is_some(), + &["feedback", "enabled"], + "feedback.enabled", + ), + ( + self.windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + .is_some(), + &["windows", "sandbox_private_desktop"], + "windows.sandbox_private_desktop", + ), + ]; + + managed_fields + .into_iter() + .find_map(|(is_managed, managed_path, field)| { + (is_managed && config_paths_overlap(segments, managed_path)).then_some(field) + }) + } +} + +fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool { + segments + .iter() + .zip(managed_path) + .all(|(segment, managed_segment)| segment == managed_segment) } impl TryFrom for ConfigRequirements { diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index ec2056581c31..ecc60e694390 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -88,6 +88,10 @@ fn session_configuration_to_lock_config_toml( .effective_config() .try_into() .context("failed to deserialize effective config for config lock")?; + config + .config_layer_stack + .requirements_toml() + .apply_exact_to_config(&mut lock_config); if config.config_lock_save_fields_resolved_from_model_catalog { save_session_resolved_fields(sc, &mut lock_config); @@ -203,6 +207,7 @@ where #[cfg(test)] mod tests { use super::*; + use codex_config::test_support::CloudConfigBundleFixture; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -302,6 +307,58 @@ mod tests { assert_eq!(lock.approvals_reviewer, None); } + #[tokio::test] + async fn lock_contains_exact_managed_requirements() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let sqlite_home = codex_home.path().join("managed-state"); + let log_dir = codex_home.path().join("managed-logs"); + let requirements = format!( + r#" +sqlite_home = {:?} +log_dir = {:?} +check_for_update_on_startup = false +allow_login_shell = false + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + sqlite_home.display(), + log_dir.display(), + ); + let config = crate::config::ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), + ) + .build() + .await + .expect("config should load"); + let mut sc = crate::session::tests::make_session_configuration_for_tests().await; + sc.original_config_do_not_use = Arc::new(config); + + let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); + let lock = &lockfile.config; + + assert_eq!(lock.sqlite_home.as_deref(), Some(sqlite_home.as_path())); + assert_eq!(lock.log_dir.as_deref(), Some(log_dir.as_path())); + assert_eq!(lock.check_for_update_on_startup, Some(false)); + assert_eq!(lock.allow_login_shell, Some(false)); + assert_eq!( + lock.feedback.as_ref().and_then(|feedback| feedback.enabled), + Some(false) + ); + assert_eq!( + lock.windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop), + Some(false) + ); + } + #[tokio::test] async fn lock_validation_reports_config_diff() { let sc = crate::session::tests::make_session_configuration_for_tests().await; From 0c6d72adf781d03dc82f813d1273845d500560dc Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 17 Jun 2026 05:04:16 +0000 Subject: [PATCH 10/10] Preserve login shell default in config reads --- .../app-server/src/config_manager_service.rs | 1 + .../src/config_manager_service_tests.rs | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 700274640829..c25ec55d29c4 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -131,6 +131,7 @@ impl ConfigManager { layers .requirements_toml() .apply_exact_to_config(&mut effective_config_toml); + effective_config_toml.allow_login_shell.get_or_insert(true); let json_value = serde_json::to_value(&effective_config_toml) .map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?; diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 0eeb16875044..43dae5e67397 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -807,6 +807,26 @@ async fn read_reflects_exact_managed_requirement() { ); } +#[tokio::test] +async fn read_materializes_default_allow_login_shell() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let response = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read should succeed"); + + assert_eq!( + response.config.additional.get("allow_login_shell"), + Some(&serde_json::json!(true)) + ); +} + #[tokio::test] async fn write_value_allows_unmanaged_sibling_of_exact_requirement() { let tmp = tempdir().expect("tempdir");