From a5a94ee5a7447d2c0a846dbc97c2a469c115a598 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 29 May 2026 16:31:33 -0700 Subject: [PATCH] Constrain Windows sandbox requirements (#23766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Managed requirements can already constrain sandbox policy choices, but Windows sandbox implementation selection was still resolved independently from those requirements. That left the TUI able to continue through the unelevated fallback even when an organization wants to require the elevated Windows sandbox implementation. # What - Add `[windows].allowed_sandbox_implementations` requirements support for the Windows `elevated` and `unelevated` implementations. - Apply that allowlist during core config resolution so disallowed configured or feature-selected Windows sandbox implementations fall back to an allowed implementation with the existing requirements warning path. - Reuse the existing TUI Windows setup prompts to block disallowed unelevated continuation, keep required elevated setup in front of the user, and refuse to persist a TUI-selected Windows sandbox mode that requirements disallow. # Semantics | Allowed | Selected | Effective | | --- | --- | --- | | `["elevated"]` | `unelevated` / unset | `elevated` | | `["unelevated"]` | `elevated` / unset | `unelevated` | | `["elevated", "unelevated"]` | `elevated` | `elevated` | | `["elevated", "unelevated"]` | `unelevated` | `unelevated` | | `["elevated", "unelevated"]` | unset | `elevated` | Availability is handled by interactive setup surfaces after allowlist resolution. If the effective elevated implementation is not ready, elevated-only requirements block on setup. When unelevated is also allowed, the UI may offer the existing unelevated fallback. ## TUI Screens If elevated setup is not already complete: ``` Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control network access. Learn more › 1. Set up default sandbox (requires Administrator permissions) 2. Quit ``` If admin setup fails under `["elevated"]`: ``` Couldn't set up your sandbox with Administrator permissions Your organization requires the default sandbox before Codex can continue. Learn more › 1. Try setting up admin sandbox again 2. Quit ``` # Next Steps - extend the requirements/readout surface, such as `configRequirements/read`, so clients can inspect the loaded `[windows].allowed_sandbox_implementations` requirement instead of inferring it from Windows setup state - consider extending `windowsSandbox/readiness` as well - update the App startup guide, setup flow, and banner surfaces so an elevated-only requirement omits any continue-unelevated escape hatch and blocks startup until a permitted implementation is ready; - preserve the existing unelevated fallback path when requirements allow it, including the `["unelevated"]` case where elevated is disallowed --- .../codex_app_server_protocol.schemas.json | 9 + .../codex_app_server_protocol.v2.schemas.json | 9 + .../v2/ConfigRequirementsReadResponse.json | 16 ++ .../typescript/v2/ConfigRequirements.ts | 3 +- .../src/protocol/v2/config.rs | 2 + .../src/protocol/v2/tests.rs | 1 + .../request_processors/config_processor.rs | 41 +++ .../windows_sandbox_processor.rs | 117 ++++++--- codex-rs/cloud-requirements/src/lib.rs | 16 ++ codex-rs/config/src/config_requirements.rs | 138 ++++++++++ codex-rs/config/src/lib.rs | 1 + .../core/src/config/config_loader_tests.rs | 3 + codex-rs/core/src/config/config_tests.rs | 43 ++++ codex-rs/core/src/config/mod.rs | 28 +- codex-rs/tui/src/app/event_dispatch.rs | 57 ++++- codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/chatwidget/input_restore.rs | 7 + codex-rs/tui/src/chatwidget/session_flow.rs | 8 +- ...indows_sandbox_required_enable_prompt.snap | 12 + ...dows_sandbox_required_fallback_prompt.snap | 13 + codex-rs/tui/src/chatwidget/tests.rs | 1 - .../tui/src/chatwidget/tests/permissions.rs | 161 ++++++++++++ .../src/chatwidget/windows_sandbox_prompts.rs | 239 +++++++++++------- codex-rs/tui/src/debug_config.rs | 2 + codex-rs/tui/src/lib.rs | 31 ++- 25 files changed, 821 insertions(+), 143 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap 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 92549d74c..0c2ebf121 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 @@ -7809,6 +7809,15 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/v2/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, "computerUse": { "anyOf": [ { 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 d266a3e97..c6ea51680 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 @@ -4178,6 +4178,15 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, "computerUse": { "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 a82421fb0..0b8170d65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -121,6 +121,15 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, "computerUse": { "anyOf": [ { @@ -485,6 +494,13 @@ "live" ], "type": "string" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "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 c5f389586..3e60e78da 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,5 +6,6 @@ import type { AskForApproval } from "./AskForApproval"; import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; +import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: 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, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; 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 e30b3012e..05a31cf44 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -1,6 +1,7 @@ use super::ApprovalsReviewer; use super::AskForApproval; use super::SandboxMode; +use super::WindowsSandboxSetupMode; use super::shared::default_enabled; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::AutoCompactTokenLimitScope; @@ -358,6 +359,7 @@ pub struct ConfigRequirements { #[experimental("configRequirements/read.allowedApprovalsReviewers")] pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_windows_sandbox_implementations: Option>, pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, 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 a0be5cb15..bb6003c7a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1670,6 +1670,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() }]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_windows_sandbox_implementations: None, allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, 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 5ba6d02ec..357102619 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -30,6 +30,7 @@ use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; use codex_config::HookEventsToml; @@ -420,6 +421,23 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .filter_map(map_sandbox_mode_requirement_to_api) .collect() }), + allowed_windows_sandbox_implementations: requirements.windows.and_then(|windows| { + windows + .allowed_sandbox_implementations + .map(|implementations| { + implementations + .into_iter() + .map(|implementation| match implementation { + codex_config::types::WindowsSandboxModeToml::Elevated => { + WindowsSandboxSetupMode::Elevated + } + codex_config::types::WindowsSandboxModeToml::Unelevated => { + WindowsSandboxSetupMode::Unelevated + } + }) + .collect() + }) + }), allowed_permissions: requirements.allowed_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes @@ -634,8 +652,10 @@ 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::WindowsSandboxSetupMode; use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; + use codex_config::WindowsRequirementsToml; use pretty_assertions::assert_eq; #[test] @@ -687,4 +707,25 @@ mod tests { Some(false) ); } + + #[test] + fn requirements_api_includes_allowed_windows_sandbox_implementations() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + windows: Some(WindowsRequirementsToml { + allowed_sandbox_implementations: Some(vec![ + codex_config::types::WindowsSandboxModeToml::Elevated, + codex_config::types::WindowsSandboxModeToml::Unelevated, + ]), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.allowed_windows_sandbox_implementations, + Some(vec![ + WindowsSandboxSetupMode::Elevated, + WindowsSandboxSetupMode::Unelevated, + ]) + ); + } } diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index aa82ea951..c0917d933 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -41,6 +41,29 @@ impl WindowsSandboxRequestProcessor { request_id: &ConnectionRequestId, params: WindowsSandboxSetupStartParams, ) -> Result<(), JSONRPCErrorError> { + // Validate requirements before acknowledging setup so callers do not get a + // `started` response for a Windows sandbox mode that cannot be persisted. + let command_cwd = params + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| self.config.cwd.to_path_buf()); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(command_cwd.clone()), + ..Default::default() + }, + Some(command_cwd.clone()), + ) + .await + .map_err(|err| config_load_error(&err))?; + let setup_mode = resolve_allowed_windows_sandbox_setup_mode( + config.config_layer_stack.requirements(), + params.mode, + )?; + self.outgoing .send_response( request_id.clone(), @@ -48,46 +71,22 @@ impl WindowsSandboxRequestProcessor { ) .await; - let mode = match params.mode { - WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated, - WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, - }; - let config = Arc::clone(&self.config); - let config_manager = self.config_manager.clone(); - let command_cwd = params - .cwd - .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.to_path_buf()); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; tokio::spawn(async move { - let derived_config = config_manager - .load_for_cwd( - /*request_overrides*/ None, - ConfigOverrides { - cwd: Some(command_cwd.clone()), - ..Default::default() - }, - Some(command_cwd.clone()), - ) - .await; - let setup_result = match derived_config { - Ok(config) => { - let setup_request = WindowsSandboxSetupRequest { - mode, - permission_profile: config.permissions.effective_permission_profile(), - workspace_roots: config.effective_workspace_roots(), - command_cwd, - env_map: std::env::vars().collect(), - codex_home: config.codex_home.to_path_buf(), - }; - codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await - } - Err(err) => Err(err.into()), + let setup_request = WindowsSandboxSetupRequest { + mode: setup_mode, + permission_profile: config.permissions.effective_permission_profile(), + workspace_roots: config.effective_workspace_roots(), + command_cwd, + env_map: std::env::vars().collect(), + codex_home: config.codex_home.to_path_buf(), }; + let setup_result = + codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await; let notification = WindowsSandboxSetupCompletedNotification { - mode: match mode { + mode: match setup_mode { CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated, CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated, }, @@ -105,6 +104,28 @@ impl WindowsSandboxRequestProcessor { } } +/// Resolves the requested API mode after checking that managed requirements allow it. +fn resolve_allowed_windows_sandbox_setup_mode( + requirements: &codex_config::ConfigRequirements, + requested_mode: WindowsSandboxSetupMode, +) -> Result { + let (setup_mode, config_mode) = match requested_mode { + WindowsSandboxSetupMode::Elevated => ( + CoreWindowsSandboxSetupMode::Elevated, + codex_config::types::WindowsSandboxModeToml::Elevated, + ), + WindowsSandboxSetupMode::Unelevated => ( + CoreWindowsSandboxSetupMode::Unelevated, + codex_config::types::WindowsSandboxModeToml::Unelevated, + ), + }; + requirements + .windows_sandbox_mode + .can_set(&Some(config_mode)) + .map_err(|err| invalid_request(format!("invalid Windows sandbox setup mode: {err}")))?; + Ok(setup_mode) +} + fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadinessResponse { if !cfg!(windows) { return WindowsSandboxReadinessResponse { @@ -140,6 +161,34 @@ fn determine_windows_sandbox_readiness_from_state( #[cfg(test)] mod tests { use super::*; + use crate::error_code::INVALID_REQUEST_ERROR_CODE; + use codex_config::ConfigRequirements; + use codex_config::Constrained; + use codex_config::ConstrainedWithSource; + use codex_config::types::WindowsSandboxModeToml; + + #[test] + fn resolve_allowed_windows_sandbox_setup_mode_rejects_disallowed_mode() { + let requirements = ConfigRequirements { + windows_sandbox_mode: ConstrainedWithSource::new( + Constrained::allow_only(Some(WindowsSandboxModeToml::Elevated)), + /*source*/ None, + ), + ..Default::default() + }; + + let err = resolve_allowed_windows_sandbox_setup_mode( + &requirements, + WindowsSandboxSetupMode::Unelevated, + ) + .expect_err("unelevated setup should be rejected"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert!( + err.message.contains("invalid Windows sandbox setup mode"), + "{err:?}" + ); + } #[test] fn determine_windows_sandbox_readiness_reports_not_configured_when_disabled() { diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 19fbd8cd1..0beb91780 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1222,6 +1222,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1308,6 +1309,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1345,6 +1347,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1399,6 +1402,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1582,6 +1586,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1666,6 +1671,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1748,6 +1754,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1958,6 +1965,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2002,6 +2010,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2066,6 +2075,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2126,6 +2136,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2188,6 +2199,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2251,6 +2263,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2318,6 +2331,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2411,6 +2425,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2450,6 +2465,7 @@ command = "sample-mcp" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9be87746e..1f92591bc 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::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { @@ -87,6 +88,7 @@ pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, + pub windows_sandbox_mode: ConstrainedWithSource>, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -120,6 +122,10 @@ impl Default for ConfigRequirements { Constrained::allow_any(PermissionProfile::read_only()), /*source*/ None, ), + windows_sandbox_mode: ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, @@ -648,6 +654,17 @@ impl ComputerUseRequirementsToml { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct WindowsRequirementsToml { + pub allowed_sandbox_implementations: Option>, +} + +impl WindowsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allowed_sandbox_implementations.is_none() + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct FeatureRequirementsToml { #[serde(flatten)] @@ -754,6 +771,7 @@ pub struct ConfigRequirementsToml { pub allow_managed_hooks_only: Option, pub allow_appshots: Option, pub computer_use: Option, + pub windows: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub hooks: Option, @@ -806,6 +824,7 @@ pub struct ConfigRequirementsWithSources { pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, pub computer_use: Option>, + pub windows: Option>, pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, @@ -846,6 +865,7 @@ impl ConfigRequirementsWithSources { allow_managed_hooks_only: _, allow_appshots: _, computer_use: _, + windows: _, feature_requirements: _, hooks: _, mcp_servers: _, @@ -879,6 +899,7 @@ impl ConfigRequirementsWithSources { allow_managed_hooks_only, allow_appshots, computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -910,6 +931,7 @@ impl ConfigRequirementsWithSources { allow_managed_hooks_only, allow_appshots, computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -931,6 +953,7 @@ impl ConfigRequirementsWithSources { allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), allow_appshots: allow_appshots.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), + windows: windows.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -1021,6 +1044,10 @@ impl ConfigRequirementsToml { .computer_use .as_ref() .is_none_or(ComputerUseRequirementsToml::is_empty) + && self + .windows + .as_ref() + .is_none_or(WindowsRequirementsToml::is_empty) && self .feature_requirements .as_ref() @@ -1065,6 +1092,7 @@ impl TryFrom for ConfigRequirements { allow_managed_hooks_only, allow_appshots, computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -1174,6 +1202,44 @@ impl TryFrom for ConfigRequirements { /*source*/ None, ), }; + let windows_sandbox_mode = match windows { + Some(Sourced { + value: + WindowsRequirementsToml { + allowed_sandbox_implementations: Some(implementations), + }, + 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 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)) + } + Some(_) | None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; let exec_policy = match rules { Some(Sourced { value, source }) => { let policy = value.to_requirements_policy().map_err(|err| { @@ -1299,6 +1365,7 @@ impl TryFrom for ConfigRequirements { approval_policy, approvals_reviewer, permission_profile, + windows_sandbox_mode, web_search_mode, allow_managed_hooks_only, allow_appshots, @@ -1374,6 +1441,7 @@ mod tests { allow_managed_hooks_only, allow_appshots, computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -1401,6 +1469,7 @@ mod tests { allow_appshots: allow_appshots .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), + windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1581,6 +1650,7 @@ mod tests { allow_managed_hooks_only: Some(true), allow_appshots: Some(false), computer_use: Some(computer_use.clone()), + windows: None, feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1621,6 +1691,7 @@ mod tests { )), allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), + windows: None, feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1667,6 +1738,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1718,6 +1790,7 @@ mod tests { allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -2357,6 +2430,71 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_allowed_windows_sandbox_implementations() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Elevated)) + .is_ok() + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Unelevated)) + .is_err() + ); + assert!(requirements.windows_sandbox_mode.can_set(&None).is_err()); + + Ok(()) + } + + #[test] + fn empty_allowed_windows_sandbox_implementations_is_rejected() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + ConfigRequirements::try_from(with_unknown_source(config)), + Err(ConstraintError::EmptyField { + field_name: "windows.allowed_sandbox_implementations".to_string(), + }) + ); + + Ok(()) + } + + #[test] + fn allowed_windows_sandbox_implementations_prefer_elevated_fallback() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["unelevated", "elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + + Ok(()) + } + #[test] fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 5ae268803..036696235 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -62,6 +62,7 @@ pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; pub use config_requirements::WebSearchModeRequirement; +pub use config_requirements::WindowsRequirementsToml; pub use config_requirements::sandbox_mode_requirement_for_permission_profile; pub use constraint::Constrained; pub use constraint::ConstraintError; diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 2682e6ef4..d0097d753 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1103,6 +1103,7 @@ allowed_approval_policies = ["on-request"] allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1164,6 +1165,7 @@ allowed_approval_policies = ["on-request"] allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1374,6 +1376,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 950103562..cb72404ed 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8195,6 +8195,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8918,6 +8919,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8945,6 +8947,47 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s Ok(()) } +#[tokio::test] +async fn windows_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[windows] +sandbox = "unelevated" +"#, + )?; + + let requirements = codex_config::ConfigRequirementsToml { + windows: Some(codex_config::WindowsRequirementsToml { + allowed_sandbox_implementations: Some(vec![ + codex_config::types::WindowsSandboxModeToml::Elevated, + ]), + }), + ..Default::default() + }; + + let 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_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + assert_eq!( + config.permissions.windows_sandbox_mode, + Some(codex_config::types::WindowsSandboxModeToml::Elevated) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `windows.sandbox` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn danger_full_access_with_never_is_rejected_when_requirements_force_read_only() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 618520b7b..482159dfb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2457,6 +2457,7 @@ impl Config { 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, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, allow_appshots: _, @@ -2568,7 +2569,28 @@ impl Config { &mut startup_warnings, )?; let enable_network_proxy = features.enabled(Feature::NetworkProxy); - let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + // Keep the configured mode separate so a requirement-constrained mode + // does not look like it was explicitly selected in config. + let selected_windows_sandbox_mode = configured_windows_sandbox_mode.or_else(|| { + match WindowsSandboxLevel::from_features(&features) { + WindowsSandboxLevel::Elevated => Some(WindowsSandboxModeToml::Elevated), + WindowsSandboxLevel::RestrictedToken => Some(WindowsSandboxModeToml::Unelevated), + WindowsSandboxLevel::Disabled => None, + } + }); + apply_requirement_constrained_value( + "windows.sandbox", + selected_windows_sandbox_mode, + &mut constrained_windows_sandbox_mode, + &mut startup_warnings, + )?; + let effective_windows_sandbox_mode = *constrained_windows_sandbox_mode.get(); + let windows_sandbox_mode = if constrained_windows_sandbox_mode.source.is_some() { + effective_windows_sandbox_mode + } else { + configured_windows_sandbox_mode + }; let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg); let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; @@ -2626,10 +2648,10 @@ impl Config { )); } - let windows_sandbox_level = match windows_sandbox_mode { + let windows_sandbox_level = match effective_windows_sandbox_mode { Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, - None => WindowsSandboxLevel::from_features(&features), + None => WindowsSandboxLevel::Disabled, }; let memories_config: MemoriesConfig = cfg.memories.clone().unwrap_or_default().into(); let memories_root = memory_root(&codex_home); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f9683e26e..2e1b97aac 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,8 @@ use super::resize_reflow::trailing_run_start; use super::*; +#[cfg(target_os = "windows")] +use codex_config::types::WindowsSandboxModeToml; const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); @@ -867,6 +869,19 @@ impl App { preset, profile_selection, } => { + #[cfg(any(target_os = "windows", test))] + if !self.chat_widget.windows_sandbox_mode_allowed( + codex_config::types::WindowsSandboxModeToml::Elevated, + ) { + tracing::warn!( + "refusing to set up elevated Windows sandbox mode disallowed by requirements" + ); + self.chat_widget.add_info_message( + "That Windows sandbox option is disallowed by requirements.".to_string(), + /*hint*/ None, + ); + return Ok(AppRunControl::Continue); + } #[cfg(target_os = "windows")] { let setup_permissions = match self @@ -977,6 +992,19 @@ impl App { preset, profile_selection, } => { + #[cfg(any(target_os = "windows", test))] + if !self.chat_widget.windows_sandbox_mode_allowed( + codex_config::types::WindowsSandboxModeToml::Unelevated, + ) { + tracing::warn!( + "refusing to set up unelevated Windows sandbox mode disallowed by requirements" + ); + self.chat_widget.add_info_message( + "That Windows sandbox option is disallowed by requirements.".to_string(), + /*hint*/ None, + ); + return Ok(AppRunControl::Continue); + } #[cfg(target_os = "windows")] { let setup_permissions = match self @@ -1109,7 +1137,23 @@ impl App { &[("result", "success")], ); } - let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); + let selected_mode = match mode { + WindowsSandboxEnableMode::Elevated => WindowsSandboxModeToml::Elevated, + WindowsSandboxEnableMode::Legacy => WindowsSandboxModeToml::Unelevated, + }; + let elevated_enabled = selected_mode == WindowsSandboxModeToml::Elevated; + if !self.chat_widget.windows_sandbox_mode_allowed(selected_mode) { + tracing::warn!( + ?selected_mode, + "refusing to persist Windows sandbox mode disallowed by requirements" + ); + self.chat_widget.add_info_message( + "That Windows sandbox option is disallowed by requirements." + .to_string(), + /*hint*/ None, + ); + return Ok(AppRunControl::Continue); + } let edits = crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled); match crate::config_update::write_config_batch( @@ -1184,8 +1228,9 @@ impl App { /*personality*/ None, ), )); - self.apply_permission_profile_selection(selection).await; - let _ = mode; + if self.apply_permission_profile_selection(selection).await { + self.chat_widget.submit_initial_user_message_if_pending(); + } self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), Line::from(vec![ @@ -1219,7 +1264,6 @@ impl App { .send(AppEvent::UpdateActivePermissionProfile( preset.active_permission_profile.clone(), )); - let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), Line::from(vec![ @@ -1462,6 +1506,7 @@ impl App { Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() .await; + self.chat_widget.submit_initial_user_message_if_pending(); // If a managed filesystem sandbox is active, run the Windows // world-writable scan. @@ -1498,7 +1543,9 @@ impl App { } } AppEvent::SelectPermissionProfile(selection) => { - self.apply_permission_profile_selection(selection).await; + if self.apply_permission_profile_selection(selection).await { + self.chat_widget.submit_initial_user_message_if_pending(); + } } AppEvent::UpdateApprovalsReviewer(policy) => { self.config.approvals_reviewer = policy; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 860422955..c24d5dfce 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -63,7 +63,7 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; use crate::legacy_core::config::ConstraintResult; use crate::legacy_core::config::PermissionProfileSnapshot; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", test))] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; @@ -153,7 +153,7 @@ use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", test))] use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; @@ -261,7 +261,7 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::PermissionProfileSelection; use crate::app_event::RateLimitRefreshOrigin; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", test))] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::auto_review_denials; diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index b7e8ee72e..db28f4810 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -8,6 +8,13 @@ impl ChatWidget { } pub(crate) fn submit_initial_user_message_if_pending(&mut self) { + if self.suppress_initial_user_message_submit { + return; + } + #[cfg(any(target_os = "windows", test))] + if self.elevated_windows_sandbox_setup_required() { + return; + } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 01a6b7d57..6eb5910af 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -135,13 +135,7 @@ impl ChatWidget { if self.connectors_enabled() { self.prefetch_connectors(); } - if let Some(user_message) = self.initial_user_message.take() { - if self.suppress_initial_user_message_submit { - self.initial_user_message = Some(user_message); - } else { - self.submit_user_message(user_message); - } - } + self.submit_initial_user_message_if_pending(); if display == SessionConfiguredDisplay::Normal && let Some(forked_from_id) = forked_from_id { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap new file mode 100644 index 000000000..c0c36c394 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_enable_prompt.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 120)" +--- + Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control + network access. + Learn more + +› 1. Set up default sandbox (requires Administrator permissions) + 2. Quit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap new file mode 100644 index 000000000..c9195f7a0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_required_fallback_prompt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: popup +--- + Couldn't set up your sandbox with Administrator permissions + + Your organization requires the default sandbox before Codex can continue. + Learn more + +› 1. Try setting up admin sandbox again + 2. Quit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5d27e0ddc..2da524910 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -123,7 +123,6 @@ pub(super) use codex_config::ConfigLayerStack; pub(super) use codex_config::RequirementSource; pub(super) use codex_config::types::ApprovalsReviewer; pub(super) use codex_config::types::Notifications; -#[cfg(target_os = "windows")] pub(super) use codex_config::types::WindowsSandboxModeToml; pub(super) use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; pub(super) use codex_core_skills::model::SkillMetadata; diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 20d600b30..a9f6f7abe 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -48,6 +48,25 @@ fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> Permission } } +fn windows_sandbox_requirements_stack( + allowed_sandbox_implementations: Vec, +) -> ConfigLayerStack { + let requirements_toml = codex_config::ConfigRequirementsToml { + windows: Some(codex_config::WindowsRequirementsToml { + allowed_sandbox_implementations: Some(allowed_sandbox_implementations), + }), + ..Default::default() + }; + let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); + requirements_with_sources + .merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone()); + let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources) + .expect("windows sandbox requirements"); + + ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + .expect("test config layer stack") +} + #[tokio::test] async fn approvals_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -404,6 +423,148 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { ); } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn startup_windows_sandbox_prompt_blocks_disallowed_unelevated_fallback() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.set_feature_enabled(Feature::WindowsSandbox, /*enabled*/ false); + chat.set_feature_enabled(Feature::WindowsSandboxElevated, /*enabled*/ false); + chat.config.config_layer_stack = + windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]); + + chat.maybe_prompt_windows_sandbox_enable(/*show_now*/ true); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert!( + popup.contains("Your organization requires the default Codex agent sandbox"), + "expected required sandbox prompt copy: {popup}" + ); + assert!( + !popup.contains("Use non-admin sandbox"), + "expected required sandbox prompt to hide non-admin fallback: {popup}" + ); +} + +#[tokio::test] +async fn windows_sandbox_required_enable_prompt_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.config.config_layer_stack = + windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + + chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + + assert_chatwidget_snapshot!( + "windows_sandbox_required_enable_prompt", + render_bottom_popup(&chat, /*width*/ 120) + ); +} + +#[tokio::test] +async fn windows_sandbox_required_enable_prompt_reopens_on_cancel_when_unelevated_allowed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated); + chat.config.config_layer_stack = windows_sandbox_requirements_stack(vec![ + WindowsSandboxModeToml::Elevated, + WindowsSandboxModeToml::Unelevated, + ]); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + + chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::OpenWindowsSandboxEnablePrompt { .. }) + )); +} + +#[tokio::test] +async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let initial_prompt = "fix required sandbox startup".to_string(); + + chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated); + chat.config.config_layer_stack = windows_sandbox_requirements_stack(vec![ + WindowsSandboxModeToml::Elevated, + WindowsSandboxModeToml::Unelevated, + ]); + chat.initial_user_message = + create_initial_user_message(Some(initial_prompt.clone()), Vec::new(), Vec::new()); + + chat.handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: None, + cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }); + drain_insert_history(&mut rx); + + assert!(chat.initial_user_message.is_some()); + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::UserTurn { .. }), + "required sandbox setup should hold the configured initial prompt" + ); + } + + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + chat.submit_initial_user_message_if_pending(); + + let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else { + panic!("expected initial prompt submission after setup is no longer required"); + }; + assert_eq!( + items, + vec![UserInput::Text { + text: initial_prompt, + text_elements: Vec::new(), + }] + ); +} + +#[tokio::test] +async fn windows_sandbox_required_fallback_prompt_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.config.config_layer_stack = + windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]); + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + + chat.open_windows_sandbox_fallback_prompt(preset, /*profile_selection*/ None); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!("windows_sandbox_required_fallback_prompt", popup); +} + #[cfg(target_os = "windows")] #[tokio::test] async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() { diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index ebeb23429..e92180d49 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -3,6 +3,31 @@ use super::*; impl ChatWidget { + #[cfg(any(target_os = "windows", test))] + pub(crate) fn windows_sandbox_mode_allowed(&self, mode: WindowsSandboxModeToml) -> bool { + self.config + .config_layer_stack + .requirements() + .windows_sandbox_mode + .can_set(&Some(mode)) + .is_ok() + } + + #[cfg(any(target_os = "windows", test))] + pub(super) fn elevated_windows_sandbox_setup_required(&self) -> bool { + WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Elevated + && self + .config + .config_layer_stack + .requirements() + .windows_sandbox_mode + .source + .is_some() + && !crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + } + #[cfg(target_os = "windows")] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { if self @@ -193,7 +218,7 @@ impl ChatWidget { ) { } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn open_windows_sandbox_enable_prompt( &mut self, preset: ApprovalPreset, @@ -255,11 +280,22 @@ impl ChatWidget { &[], ); + let allow_unelevated = + self.windows_sandbox_mode_allowed(WindowsSandboxModeToml::Unelevated); + let setup_choice_is_required = + !allow_unelevated || self.elevated_windows_sandbox_setup_required(); let mut header = ColumnRenderable::new(); header.push(*Box::new( - Paragraph::new(vec![ - line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], - ]) + Paragraph::new(if allow_unelevated { + vec![ + line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], + ] + } else { + vec![ + line!["Your organization requires the default Codex agent sandbox to continue. Set it up to protect your files and control network access."], + line!["Learn more "], + ] + }) .wrap(Wrap { trim: false }), )); @@ -268,25 +304,27 @@ impl ChatWidget { let legacy_preset = preset.clone(); let legacy_profile_selection = profile_selection.clone(); let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Set up default sandbox (requires Administrator permissions)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - accept_otel.counter( - "codex.windows_sandbox.elevated_prompt_accept", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - profile_selection: profile_selection.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { + let retry_preset = preset.clone(); + let retry_profile_selection = profile_selection.clone(); + let mut items = vec![SelectionItem { + name: "Set up default sandbox (requires Administrator permissions)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + accept_otel.counter( + "codex.windows_sandbox.elevated_prompt_accept", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + profile_selection: profile_selection.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }]; + if allow_unelevated { + items.push(SelectionItem { name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), description: None, actions: vec![Box::new(move |tx| { @@ -302,33 +340,41 @@ impl ChatWidget { })], dismiss_on_select: true, ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.elevated_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; + }); + } + items.push(SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.elevated_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }); self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), + on_cancel: setup_choice_is_required.then(|| { + Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: retry_preset.clone(), + profile_selection: retry_profile_selection.clone(), + }); + }) as _ + }), ..Default::default() }); } - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(test)))] pub(crate) fn open_windows_sandbox_enable_prompt( &mut self, _preset: ApprovalPreset, @@ -336,7 +382,7 @@ impl ChatWidget { ) { } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, preset: ApprovalPreset, @@ -344,14 +390,24 @@ impl ChatWidget { ) { use ratatui_macros::line; + let allow_unelevated = + self.windows_sandbox_mode_allowed(WindowsSandboxModeToml::Unelevated); + let setup_choice_is_required = + !allow_unelevated || self.elevated_windows_sandbox_setup_required(); let mut lines = Vec::new(); lines.push(line![ "Couldn't set up your sandbox with Administrator permissions".bold() ]); lines.push(line![""]); - lines.push(line![ - "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." - ]); + if allow_unelevated { + lines.push(line![ + "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." + ]); + } else { + lines.push(line![ + "Your organization requires the default sandbox before Codex can continue." + ]); + } lines.push(line![ "Learn more " ]); @@ -361,32 +417,34 @@ impl ChatWidget { let elevated_preset = preset.clone(); let legacy_preset = preset; + let retry_preset = elevated_preset.clone(); + let retry_profile_selection = profile_selection.clone(); let elevated_profile_selection = profile_selection.clone(); let legacy_profile_selection = profile_selection; let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Try setting up admin sandbox again".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.session_telemetry.clone(); - let preset = elevated_preset; - move |tx| { - otel.counter( - "codex.windows_sandbox.fallback_retry_elevated", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - profile_selection: elevated_profile_selection.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { + let mut items = vec![SelectionItem { + name: "Try setting up admin sandbox again".to_string(), + description: None, + actions: vec![Box::new({ + let otel = self.session_telemetry.clone(); + let preset = elevated_preset; + move |tx| { + otel.counter( + "codex.windows_sandbox.fallback_retry_elevated", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + profile_selection: elevated_profile_selection.clone(), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }]; + if allow_unelevated { + items.push(SelectionItem { name: "Use Codex with non-admin sandbox".to_string(), description: None, actions: vec![Box::new({ @@ -406,33 +464,41 @@ impl ChatWidget { })], dismiss_on_select: true, ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.fallback_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; + }); + } + items.push(SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.fallback_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }); self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), + on_cancel: setup_choice_is_required.then(|| { + Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { + preset: retry_preset.clone(), + profile_selection: retry_profile_selection.clone(), + }); + }) as _ + }), ..Default::default() }); } - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(test)))] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, _preset: ApprovalPreset, @@ -442,8 +508,11 @@ impl ChatWidget { #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let setup_is_required = windows_sandbox_level == WindowsSandboxLevel::Disabled + || self.elevated_windows_sandbox_setup_required(); if show_now - && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled + && setup_is_required && let Some(preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index b1f5608b8..a7a4ad0a4 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -718,6 +718,7 @@ mod tests { allow_managed_hooks_only: Some(true), allow_appshots: Some(false), computer_use: None, + windows: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -936,6 +937,7 @@ approval_policy = "never" allow_managed_hooks_only: None, allow_appshots: None, computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a90fd7a36..36b800145 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -11,6 +11,7 @@ use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; +#[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::session_resume::ResolveCwdOutcome; use crate::session_resume::resolve_cwd_for_resume_or_fork; @@ -51,6 +52,7 @@ use codex_login::enforce_login_restrictions; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; +#[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; use codex_rollout::StateDbHandle; use codex_rollout::state_db; @@ -1392,6 +1394,7 @@ async fn run_ratatui_app( let should_show_trust_screen_flag = !uses_remote_workspace && should_show_trust_screen(&initial_config); + #[cfg(target_os = "windows")] let mut trust_decision_was_made = false; let login_status = if initial_config.model_provider.requires_openai_auth { let Some(app_server) = app_server.as_mut() else { @@ -1437,7 +1440,10 @@ async fn run_ratatui_app( exit_reason: ExitReason::UserRequested, }); } - trust_decision_was_made = onboarding_result.directory_trust_persisted; + #[cfg(target_os = "windows")] + { + trust_decision_was_made = onboarding_result.directory_trust_persisted; + } // If this onboarding run included the login step, always refresh cloud requirements and // rebuild config. This avoids missing newly available cloud requirements due to login // status detection edge cases. @@ -1695,9 +1701,26 @@ async fn run_ratatui_app( set_default_client_residency_requirement(config.enforce_residency.value()); let should_show_trust_screen = should_show_trust_screen(&config); - let should_prompt_windows_sandbox_nux_at_startup = cfg!(target_os = "windows") - && trust_decision_was_made - && WindowsSandboxLevel::from_config(&config) == WindowsSandboxLevel::Disabled; + #[cfg(target_os = "windows")] + let windows_sandbox_level = WindowsSandboxLevel::from_config(&config); + #[cfg(target_os = "windows")] + let required_elevated_sandbox_needs_setup = windows_sandbox_level + == WindowsSandboxLevel::Elevated + && config + .config_layer_stack + .requirements() + .windows_sandbox_mode + .source + .is_some() + && !crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( + config.codex_home.as_path(), + ); + #[cfg(target_os = "windows")] + let should_prompt_windows_sandbox_nux_at_startup = (trust_decision_was_made + && windows_sandbox_level == WindowsSandboxLevel::Disabled) + || required_elevated_sandbox_needs_setup; + #[cfg(not(target_os = "windows"))] + let should_prompt_windows_sandbox_nux_at_startup = false; let Cli { prompt,