From 2f8beb9f4e7c37bd7ca7eabaedf2ee964cdc7189 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Feb 2026 17:50:37 -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 | 10 + codex-rs/cloud-requirements/src/lib.rs | 3 + codex-rs/core/src/codex.rs | 33 ++- codex-rs/core/src/config/mod.rs | 84 ++++++-- .../src/config_loader/config_requirements.rs | 204 ++++++++++++++++++ codex-rs/core/src/config_loader/mod.rs | 1 + codex-rs/core/src/config_loader/tests.rs | 16 ++ codex-rs/core/src/tasks/review.rs | 5 +- 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 | 20 +- codex-rs/tui/src/debug_config.rs | 17 ++ 17 files changed, 404 insertions(+), 31 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 969d914b18..ff31b0d5e0 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 9e77238b75..d6ddd65172 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 765d0b86cf..89cecfd189 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 630e039578..c3fd335941 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 a6d49f2e4c..6065e404f9 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 e1f27be0b5..a1df94a0a7 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -115,6 +115,9 @@ 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| modes.into_iter().map(Into::into).collect()), enforce_residency: requirements .enforce_residency .map(map_residency_requirement_to_api), @@ -177,6 +180,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,6 +201,10 @@ mod tests { mapped.allowed_sandbox_modes, Some(vec![SandboxMode::ReadOnly]), ); + assert_eq!( + mapped.allowed_web_search_modes, + Some(vec![codex_protocol::config_types::WebSearchMode::Cached]), + ); assert_eq!( mapped.enforce_residency, Some(codex_app_server_protocol::ResidencyRequirement::Us), diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 9ca432dc7f..30d49bd406 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 663c65585a..a6f899c9b1 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(Some(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: per_turn_config.web_search_mode.value(), }); let cwd = session_configuration.cwd.clone(); @@ -3521,7 +3533,18 @@ 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(Some(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 4e2d08b23c..3a4cfb26e7 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,25 @@ 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 { + if let Some(mode) = web_search_mode.value() { return mode; } - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - WebSearchMode::Live + + let (preferred, alternate) = if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + (WebSearchMode::Live, WebSearchMode::Cached) } else { - WebSearchMode::Cached + (WebSearchMode::Cached, WebSearchMode::Live) + }; + for mode in [preferred, alternate, WebSearchMode::Disabled] { + let candidate = Some(mode); + if web_search_mode.can_set(&candidate).is_ok() { + return mode; + } } + WebSearchMode::Disabled } impl Config { @@ -1626,6 +1634,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 +1652,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 +1737,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, @@ -2463,24 +2478,26 @@ 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); + let web_search_mode = Constrained::allow_any(None); + 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); + let web_search_mode = Constrained::allow_any(None); + 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, - ); + let web_search_mode = Constrained::allow_any(Some(WebSearchMode::Cached)); + let mode = + resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); assert_eq!(mode, WebSearchMode::Cached); } @@ -3983,7 +4000,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(None), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4071,7 +4088,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(None), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4174,7 +4191,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(None), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4263,7 +4280,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(None), use_experimental_unified_exec_tool: !cfg!(windows), ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4810,6 +4827,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 +4845,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(), Some(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 b3e6043288..555a5f983c 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,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(SandboxPolicy::ReadOnly), None, ), + web_search_mode: ConstrainedWithSource::new(Constrained::allow_any(None), None), mcp_servers: None, exec_policy: None, enforce_residency: ConstrainedWithSource::new(Constrained::allow_any(None), None), @@ -117,11 +120,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 +195,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 +229,7 @@ impl ConfigRequirementsWithSources { { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -197,6 +241,7 @@ impl ConfigRequirementsWithSources { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -204,6 +249,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 +294,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 +308,7 @@ impl TryFrom for ConfigRequirements { let ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -356,6 +404,48 @@ impl TryFrom for ConfigRequirements { } None => None, }; + let web_search_mode = match allowed_web_search_modes { + Some(Sourced { + value: mut modes, + source: requirement_source, + }) => { + if modes.is_empty() { + modes.push(WebSearchModeRequirement::Disabled); + } + let initial_mode = match modes.first().copied() { + Some(mode) => mode, + None => WebSearchModeRequirement::Disabled, + }; + let mut accepted = modes.into_iter().collect::>(); + accepted.insert(WebSearchModeRequirement::Disabled); + let accepted_for_error: Vec = + accepted.iter().copied().map(Into::into).collect(); + let initial_value = Some(WebSearchMode::from(initial_mode)); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + let Some(mode) = candidate else { + return Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{accepted_for_error:?}"), + requirement_source: requirement_source_for_error.clone(), + }); + }; + if accepted.contains(&(*mode).into()) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: format!("{accepted_for_error:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new(Constrained::allow_any(None), None), + }; let enforce_residency = match enforce_residency { Some(Sourced { @@ -383,6 +473,7 @@ impl TryFrom for ConfigRequirements { Ok(ConfigRequirements { approval_policy, sandbox_policy, + web_search_mode, mcp_servers, exec_policy, enforce_residency, @@ -410,6 +501,7 @@ mod tests { let ConfigRequirementsToml { allowed_approval_policies, allowed_sandbox_modes, + allowed_web_search_modes, mcp_servers, rules, enforce_residency, @@ -419,6 +511,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 +530,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 +542,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 +558,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 +592,7 @@ mod tests { source_location, )), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -527,6 +631,7 @@ mod tests { existing_source, )), allowed_sandbox_modes: None, + allowed_web_search_modes: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -615,6 +720,7 @@ mod tests { r#" allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["read-only"] + allowed_web_search_modes = ["cached"] enforce_residency = "us" "#, )?; @@ -632,6 +738,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 +856,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(), + Some(WebSearchMode::Cached) + ); + assert!( + requirements + .web_search_mode + .can_set(&Some(WebSearchMode::Disabled)) + .is_ok() + ); + assert_eq!( + requirements + .web_search_mode + .can_set(&Some(WebSearchMode::Live)), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Some(Live)".into(), + allowed: "[Disabled, Cached]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert_eq!( + requirements.web_search_mode.can_set(&None), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "None".into(), + allowed: "[Disabled, Cached]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn allowed_web_search_modes_allows_disabled_and_sets_default() -> 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(), + Some(WebSearchMode::Disabled) + ); + assert_eq!( + requirements + .web_search_mode + .can_set(&Some(WebSearchMode::Cached)), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Some(Cached)".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn allowed_web_search_modes_empty_defaults_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(), + Some(WebSearchMode::Disabled) + ); + assert_eq!( + requirements + .web_search_mode + .can_set(&Some(WebSearchMode::Cached)), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Some(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 0ae54111a4..c79388a71e 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 d68093c30f..a6eb922969 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,10 @@ enforce_residency = "us" .can_set(&AskForApproval::OnFailure) .is_err() ); + assert_eq!( + config_requirements.web_search_mode.value(), + Some(WebSearchMode::Cached) + ); assert_eq!( config_requirements.enforce_residency.value(), Some(crate::config_loader::ResidencyRequirement::Us) @@ -536,6 +549,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 +596,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 +632,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 87d6c41933..50931a3557 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -86,7 +86,10 @@ 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); + sub_agent_config + .web_search_mode + .set(Some(WebSearchMode::Disabled)) + .expect("review web_search_mode should satisfy constraints"); // 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 d74ab0d7be..870868d2b3 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(Some(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 9b1548591b..e1acef312b 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(Some(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 edcbfd35d3..1f8a9de5e3 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -34,7 +34,10 @@ 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(Some(WebSearchMode::Cached)) + .expect("test web_search_mode should satisfy constraints"); }); let test = builder .build(&server) @@ -69,7 +72,10 @@ 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(Some(WebSearchMode::Cached)) + .expect("test web_search_mode should satisfy constraints"); }); let test = builder .build(&server) @@ -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(None) + .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(None) + .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 4202264fb2..67ae644080 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -70,6 +70,15 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(modes) = requirements_toml.allowed_web_search_modes.as_ref() { + let value = join_or_empty(modes.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( @@ -185,8 +194,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 +298,15 @@ mod tests { Constrained::allow_any(Some(ResidencyRequirement::Us)), Some(RequirementSource::CloudRequirements), ); + requirements.web_search_mode = ConstrainedWithSource::new( + Constrained::allow_any(Some(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,6 +347,7 @@ mod tests { .as_str(), ) ); + assert!(rendered.contains("allowed_web_search_modes: cached (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:"));