From e2b6c3a87f0af05ff75c6098cc7aaffa0da44cb3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Feb 2026 21:20:56 -0800 Subject: [PATCH] feat: add support for allowed_web_search_modes in requirements.toml Example: ```toml # This means that "live" is not allowed; "disabled" is allowed even though not listed explicitly. allowed_web_search_modes = ["cached"] ``` ### Why - Support admin/MDM/requirements.toml constraints over web-search behavior, independent of user config or sandbox defaults. - Ensure per-turn config resolution and review-mode overrides can never crash when constraints are present. - Note that `allowed_web_search_modes = ["cached"]` denies `"live"`, even if `--yolo` is used. See `resolve_web_search_mode_for_turn()`. ### What - Add `allowed_web_search_modes` to requirements parsing and app-server v2 `ConfigRequirements` (`allowedWebSearchModes`), with schema/TS fixture updates. - Introduce TOML-only `WebSearchModeRequirement` for requirements allowlists; accept `disabled|cached|live`, and treat an empty list as `[disabled]`. - Convert the allowlist to a `ConstrainedWithSource>`, always permitting `Disabled` while enforcing membership for other values. - Make `Config.web_search_mode` a constrained field, update call sites/tests, and surface constraint violations via warnings/fallback. - Extend TUI `/debug-config` output to display `allowed_web_search_modes`. ### Safety - Avoid `expect()` on constrained `web_search_mode` mutation in session per-turn config and review-thread setup; warn and keep the constrained value instead. --- .../codex_app_server_protocol.schemas.json | 9 + .../v2/ConfigRequirementsReadResponse.json | 17 ++ .../typescript/v2/ConfigRequirements.ts | 3 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/config_api.rs | 37 ++++ codex-rs/cloud-requirements/src/lib.rs | 3 + codex-rs/core/src/codex.rs | 30 ++- codex-rs/core/src/config/mod.rs | 199 +++++++++++++++-- .../src/config_loader/config_requirements.rs | 205 ++++++++++++++++++ codex-rs/core/src/config_loader/mod.rs | 1 + codex-rs/core/src/config_loader/tests.rs | 31 +++ codex-rs/core/src/tasks/review.rs | 16 +- codex-rs/core/tests/suite/model_tools.rs | 5 +- codex-rs/core/tests/suite/prompt_caching.rs | 5 +- codex-rs/core/tests/suite/web_search.rs | 26 ++- codex-rs/tui/src/debug_config.rs | 68 ++++++ 17 files changed, 618 insertions(+), 40 deletions(-) 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 969d914b18c..ff31b0d5e09 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 @@ -10988,6 +10988,15 @@ "null" ] }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/v2/WebSearchMode" + }, + "type": [ + "array", + "null" + ] + }, "enforceResidency": { "anyOf": [ { 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 9e77238b75b..d6ddd651722 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -30,6 +30,15 @@ "null" ] }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/WebSearchMode" + }, + "type": [ + "array", + "null" + ] + }, "enforceResidency": { "anyOf": [ { @@ -56,6 +65,14 @@ "danger-full-access" ], "type": "string" + }, + "WebSearchMode": { + "enum": [ + "disabled", + "cached", + "live" + ], + "type": "string" } }, "properties": { 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 765d0b86cf1..89cecfd189d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,8 +1,9 @@ // 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 { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = { allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, enforceResidency: ResidencyRequirement | null, }; +export type ConfigRequirements = { allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, enforceResidency: ResidencyRequirement | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 630e0395786..c3fd335941e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -532,6 +532,7 @@ pub struct ConfigReadResponse { pub struct ConfigRequirements { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, pub enforce_residency: Option, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a6d49f2e4cd..6065e404f9c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -116,7 +116,7 @@ Example (from OpenAI's official VSCode extension): - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. -- `configRequirements/read` — fetch the loaded requirements allow-lists and `enforceResidency` from `requirements.toml` and/or MDM (or `null` if none are configured). +- `configRequirements/read` — fetch the loaded requirements allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`) and `enforceResidency` from `requirements.toml` and/or MDM (or `null` if none are configured). ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index e1f27be0b53..14c4e441733 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -17,6 +17,7 @@ use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; +use codex_protocol::config_types::WebSearchMode; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; @@ -115,6 +116,16 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .filter_map(map_sandbox_mode_requirement_to_api) .collect() }), + allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { + let mut normalized = modes + .into_iter() + .map(Into::into) + .collect::>(); + if !normalized.contains(&WebSearchMode::Disabled) { + normalized.push(WebSearchMode::Disabled); + } + normalized + }), enforce_residency: requirements .enforce_residency .map(map_residency_requirement_to_api), @@ -177,6 +188,9 @@ mod tests { CoreSandboxModeRequirement::ReadOnly, CoreSandboxModeRequirement::ExternalSandbox, ]), + allowed_web_search_modes: Some(vec![ + codex_core::config_loader::WebSearchModeRequirement::Cached, + ]), mcp_servers: None, rules: None, enforce_residency: Some(CoreResidencyRequirement::Us), @@ -195,9 +209,32 @@ mod tests { mapped.allowed_sandbox_modes, Some(vec![SandboxMode::ReadOnly]), ); + assert_eq!( + mapped.allowed_web_search_modes, + Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]), + ); assert_eq!( mapped.enforce_residency, Some(codex_app_server_protocol::ResidencyRequirement::Us), ); } + + #[test] + fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() { + let requirements = ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: Some(Vec::new()), + mcp_servers: None, + rules: None, + enforce_residency: None, + }; + + let mapped = map_requirements_toml_to_api(requirements); + + assert_eq!( + mapped.allowed_web_search_modes, + Some(vec![WebSearchMode::Disabled]) + ); + } } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 9ca432dc7fa..30d49bd4066 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -381,6 +381,7 @@ mod tests { Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -421,6 +422,7 @@ mod tests { Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -464,6 +466,7 @@ mod tests { Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 663c65585a4..c1c3c59b683 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -733,10 +733,22 @@ impl Session { session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.personality = session_configuration.personality; - per_turn_config.web_search_mode = Some(resolve_web_search_mode_for_turn( - per_turn_config.web_search_mode, + let resolved_web_search_mode = resolve_web_search_mode_for_turn( + &per_turn_config.web_search_mode, session_configuration.sandbox_policy.get(), - )); + ); + if let Err(err) = per_turn_config + .web_search_mode + .set(resolved_web_search_mode) + { + let fallback_value = per_turn_config.web_search_mode.value(); + tracing::warn!( + error = %err, + ?resolved_web_search_mode, + ?fallback_value, + "resolved web_search_mode is disallowed by requirements; keeping constrained value" + ); + } per_turn_config.features = config.features.clone(); per_turn_config } @@ -794,7 +806,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &per_turn_config.features, - web_search_mode: per_turn_config.web_search_mode, + web_search_mode: Some(per_turn_config.web_search_mode.value()), }); let cwd = session_configuration.cwd.clone(); @@ -3521,7 +3533,15 @@ async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); - per_turn_config.web_search_mode = Some(review_web_search_mode); + if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) { + let fallback_value = per_turn_config.web_search_mode.value(); + tracing::warn!( + error = %err, + ?review_web_search_mode, + ?fallback_value, + "review web_search_mode is disallowed by requirements; keeping constrained value" + ); + } let otel_manager = parent_turn_context .otel_manager diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4e2d08b23ca..a75c48fc966 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -329,7 +329,7 @@ pub struct Config { pub include_apply_patch_tool: bool, /// Explicit or feature-derived web search mode. - pub web_search_mode: Option, + pub web_search_mode: Constrained, /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1331,17 +1331,39 @@ fn resolve_web_search_mode( } pub(crate) fn resolve_web_search_mode_for_turn( - explicit_mode: Option, + web_search_mode: &Constrained, sandbox_policy: &SandboxPolicy, ) -> WebSearchMode { - if let Some(mode) = explicit_mode { - return mode; - } - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - WebSearchMode::Live + let preferred = web_search_mode.value(); + + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) + && preferred != WebSearchMode::Disabled + { + for mode in [ + WebSearchMode::Live, + WebSearchMode::Cached, + WebSearchMode::Disabled, + ] { + if web_search_mode.can_set(&mode).is_ok() { + return mode; + } + } } else { - WebSearchMode::Cached + if web_search_mode.can_set(&preferred).is_ok() { + return preferred; + } + for mode in [ + WebSearchMode::Cached, + WebSearchMode::Live, + WebSearchMode::Disabled, + ] { + if web_search_mode.can_set(&mode).is_ok() { + return mode; + } + } } + + WebSearchMode::Disabled } impl Config { @@ -1482,7 +1504,8 @@ impl Config { ); approval_policy = requirements.approval_policy.value(); } - let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features); + let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) + .unwrap_or(WebSearchMode::Cached); // TODO(dylan): We should be able to leverage ConfigLayerStack so that // we can reliably check this at every config level. let did_user_set_custom_approval_policy_or_sandbox_mode = @@ -1626,6 +1649,7 @@ impl Config { let ConfigRequirements { approval_policy: mut constrained_approval_policy, sandbox_policy: mut constrained_sandbox_policy, + web_search_mode: mut constrained_web_search_mode, mcp_servers, exec_policy: _, enforce_residency, @@ -1643,6 +1667,12 @@ impl Config { &mut constrained_sandbox_policy, &mut startup_warnings, )?; + apply_requirement_constrained_value( + "web_search_mode", + web_search_mode, + &mut constrained_web_search_mode, + &mut startup_warnings, + )?; let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; @@ -1722,7 +1752,7 @@ impl Config { forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, - web_search_mode, + web_search_mode: constrained_web_search_mode.value, use_experimental_unified_exec_tool, ghost_snapshot, features, @@ -2462,27 +2492,51 @@ trust_level = "trusted" } #[test] - fn web_search_mode_for_turn_defaults_to_cached_when_unset() { - let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::ReadOnly); + fn web_search_mode_for_turn_uses_preference_for_read_only() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::ReadOnly); assert_eq!(mode, WebSearchMode::Cached); } #[test] - fn web_search_mode_for_turn_defaults_to_live_for_danger_full_access() { - let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::DangerFullAccess); + fn web_search_mode_for_turn_prefers_live_for_danger_full_access() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); + let mode = + resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); assert_eq!(mode, WebSearchMode::Live); } #[test] - fn web_search_mode_for_turn_prefers_explicit_value() { - let mode = resolve_web_search_mode_for_turn( - Some(WebSearchMode::Cached), - &SandboxPolicy::DangerFullAccess, - ); + fn web_search_mode_for_turn_respects_disabled_for_danger_full_access() { + let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled); + let mode = + resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + + assert_eq!(mode, WebSearchMode::Disabled); + } + + #[test] + fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> { + let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached]; + let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| { + if allowed.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: RequirementSource::Unknown, + }) + } + })?; + let mode = + resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); assert_eq!(mode, WebSearchMode::Cached); + Ok(()) } #[test] @@ -3983,7 +4037,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4071,7 +4125,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4174,7 +4228,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4263,7 +4317,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4309,6 +4363,72 @@ model_verbosity = "high" Ok(()) } + #[test] + fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> + { + let fixture = create_test_fixture()?; + + let requirements_toml = crate::config_loader::ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: Some(vec![ + crate::config_loader::WebSearchModeRequirement::Cached, + ]), + mcp_servers: None, + rules: None, + enforce_residency: None, + }; + let requirement_source = crate::config_loader::RequirementSource::Unknown; + let requirement_source_for_error = requirement_source.clone(); + let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached]; + let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| { + if matches!(candidate, WebSearchMode::Cached | WebSearchMode::Disabled) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{allowed:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + let requirements = crate::config_loader::ConfigRequirements { + web_search_mode: crate::config_loader::ConstrainedWithSource::new( + constrained, + Some(requirement_source), + ), + ..Default::default() + }; + let config_layer_stack = crate::config_loader::ConfigLayerStack::new( + Vec::new(), + requirements, + requirements_toml, + ) + .expect("config layer stack"); + + let config = Config::load_config_with_layer_stack( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd()), + ..Default::default() + }, + fixture.codex_home(), + config_layer_stack, + )?; + + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `web_search_mode`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); @@ -4810,6 +4930,7 @@ mcp_oauth_callback_port = 5678 allowed_sandbox_modes: Some(vec![ crate::config_loader::SandboxModeRequirement::ReadOnly, ]), + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -4827,6 +4948,38 @@ mcp_oauth_callback_port = 5678 Ok(()) } + #[tokio::test] + async fn requirements_web_search_mode_overrides_danger_full_access_default() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"sandbox_mode = "danger-full-access" +"#, + )?; + + 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 { + Some(crate::config_loader::ConfigRequirementsToml { + allowed_web_search_modes: Some(vec![ + crate::config_loader::WebSearchModeRequirement::Cached, + ]), + ..Default::default() + }) + })) + .build() + .await?; + + assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached); + assert_eq!( + resolve_web_search_mode_for_turn(&config.web_search_mode, config.sandbox_policy.get()), + WebSearchMode::Cached, + ); + Ok(()) + } + #[tokio::test] async fn requirements_disallowing_default_approval_falls_back_to_required_default() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index b3e60432887..da475bd4ede 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -76,6 +77,7 @@ impl std::ops::DerefMut for ConstrainedWithSource { pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, pub sandbox_policy: ConstrainedWithSource, + pub web_search_mode: ConstrainedWithSource, pub mcp_servers: Option>>, pub(crate) exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, @@ -92,6 +94,10 @@ impl Default for ConfigRequirements { Constrained::allow_any(SandboxPolicy::ReadOnly), None, ), + web_search_mode: ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + None, + ), mcp_servers: None, exec_policy: None, enforce_residency: ConstrainedWithSource::new(Constrained::allow_any(None), None), @@ -117,11 +123,50 @@ pub struct McpServerRequirement { pub identity: McpServerIdentity, } +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WebSearchModeRequirement { + Disabled, + Cached, + Live, +} + +impl From for WebSearchModeRequirement { + fn from(mode: WebSearchMode) -> Self { + match mode { + WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, + WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Live => WebSearchModeRequirement::Live, + } + } +} + +impl From for WebSearchMode { + fn from(mode: WebSearchModeRequirement) -> Self { + match mode { + WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, + WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Live => WebSearchMode::Live, + } + } +} + +impl fmt::Display for WebSearchModeRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WebSearchModeRequirement::Disabled => write!(f, "disabled"), + WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Live => write!(f, "live"), + } + } +} + /// Base config deserialized from /etc/codex/requirements.toml or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, pub mcp_servers: Option>, pub rules: Option, pub enforce_residency: Option, @@ -153,6 +198,7 @@ impl std::ops::Deref for Sourced { pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_sandbox_modes: Option>>, + pub allowed_web_search_modes: Option>>, pub mcp_servers: Option>>, pub rules: Option>, pub enforce_residency: Option>, @@ -186,6 +232,7 @@ impl ConfigRequirementsWithSources { { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -197,6 +244,7 @@ impl ConfigRequirementsWithSources { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -204,6 +252,7 @@ impl ConfigRequirementsWithSources { ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), @@ -248,6 +297,7 @@ impl ConfigRequirementsToml { pub fn is_empty(&self) -> bool { self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() + && self.allowed_web_search_modes.is_none() && self.mcp_servers.is_none() && self.rules.is_none() && self.enforce_residency.is_none() @@ -261,6 +311,7 @@ impl TryFrom for ConfigRequirements { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -356,6 +407,46 @@ impl TryFrom for ConfigRequirements { } None => None, }; + let web_search_mode = match allowed_web_search_modes { + Some(Sourced { + value: modes, + source: requirement_source, + }) => { + let mut accepted = modes.into_iter().collect::>(); + accepted.insert(WebSearchModeRequirement::Disabled); + let allowed_for_error = format!( + "{:?}", + accepted + .iter() + .copied() + .map(WebSearchMode::from) + .collect::>() + ); + + let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { + WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Live) { + WebSearchMode::Live + } else { + WebSearchMode::Disabled + }; + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if accepted.contains(&(*candidate).into()) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new(Constrained::allow_any(WebSearchMode::Cached), None), + }; let enforce_residency = match enforce_residency { Some(Sourced { @@ -383,6 +474,7 @@ impl TryFrom for ConfigRequirements { Ok(ConfigRequirements { approval_policy, sandbox_policy, + web_search_mode, mcp_servers, exec_policy, enforce_residency, @@ -410,6 +502,7 @@ mod tests { let ConfigRequirementsToml { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -419,6 +512,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_web_search_modes: allowed_web_search_modes + .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), enforce_residency: enforce_residency @@ -436,6 +531,10 @@ mod tests { SandboxModeRequirement::WorkspaceWrite, SandboxModeRequirement::DangerFullAccess, ]; + let allowed_web_search_modes = vec![ + WebSearchModeRequirement::Cached, + WebSearchModeRequirement::Live, + ]; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); @@ -444,6 +543,7 @@ mod tests { let other = ConfigRequirementsToml { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + allowed_web_search_modes: Some(allowed_web_search_modes.clone()), mcp_servers: None, rules: None, enforce_residency: Some(enforce_residency), @@ -459,6 +559,10 @@ mod tests { source.clone() )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)), + allowed_web_search_modes: Some(Sourced::new( + allowed_web_search_modes, + enforce_source.clone(), + )), mcp_servers: None, rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), @@ -489,6 +593,7 @@ mod tests { source_location, )), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -527,6 +632,7 @@ mod tests { existing_source, )), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -615,6 +721,7 @@ mod tests { r#" allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["read-only"] + allowed_web_search_modes = ["cached"] enforce_residency = "us" "#, )?; @@ -632,6 +739,10 @@ mod tests { requirements.sandbox_policy.source, Some(source_location.clone()) ); + assert_eq!( + requirements.web_search_mode.source, + Some(source_location.clone()) + ); assert_eq!(requirements.enforce_residency.source, Some(source_location)); Ok(()) @@ -746,6 +857,100 @@ mod tests { Ok(()) } + #[test] + fn deserialize_allowed_web_search_modes() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["cached"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Cached); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Live), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Live".into(), + allowed: "[Disabled, Cached]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Cached) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn allowed_web_search_modes_allows_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["disabled"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn allowed_web_search_modes_empty_restricts_to_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + #[test] fn deserialize_mcp_server_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0ae54111a47..c79388a71ef 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -41,6 +41,7 @@ pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; +pub use config_requirements::WebSearchModeRequirement; pub use diagnostics::ConfigError; pub use diagnostics::ConfigLoadError; pub use diagnostics::TextPosition; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index d68093c30f8..7f9c2a9a8ce 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -16,6 +16,7 @@ use crate::config_loader::config_requirements::RequirementSource; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; #[cfg(target_os = "macos")] use codex_protocol::protocol::SandboxPolicy; @@ -475,6 +476,7 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul &requirements_file, r#" allowed_approval_policies = ["never", "on-request"] +allowed_web_search_modes = ["cached"] enforce_residency = "us" "#, ) @@ -490,6 +492,13 @@ enforce_residency = "us" .cloned(), Some(vec![AskForApproval::Never, AskForApproval::OnRequest]) ); + assert_eq!( + config_requirements_toml + .allowed_web_search_modes + .as_deref() + .cloned(), + Some(vec![crate::config_loader::WebSearchModeRequirement::Cached]) + ); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( config_requirements.approval_policy.value(), @@ -504,6 +513,25 @@ enforce_residency = "us" .can_set(&AskForApproval::OnFailure) .is_err() ); + assert_eq!( + config_requirements.web_search_mode.value(), + WebSearchMode::Cached + ); + config_requirements + .web_search_mode + .can_set(&WebSearchMode::Cached)?; + config_requirements + .web_search_mode + .can_set(&WebSearchMode::Cached)?; + config_requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled)?; + assert!( + config_requirements + .web_search_mode + .can_set(&WebSearchMode::Live) + .is_err() + ); assert_eq!( config_requirements.enforce_residency.value(), Some(crate::config_loader::ResidencyRequirement::Us) @@ -536,6 +564,7 @@ allowed_approval_policies = ["on-request"] Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -582,6 +611,7 @@ allowed_approval_policies = ["on-request"] ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -617,6 +647,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> let requirements = ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 87d6c41933f..e2b11dd540b 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex_delegate::run_codex_thread_one_shot; +use crate::config::Constrained; use crate::review_format::format_review_findings_block; use crate::review_format::render_review_output_text; use crate::state::TaskKind; @@ -86,7 +87,20 @@ async fn start_review_conversation( let mut sub_agent_config = config.as_ref().clone(); // Carry over review-only feature restrictions so the delegate cannot // re-enable blocked tools (web search, view image). - sub_agent_config.web_search_mode = Some(WebSearchMode::Disabled); + if let Err(err) = sub_agent_config + .web_search_mode + .set(WebSearchMode::Disabled) + { + tracing::warn!( + "failed to force review web_search_mode=disabled; falling back to a normalizer: {err}" + ); + sub_agent_config.web_search_mode = + Constrained::normalized(WebSearchMode::Disabled, |_| WebSearchMode::Disabled) + .unwrap_or_else(|err| { + tracing::warn!("failed to build normalizer for review web_search_mode: {err}"); + Constrained::allow_any(WebSearchMode::Disabled) + }); + } // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index d74ab0d7be5..ae591461356 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -36,7 +36,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .with_model(model) // Keep tool expectations stable when the default web_search mode changes. .with_config(|config| { - config.web_search_mode = Some(WebSearchMode::Cached); + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); config.features.enable(Feature::CollaborationModes); }); let test = builder diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9b1548591b1..242f9314064 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -97,7 +97,10 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { config.user_instructions = Some("be consistent and helpful".to_string()); config.model = Some("gpt-5.1-codex-max".to_string()); // Keep tool expectations stable when the default web_search mode changes. - config.web_search_mode = Some(WebSearchMode::Cached); + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); config.features.enable(Feature::CollaborationModes); }) .build(&server) diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index edcbfd35d3e..65e8aedbed5 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -34,14 +34,17 @@ async fn web_search_mode_cached_sets_external_web_access_false() { let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.web_search_mode = Some(WebSearchMode::Cached); + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); }); let test = builder .build(&server) .await .expect("create test Codex conversation"); - test.submit_turn("hello cached web search") + test.submit_turn_with_policy("hello cached web search", SandboxPolicy::ReadOnly) .await .expect("submit turn"); @@ -69,14 +72,17 @@ async fn web_search_mode_takes_precedence_over_legacy_flags() { .with_model("gpt-5-codex") .with_config(|config| { config.features.enable(Feature::WebSearchRequest); - config.web_search_mode = Some(WebSearchMode::Cached); + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); }); let test = builder .build(&server) .await .expect("create test Codex conversation"); - test.submit_turn("hello cached+live flags") + test.submit_turn_with_policy("hello cached+live flags", SandboxPolicy::ReadOnly) .await .expect("submit turn"); @@ -90,7 +96,7 @@ async fn web_search_mode_takes_precedence_over_legacy_flags() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn web_search_mode_defaults_to_cached_when_unset() { +async fn web_search_mode_defaults_to_cached_when_features_disabled() { skip_if_no_network!(); let server = start_mock_server().await; @@ -103,7 +109,10 @@ async fn web_search_mode_defaults_to_cached_when_unset() { let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.web_search_mode = None; + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); config.features.disable(Feature::WebSearchCached); config.features.disable(Feature::WebSearchRequest); }); @@ -148,7 +157,10 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() { let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.web_search_mode = None; + config + .web_search_mode + .set(WebSearchMode::Cached) + .expect("test web_search_mode should satisfy constraints"); config.features.disable(Feature::WebSearchCached); config.features.disable(Feature::WebSearchRequest); }); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 4202264fb2e..911510aaf80 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -6,6 +6,7 @@ use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; +use codex_core::config_loader::WebSearchModeRequirement; use ratatui::style::Stylize; use ratatui::text::Line; @@ -70,6 +71,21 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(modes) = requirements_toml.allowed_web_search_modes.as_ref() { + let normalized = normalize_allowed_web_search_modes(modes); + let value = join_or_empty( + normalized + .iter() + .map(ToString::to_string) + .collect::>(), + ); + requirement_lines.push(requirement_line( + "allowed_web_search_modes", + value, + requirements.web_search_mode.source.as_ref(), + )); + } + if let Some(servers) = requirements_toml.mcp_servers.as_ref() { let value = join_or_empty(servers.keys().cloned().collect::>()); requirement_lines.push(requirement_line( @@ -127,6 +143,20 @@ fn join_or_empty(values: Vec) -> String { } } +fn normalize_allowed_web_search_modes( + modes: &[WebSearchModeRequirement], +) -> Vec { + if modes.is_empty() { + return vec![WebSearchModeRequirement::Disabled]; + } + + let mut normalized = modes.to_vec(); + if !normalized.contains(&WebSearchModeRequirement::Disabled) { + normalized.push(WebSearchModeRequirement::Disabled); + } + normalized +} + fn format_config_layer_source(source: &ConfigLayerSource) -> String { match source { ConfigLayerSource::Mdm { domain, key } => { @@ -185,8 +215,10 @@ mod tests { use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; use codex_core::config_loader::Sourced; + use codex_core::config_loader::WebSearchModeRequirement; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; + use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::text::Line; use std::collections::BTreeMap; @@ -287,10 +319,15 @@ mod tests { Constrained::allow_any(Some(ResidencyRequirement::Us)), Some(RequirementSource::CloudRequirements), ); + requirements.web_search_mode = ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + Some(RequirementSource::CloudRequirements), + ); let requirements_toml = ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), + allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), mcp_servers: Some(BTreeMap::from([( "docs".to_string(), McpServerRequirement { @@ -331,8 +368,39 @@ mod tests { .as_str(), ) ); + assert!( + rendered.contains( + "allowed_web_search_modes: cached, disabled (source: cloud requirements)" + ) + ); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(!rendered.contains(" - rules:")); } + + #[test] + fn debug_config_output_normalizes_empty_web_search_mode_list() { + let mut requirements = ConfigRequirements::default(); + requirements.web_search_mode = ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Disabled), + Some(RequirementSource::CloudRequirements), + ); + + let requirements_toml = ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: Some(Vec::new()), + mcp_servers: None, + rules: None, + enforce_residency: None, + }; + + let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!( + rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)") + ); + } }