diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75bb355cf8..edc16444a7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -266,6 +266,7 @@ use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; +pub(crate) use self::platform_actions::WindowsSandboxHost; use self::platform_actions::*; use self::side::SideParentStatus; use self::side::SideParentStatusChange; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 9b49e67fb6..5fd3cd2c28 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -48,6 +48,28 @@ impl App { { return Ok(AppRunControl::Continue); } + if matches!( + &event, + AppEvent::OpenWindowsSandboxEnablePrompt { .. } + | AppEvent::OpenWindowsSandboxFallbackPrompt { .. } + | AppEvent::BeginWindowsSandboxElevatedSetup { .. } + | AppEvent::BeginWindowsSandboxLegacySetup { .. } + | AppEvent::EnableWindowsSandboxForAgentMode { .. } + ) && !self.windows_sandbox_setup_is_local() + { + if matches!( + &event, + AppEvent::OpenWindowsSandboxFallbackPrompt { .. } + | AppEvent::EnableWindowsSandboxForAgentMode { .. } + ) { + self.chat_widget.clear_windows_sandbox_setup_status(); + } + self.chat_widget.add_info_message( + "Windows sandbox setup requires local connections and executors.".to_string(), + /*hint*/ None, + ); + return Ok(AppRunControl::Continue); + } if self.chat_widget.has_misalignment_policy_violation() && matches!( event, @@ -2179,6 +2201,7 @@ impl App { ); return Ok(AppRunControl::Continue); } + self.chat_widget.windows_sandbox_elevated_setup_complete = elevated_enabled; let edits = crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled); match crate::config_update::write_config_batch( diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index 32931b4d8f..6ca93bc2d7 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -5,11 +5,73 @@ use super::*; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WindowsSandboxHost { + Local, + Mixed, + Remote, +} + #[derive(Default)] pub(super) struct WindowsSandboxState { pub(super) setup_started_at: Option, } +pub(super) fn windows_sandbox_host( + target: &AppServerTarget, + environment_manager: &EnvironmentManager, +) -> WindowsSandboxHost { + if target.uses_remote_workspace() { + WindowsSandboxHost::Remote + } else if environment_manager + .default_environment_ids() + .into_iter() + .filter_map(|id| environment_manager.get_environment(&id)) + .any(|environment| environment.is_remote()) + { + if environment_manager.try_local_environment().is_some() { + WindowsSandboxHost::Mixed + } else { + WindowsSandboxHost::Remote + } + } else { + WindowsSandboxHost::Local + } +} + +#[cfg(target_os = "windows")] +pub(super) async fn windows_sandbox_ready(app_server: &mut AppServerSession) -> bool { + let request_id = app_server.next_request_id(); + matches!( + tokio::time::timeout( + Duration::from_secs(5), + app_server + .request_handle() + .request_typed(ClientRequest::WindowsSandboxReadiness { + request_id, + params: None, + }), + ) + .await, + Ok(Ok( + codex_app_server_protocol::WindowsSandboxReadinessResponse { + status: codex_app_server_protocol::WindowsSandboxReadiness::Ready, + } + )) + ) +} + +impl App { + pub(super) fn windows_sandbox_host(&self) -> WindowsSandboxHost { + windows_sandbox_host(&self.app_server_target, self.environment_manager.as_ref()) + } + + /// A local app server owns setup for both embedded and daemon connections. + pub(super) fn windows_sandbox_setup_is_local(&self) -> bool { + self.windows_sandbox_host() == WindowsSandboxHost::Local + } +} + pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool { matches!( key_event, diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 2951be249e..e816683d58 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -175,6 +175,12 @@ impl App { return false; } if !self.reconnect.offline { + #[cfg(target_os = "windows")] + if self.windows_sandbox_setup_is_local() + && let Some(message) = self.chat_widget.initial_user_message.take() + { + self.chat_widget.restore_user_message_to_composer(message); + } self.reconnect.offline = true; self.reconnect.failed = false; if self.pending_server_version_notice.take().is_some() { @@ -259,6 +265,7 @@ impl App { self.environment_manager.as_ref(), ), ); + self.chat_widget.windows_sandbox_host = self.windows_sandbox_host(); self.chat_widget.cyber_policy_notice = Default::default(); self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; self.chat_widget.remote_connection = diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6165351cef..f1451569c0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -497,6 +497,12 @@ impl App { } chat_widget.remote_connection = self.chat_widget.remote_connection.clone(); chat_widget.set_local_worktree_operations(self.chat_widget.local_worktree_operations); + chat_widget.windows_sandbox_host = self.chat_widget.windows_sandbox_host; + #[cfg(any(target_os = "windows", test))] + { + chat_widget.windows_sandbox_elevated_setup_complete = + self.chat_widget.windows_sandbox_elevated_setup_complete; + } chat_widget.set_agents_navigation_enabled(matches!( self.app_server_target, AppServerTarget::LocalDaemon { .. } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index c5ec00043a..038925294c 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -682,9 +682,39 @@ impl App { AppServerTarget::LocalDaemon { .. } )); let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); - chat_widget - .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - + let windows_sandbox_host = + windows_sandbox_host(&app_server_target, environment_manager.as_ref()); + chat_widget.windows_sandbox_host = windows_sandbox_host; + let windows_sandbox_host_is_local = windows_sandbox_host == WindowsSandboxHost::Local; + #[cfg(target_os = "windows")] + let sandbox_ready = + if windows_sandbox_host_is_local && chat_widget.required_elevated_windows_sandbox() { + windows_sandbox_ready(&mut app_server).await + } else { + false + }; + #[cfg(not(target_os = "windows"))] + let sandbox_ready = false; + #[cfg(target_os = "windows")] + if sandbox_ready { + chat_widget.windows_sandbox_elevated_setup_complete = true; + } + chat_widget.maybe_prompt_windows_sandbox_enable( + should_prompt_windows_sandbox_nux_at_startup + && windows_sandbox_host_is_local + && !sandbox_ready, + ); + #[cfg(target_os = "windows")] + if windows_sandbox_host == WindowsSandboxHost::Mixed + && should_prompt_windows_sandbox_nux_at_startup + { + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::StartupWarningsCell::new(vec![ + "Windows sandbox setup is unavailable when local and remote executors are configured together." + .to_string(), + ]), + ))); + } let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| { diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 2d8ef54a58..b04091cb1b 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -170,6 +170,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv app.app_server_target = AppServerTarget::LocalDaemon { endpoint: endpoint.clone(), }; + if previous_thread.is_none() { + app.chat_widget.windows_sandbox_elevated_setup_complete = true; + } let available = Arc::new(std::sync::atomic::AtomicBool::new(false)); let server_available = Arc::clone(&available); let restored_previous = previous_thread @@ -314,6 +317,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv CODEX_CLI_VERSION, ) .await?; + if previous_thread.is_none() { + assert!(app.chat_widget.windows_sandbox_elevated_setup_complete); + } assert!(!app.reconnect.offline); assert_eq!(app.current_displayed_thread_id(), previous_thread); diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 9ef3aca8df..455128cf43 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -15,6 +15,54 @@ use pretty_assertions::assert_eq; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; +#[tokio::test] +async fn windows_sandbox_setup_uses_local_app_server_connection() { + let mut app = make_test_app().await; + assert!(app.windows_sandbox_setup_is_local()); + + let endpoint = crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }; + app.app_server_target = crate::AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }; + assert!(app.windows_sandbox_setup_is_local()); + + app.app_server_target = crate::AppServerTarget::Remote { endpoint }; + assert!(!app.windows_sandbox_setup_is_local()); +} + +#[tokio::test] +async fn windows_sandbox_setup_skips_remote_default_executor() -> Result<()> { + let mut app = make_test_app().await; + app.environment_manager = Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + Some(codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ) + .await, + ); + assert!(!app.windows_sandbox_setup_is_local()); + + app.environment_manager = Arc::new( + EnvironmentManager::create_for_tests_with_local( + Some("ws://127.0.0.1:8765".to_string()), + codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, + ) + .await, + ); + assert_eq!(app.windows_sandbox_host(), WindowsSandboxHost::Mixed); + assert!(!app.windows_sandbox_setup_is_local()); + Ok(()) +} + fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver) { let (app_event_tx, app_event_rx) = unbounded_channel(); ( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4e6a7dfbcd..4f91c11b20 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -255,8 +255,6 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::PermissionProfileSelection; use crate::app_event::RateLimitRefreshOrigin; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::auto_review_denials; use crate::auto_review_denials::RecentAutoReviewDenials; @@ -593,10 +591,13 @@ pub(crate) struct ChatWidget { model_popup_model_ids: Vec, session_telemetry: SessionTelemetry, session_header: SessionHeader, - initial_user_message: Option, + pub(crate) initial_user_message: Option, status_account_display: Option, pub(crate) remote_connection: Option, pub(crate) local_worktree_operations: bool, + pub(crate) windows_sandbox_host: crate::app::WindowsSandboxHost, + #[cfg(any(target_os = "windows", test))] + pub(crate) windows_sandbox_elevated_setup_complete: bool, token_info: Option, token_usage_pending: bool, // Status and polling use account usage reads; response streams may identify meters differently. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 5eaa8d7715..af8e867122 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -135,6 +135,9 @@ impl ChatWidget { status_account_display, remote_connection: None, local_worktree_operations: true, + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, + #[cfg(any(target_os = "windows", test))] + windows_sandbox_elevated_setup_complete: false, token_info: None, token_usage_pending: false, rate_limit_snapshots_by_limit_id: BTreeMap::new(), diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 74dd84314a..a15bb8e140 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -118,7 +118,9 @@ impl ChatWidget { return; } #[cfg(any(target_os = "windows", test))] - if self.elevated_windows_sandbox_setup_required() { + if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Local + && self.elevated_windows_sandbox_setup_required() + { return; } if let Some(draft) = pending_draft.take() { @@ -136,7 +138,18 @@ impl ChatWidget { return; } #[cfg(any(target_os = "windows", test))] - if self.elevated_windows_sandbox_setup_required() { + if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Local + && self.elevated_windows_sandbox_setup_required() + { + return; + } + #[cfg(any(target_os = "windows", test))] + if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Mixed + && self.elevated_windows_sandbox_setup_required() + { + if let Some(user_message) = self.initial_user_message.take() { + self.restore_user_message_to_composer(user_message); + } return; } if self.blocks_direct_input { diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index 77fe534ab8..9ccd41b427 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -323,6 +323,17 @@ impl ChatWidget { }; let requires_confirmation = approvals_reviewer == ApprovalsReviewer::User && preset.id == "full-access"; + #[cfg(target_os = "windows")] + if preset.id == "auto" && self.windows_sandbox_host == crate::app::WindowsSandboxHost::Mixed + { + let preset = preset.clone(); + return vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset.clone(), + profile_selection: profile_selection.clone(), + }); + })]; + } if requires_confirmation { let preset = preset.clone(); return vec![Box::new(move |tx| { @@ -336,21 +347,15 @@ impl ChatWidget { if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { #[cfg(target_os = "windows")] { + if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Remote { + // The remote server owns the permission choice. Its executor + // cannot be set up from this TUI's Windows account. + return apply_actions(); + } if crate::windows_sandbox::level_from_config(&self.config) == WindowsSandboxLevel::Disabled { let preset = preset.clone(); - if crate::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) { - return vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - profile_selection: profile_selection.clone(), - }); - })]; - } return vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset.clone(), diff --git a/codex-rs/tui/src/chatwidget/permission_shortcuts.rs b/codex-rs/tui/src/chatwidget/permission_shortcuts.rs index 879d89e1d4..8079f471c0 100644 --- a/codex-rs/tui/src/chatwidget/permission_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/permission_shortcuts.rs @@ -55,12 +55,13 @@ impl ChatWidget { { continue; } - // Agent mode still needs explicit Windows setup. + // These modes still need the explicit Windows setup/warning flow. #[cfg(target_os = "windows")] if preset.id == "auto" - && reviewer == ApprovalsReviewer::User - && crate::windows_sandbox::level_from_config(&self.config) - == WindowsSandboxLevel::Disabled + && (self.windows_sandbox_host == crate::app::WindowsSandboxHost::Mixed + || (reviewer == ApprovalsReviewer::User + && crate::windows_sandbox::level_from_config(&self.config) + == WindowsSandboxLevel::Disabled)) { continue; } diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index f0f087bb63..2ea2387d36 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -11,6 +11,31 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; +#[cfg(target_os = "windows")] +#[tokio::test] +async fn remote_windows_agent_permission_uses_server_selection() { + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("Agent preset"); + let (mut chat, mut events, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.windows_sandbox_host = crate::app::WindowsSandboxHost::Remote; + chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated); + let actions = chat.permission_mode_actions( + &preset, + "Agent".to_string(), + ApprovalsReviewer::User, + /*profile_selection*/ None, + /*return_to_permissions*/ false, + ); + actions[0](&chat.app_event_tx); + assert!(matches!(events.try_recv(), Ok(AppEvent::CodexOp(_)))); + assert!(matches!( + events.try_recv(), + Ok(AppEvent::UpdateAskForApprovalPolicy(_)) + )); +} + #[tokio::test] async fn permission_discovery_uses_server_catalog_for_remote_custom_selection() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -766,6 +791,22 @@ async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { ); } +#[tokio::test] +async fn mixed_executors_restore_required_sandbox_prompt_without_submitting() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.windows_sandbox_host = crate::app::WindowsSandboxHost::Mixed; + chat.config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Elevated); + chat.config.config_layer_stack = + windows_sandbox_requirements_stack(vec![WindowsSandboxModeToml::Elevated]); + chat.initial_user_message = + create_initial_user_message(Some("review this".to_string()), Vec::new(), Vec::new()); + + chat.submit_initial_user_message_if_pending(); + + assert_eq!(chat.composer_text_with_pending(), "review this"); + assert!(op_rx.try_recv().is_err()); +} + #[tokio::test] async fn windows_sandbox_required_fallback_prompt_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index a2c81ec667..fbe018fb48 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -14,7 +14,7 @@ impl ChatWidget { } #[cfg(any(target_os = "windows", test))] - pub(super) fn elevated_windows_sandbox_setup_required(&self) -> bool { + pub(crate) fn required_elevated_windows_sandbox(&self) -> bool { crate::windows_sandbox::level_from_config(&self.config) == WindowsSandboxLevel::Elevated && self .config @@ -23,7 +23,11 @@ impl ChatWidget { .windows_sandbox_mode .source .is_some() - && !crate::windows_sandbox::sandbox_setup_is_complete(self.config.codex_home.as_path()) + } + + #[cfg(any(target_os = "windows", test))] + pub(super) fn elevated_windows_sandbox_setup_required(&self) -> bool { + self.required_elevated_windows_sandbox() && !self.windows_sandbox_elevated_setup_complete } #[cfg(any(target_os = "windows", test))] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5e922fb8ae..6a0c65565e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1818,8 +1818,7 @@ async fn run_ratatui_app( .requirements() .windows_sandbox_mode .source - .is_some() - && !crate::windows_sandbox::sandbox_setup_is_complete(config.codex_home.as_path()); + .is_some(); #[cfg(target_os = "windows")] let should_prompt_windows_sandbox_nux_at_startup = (trust_decision_was_made && windows_sandbox_level == WindowsSandboxLevel::Disabled) diff --git a/codex-rs/tui/src/windows_sandbox.rs b/codex-rs/tui/src/windows_sandbox.rs index ef70225e21..153023a450 100644 --- a/codex-rs/tui/src/windows_sandbox.rs +++ b/codex-rs/tui/src/windows_sandbox.rs @@ -15,6 +15,7 @@ use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; #[cfg(target_os = "windows")] use std::collections::HashMap; +#[cfg(target_os = "windows")] use std::path::Path; pub(crate) fn level_from_config(config: &Config) -> WindowsSandboxLevel { @@ -34,11 +35,6 @@ pub(crate) fn level_from_config(config: &Config) -> WindowsSandboxLevel { #[cfg(target_os = "windows")] pub(crate) use codex_windows_sandbox::sandbox_setup_is_complete; -#[cfg(not(target_os = "windows"))] -pub(crate) fn sandbox_setup_is_complete(_codex_home: &Path) -> bool { - false -} - #[cfg(target_os = "windows")] pub(crate) fn prepare_elevated_sandbox( permission_profile: &PermissionProfile,