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,