From 7f501cd334af76dea303980a500c3df2003b1dd7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 16 Sep 2026 00:11:50 +0000 Subject: [PATCH] Track Windows sandbox policy and per-thread executor hosts in the TUI (#45813) ## What changed - Add `WindowsSandboxConfig` helpers to read app-server configuration and managed requirements, preserve legacy feature flag fallbacks, and select an allowed setup mode with elevated mode preferred when the configured mode is disallowed. - Store the sandbox host classification in thread session state using reported environments across start, resume, fork, and replay paths. Classify missing or empty environments as `Unknown`, and distinguish local, remote, and mixed selections. ## Testing Add coverage for managed setup restrictions, legacy configuration precedence, unknown policy state, environment classification, and remote host state in inactive-thread replay. GitOrigin-RevId: ae8920d9c2a1c80b641ac90617ae607b171bd55a --- codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/platform_actions.rs | 1 + codex-rs/tui/src/app/tests.rs | 16 +- .../src/app/tests/advanced_reasoning_tests.rs | 3 + codex-rs/tui/src/app/tests/startup.rs | 33 ++++ codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_routing.rs | 3 + codex-rs/tui/src/app/thread_session_state.rs | 4 + codex-rs/tui/src/app_server_session.rs | 7 + .../src/app_server_session/rollout_history.rs | 1 + .../tui/src/chatwidget/tests/app_server.rs | 1 + .../chatwidget/tests/composer_submission.rs | 9 + .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 11 ++ .../tui/src/chatwidget/tests/permissions.rs | 3 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/history_cell/tests.rs | 1 + codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/session_state.rs | 1 + codex-rs/tui/src/windows_sandbox.rs | 156 +++++++++++++++++- codex-rs/tui/src/windows_sandbox_tests.rs | 82 +++++++++ 22 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/windows_sandbox_tests.rs diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 47d9066d48..b7344b7da6 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -1775,6 +1775,7 @@ enabled = false app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index 69d42904d8..1f157ae291 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -16,6 +16,7 @@ pub(crate) enum WindowsSandboxHost { Local, Mixed, Remote, + Unknown, } #[derive(Default)] diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index cf696d6d93..2d0548dff8 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4403,6 +4403,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let primary_cwd = test_path_buf("/tmp/main").abs(); let shared_root = test_path_buf("/tmp/shared").abs(); let primary_session = ThreadSessionState { + windows_sandbox_host: WindowsSandboxHost::Local, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()], @@ -4427,7 +4428,11 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { originator: None, - environments: None, + environments: Some(vec![codex_app_server_protocol::ThreadEnvironment { + environment_id: "remote".to_string(), + cwd: test_path_buf("/tmp/agent").abs().into(), + runtime_workspace_roots: Vec::new(), + }]), id: agent_thread_id.to_string(), extra: None, session_id: agent_thread_id.to_string(), @@ -4474,6 +4479,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re drop(store); assert_eq!(session.thread_id, agent_thread_id); + assert_eq!(session.windows_sandbox_host, WindowsSandboxHost::Remote); assert_eq!(session.thread_name, Some("agent thread".to_string())); assert_eq!(session.model, "gpt-agent"); assert_eq!(session.model_provider_id, "agent-provider"); @@ -5083,6 +5089,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() { let side_thread_id = ThreadId::new(); let mut store = ThreadEventStore::new(/*capacity*/ 4); let session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, forked_from_id: Some(parent_thread_id), fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) @@ -5150,6 +5157,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() { let snapshot = ThreadEventSnapshot { delegated_turns: Vec::new(), session: Some(ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, forked_from_id: Some(parent_thread_id), fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) @@ -5731,6 +5739,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { }; let make_header = |is_first| -> Arc { let session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -6277,6 +6286,7 @@ async fn replace_goal_confirmation_snapshot() { fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -7314,6 +7324,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let make_header = |is_first| { let session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -7386,6 +7397,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let base_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: base_id, forked_from_id: None, fork_parent_title: None, @@ -8545,6 +8557,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { let thread_id = ThreadId::new(); let event = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -9279,6 +9292,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { let thread_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs index 1f60e913f8..2e82c78975 100644 --- a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -21,6 +21,7 @@ async fn fork_current_session_preserves_conversation_ultra() -> Result<()> { .expect("create source rollout"), )?; app.chat_widget.handle_thread_session(ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, model: "gpt-5.4".to_string(), reasoning_effort: Some(ReasoningEffortConfig::Ultra), ..test_thread_session(source_thread_id, test_path_buf("/tmp/project")) @@ -53,11 +54,13 @@ async fn switching_from_ultra_thread_restores_configured_plan_effort() { app.chat_widget .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let ultra_session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, model: "gpt-5.4".to_string(), reasoning_effort: Some(ReasoningEffortConfig::Ultra), ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/ultra")) }; let normal_session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, model: "gpt-5.4".to_string(), reasoning_effort: Some(ReasoningEffortConfig::Medium), ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/normal")) diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index a3cb1093aa..8398ae08b5 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1633,3 +1633,36 @@ async fn external_writer_startup_keeps_initial_prompt_as_draft() -> Result<()> { })); Ok(()) } + +#[test] +fn windows_sandbox_host_from_observed_environments() { + for (ids, expected) in [ + (None, WindowsSandboxHost::Unknown), + (Some(vec![]), WindowsSandboxHost::Unknown), + ( + Some(vec![codex_exec_server::LOCAL_ENVIRONMENT_ID]), + WindowsSandboxHost::Local, + ), + (Some(vec!["remote"]), WindowsSandboxHost::Remote), + ( + Some(vec!["remote", codex_exec_server::LOCAL_ENVIRONMENT_ID]), + WindowsSandboxHost::Mixed, + ), + ] { + let environments = ids.map(|ids| { + ids.into_iter() + .map(|id| codex_app_server_protocol::ThreadEnvironment { + environment_id: id.to_string(), + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from(std::env::temp_dir()) + .unwrap() + .into(), + runtime_workspace_roots: Vec::new(), + }) + .collect::>() + }); + assert_eq!( + crate::windows_sandbox::host_from_environments(environments.as_deref()), + expected + ); + } +} diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index f0d15b2aeb..b7d34f637f 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -661,6 +661,7 @@ mod tests { fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 967649b46e..6f2a9ab99b 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1366,6 +1366,9 @@ impl App { ) -> Option { let mut session = self.primary_session_configured.clone()?; session.thread_id = thread_id; + session.windows_sandbox_host = crate::windows_sandbox::host_from_environments( + notification.thread.environments.as_deref(), + ); session.thread_name = notification.thread.name.clone(); session.model_provider_id = notification.thread.model_provider.clone(); session diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 8ec8f85727..fe67bd4070 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -87,6 +87,7 @@ impl App { session } else { ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -111,6 +112,8 @@ impl App { rollout_path: thread.path.clone(), } }; + session.windows_sandbox_host = + crate::windows_sandbox::host_from_environments(thread.environments.as_deref()); session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); @@ -169,6 +172,7 @@ mod tests { fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a50000f024..581d758a07 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2301,6 +2301,7 @@ async fn thread_session_state_from_thread_start_response( ); thread_session_state_from_thread_response( &response.thread.id, + crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()), response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), @@ -2344,6 +2345,7 @@ async fn thread_session_state_from_thread_resume_response( }; let mut session = thread_session_state_from_thread_response( &response.thread.id, + crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()), response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), @@ -2380,6 +2382,7 @@ async fn thread_session_state_from_thread_fork_response( ); thread_session_state_from_thread_response( &response.thread.id, + crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()), response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), @@ -2431,6 +2434,7 @@ fn display_permission_profile_from_thread_response( )] async fn thread_session_state_from_thread_response( thread_id: &str, + windows_sandbox_host: crate::app::WindowsSandboxHost, forked_from_id: Option, thread_name: Option, rollout_path: Option, @@ -2461,6 +2465,7 @@ async fn thread_session_state_from_thread_response( ); let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { + windows_sandbox_host, thread_id, forked_from_id, fork_parent_title: None, @@ -4152,6 +4157,7 @@ mod tests { let session = thread_session_state_from_thread_response( &thread_id.to_string(), + crate::app::WindowsSandboxHost::Local, /*forked_from_id*/ None, Some("restore".to_string()), /*rollout_path*/ None, @@ -4188,6 +4194,7 @@ mod tests { let session = thread_session_state_from_thread_response( &thread_id.to_string(), + crate::app::WindowsSandboxHost::Local, Some(forked_from_id.to_string()), Some("restore".to_string()), /*rollout_path*/ None, diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index 4d0d411b7b..4d432cd8df 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -41,6 +41,7 @@ impl AppServerSession { .await?; let session = thread_session_state_from_thread_response( &thread.id, + crate::windows_sandbox::host_from_environments(thread.environments.as_deref()), thread.forked_from_id.clone(), thread.name.clone(), thread.path.clone(), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index edace675b9..c807212042 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -44,6 +44,7 @@ fn thread_settings_for_test( fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 1d1db72362..5566010a4a 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -384,6 +384,7 @@ async fn submission_preserves_text_elements_and_local_images() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -493,6 +494,7 @@ async fn submission_includes_configured_active_permission_profile() { }; let expected_active_permission_profile = ActivePermissionProfile::new("custom"); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -548,6 +550,7 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { file_system: ManagedFileSystemPermissions::Unrestricted, }; let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -593,6 +596,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -693,6 +697,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -762,6 +767,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -803,6 +809,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -844,6 +851,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -888,6 +896,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index c8cdfe9248..498f84b607 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1299,6 +1299,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8765054da2..b4582414e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -19,6 +19,7 @@ async fn resumed_initial_messages_render_history() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -133,6 +134,7 @@ async fn restored_conversation_ultra_remains_selected_after_switching_to_plan() chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -420,6 +422,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -492,6 +495,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -598,6 +602,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .expect("permission profile should project to legacy sandbox policy"); let expected_sandbox = SandboxPolicy::from(expected_core_sandbox); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -671,6 +676,7 @@ async fn session_configured_preserves_profile_workspace_roots() { let session_permission_profile = PermissionProfile::workspace_write() .materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -718,6 +724,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { network_access: NetworkAccess::Restricted, }; let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -759,6 +766,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -819,6 +827,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -1116,6 +1125,7 @@ async fn replayed_reasoning_item_preserves_summary_parts_and_hides_raw_reasoning let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = false; chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -1167,6 +1177,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = true; chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 2ea2387d36..357b6fc832 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -745,6 +745,7 @@ async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { create_initial_user_message(Some(initial_prompt.clone()), Vec::new(), Vec::new()); chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -1168,6 +1169,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure .set_enabled(Feature::GuardianApproval, /*enabled*/ true); chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, @@ -1216,6 +1218,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w let permission_profile = app_server_workspace_write_profile(extra_root); chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index bb4904c913..0deccaa8df 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -15,6 +15,7 @@ fn paste_hidden_plan_shell_payload(chat: &mut ChatWidget) -> String { fn plan_test_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, @@ -1249,6 +1250,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 6f5e038a32..34204addcd 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -4321,6 +4321,7 @@ async fn session_configured_clears_goal_status_footer() { let rollout_file = NamedTempFile::new().unwrap(); chat.handle_thread_session(crate::session_state::ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 50a35b12d8..8385516759 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -596,6 +596,7 @@ fn image_generation_call_renders_saved_path() { fn session_configured_event(model: &str) -> ThreadSessionState { ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 20989980ad..121a8542b9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -236,7 +236,6 @@ mod updates_cache; mod version; mod vim_search; mod width; -#[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; mod workspace_messages; diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 059e148a79..785cd195b8 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -28,6 +28,7 @@ pub(crate) struct MessageHistoryMetadata { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadSessionState { + pub(crate) windows_sandbox_host: crate::app::WindowsSandboxHost, pub(crate) thread_id: ThreadId, pub(crate) forked_from_id: Option, pub(crate) fork_parent_title: Option, diff --git a/codex-rs/tui/src/windows_sandbox.rs b/codex-rs/tui/src/windows_sandbox.rs index d8976efbfb..15264028ec 100644 --- a/codex-rs/tui/src/windows_sandbox.rs +++ b/codex-rs/tui/src/windows_sandbox.rs @@ -1,10 +1,138 @@ -//! Windows sandbox display state derived from configuration. +//! Windows sandbox configuration, managed requirements, and executor selection for the TUI. use crate::legacy_core::config::Config; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WindowsSandboxImplementation; +use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; use codex_protocol::config_types::WindowsSandboxLevel; +use uuid::Uuid; +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct WindowsSandboxConfig { + pub(crate) mode: Option, + // None means policy has not been loaded; a loaded null list allows both modes. + pub(crate) requirements: Option>>, +} + +#[allow(dead_code)] +impl WindowsSandboxConfig { + pub(crate) fn from_responses( + config: &ConfigReadResponse, + requirements: ConfigRequirementsReadResponse, + ) -> Self { + let mut state = Self { + mode: config + .config + .additional + .get("windows") + .and_then(|windows| windows.get("sandbox")) + .and_then(|mode| serde_json::from_value(mode.clone()).ok()) + .or_else(|| { + let features = config.config.additional.get("features")?; + [ + ( + "elevated_windows_sandbox", + WindowsSandboxSetupMode::Elevated, + ), + ( + "experimental_windows_sandbox", + WindowsSandboxSetupMode::Unelevated, + ), + ( + "enable_experimental_windows_sandbox", + WindowsSandboxSetupMode::Unelevated, + ), + ] + .into_iter() + .find_map(|(key, mode)| { + (features.get(key).and_then(serde_json::Value::as_bool) == Some(true)) + .then_some(mode) + }) + }), + requirements: Some( + requirements + .requirements + .and_then(|requirements| requirements.allowed_windows_sandbox_implementations) + .map(|allowed| { + allowed + .into_iter() + .filter_map(|implementation| match implementation { + WindowsSandboxImplementation::Elevated => { + Some(WindowsSandboxSetupMode::Elevated) + } + WindowsSandboxImplementation::Unelevated => { + Some(WindowsSandboxSetupMode::Unelevated) + } + WindowsSandboxImplementation::Mxc => None, + }) + .collect() + }), + ), + }; + if let Some(Some(allowed)) = &state.requirements + && !state.mode.is_some_and(|mode| allowed.contains(&mode)) + { + // Managed requirements prefer elevated when the configured value is disallowed. + state.mode = [ + WindowsSandboxSetupMode::Elevated, + WindowsSandboxSetupMode::Unelevated, + ] + .into_iter() + .find(|mode| allowed.contains(mode)); + } + state + } + + pub(crate) fn level(&self) -> WindowsSandboxLevel { + match self.mode { + Some(WindowsSandboxSetupMode::Elevated) => WindowsSandboxLevel::Elevated, + Some(WindowsSandboxSetupMode::Unelevated) => WindowsSandboxLevel::RestrictedToken, + None => WindowsSandboxLevel::Disabled, + } + } + + pub(crate) fn allows(&self, mode: WindowsSandboxSetupMode) -> bool { + match &self.requirements { + Some(Some(allowed)) => allowed.contains(&mode), + Some(None) => true, + None => false, + } + } + + pub(crate) fn requires_elevated(&self) -> bool { + self.mode == Some(WindowsSandboxSetupMode::Elevated) + && matches!(self.requirements, Some(Some(_))) + } + + pub(crate) async fn read( + handle: AppServerRequestHandle, + cwd: String, + ) -> color_eyre::Result { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let config = crate::config_update::read_effective_config(handle.clone(), cwd).await?; + let requirements = handle + .request_typed(ClientRequest::ConfigRequirementsRead { + request_id: RequestId::String(format!( + "tui-windows-sandbox-requirements-{}", + Uuid::new_v4() + )), + params: None, + }) + .await?; + Ok(Self::from_responses(&config, requirements)) + }) + .await? + } +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn level_from_config(config: &Config) -> WindowsSandboxLevel { match config.permissions.windows_sandbox_mode { Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, @@ -18,3 +146,29 @@ pub(crate) fn level_from_config(config: &Config) -> WindowsSandboxLevel { None => WindowsSandboxLevel::Disabled, } } + +/// A server-local connection can still select remote executors. Missing selections are unknown. +pub(crate) fn host_from_environments( + environments: Option<&[codex_app_server_protocol::ThreadEnvironment]>, +) -> crate::app::WindowsSandboxHost { + use crate::app::WindowsSandboxHost; + let Some(environments) = environments.filter(|environments| !environments.is_empty()) else { + return WindowsSandboxHost::Unknown; + }; + let local = environments + .iter() + .any(|environment| environment.environment_id == codex_exec_server::LOCAL_ENVIRONMENT_ID); + let remote = environments + .iter() + .any(|environment| environment.environment_id != codex_exec_server::LOCAL_ENVIRONMENT_ID); + match (local, remote) { + (true, false) => WindowsSandboxHost::Local, + (true, true) => WindowsSandboxHost::Mixed, + (false, true) => WindowsSandboxHost::Remote, + (false, false) => WindowsSandboxHost::Unknown, + } +} + +#[cfg(test)] +#[path = "windows_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/windows_sandbox_tests.rs b/codex-rs/tui/src/windows_sandbox_tests.rs new file mode 100644 index 0000000000..e949094dd3 --- /dev/null +++ b/codex-rs/tui/src/windows_sandbox_tests.rs @@ -0,0 +1,82 @@ +use super::*; +use codex_app_server_protocol::WindowsSandboxSetupMode::Elevated; +use codex_app_server_protocol::WindowsSandboxSetupMode::Unelevated; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn windows_sandbox_server_policy_controls_setup_choices() { + for (configured, allowed, mode, expected) in [ + ( + Some("unelevated"), + serde_json::Value::Null, + Some(Unelevated), + (true, true, false), + ), + ( + Some("unelevated"), + json!(["elevated"]), + Some(Elevated), + (true, false, true), + ), + ( + None, + json!(["unelevated", "elevated"]), + Some(Elevated), + (true, true, true), + ), + ( + None, + json!(["unelevated"]), + Some(Unelevated), + (false, true, false), + ), + (Some("unelevated"), json!([]), None, (false, false, false)), + ( + Some("unelevated"), + json!(["mxc"]), + None, + (false, false, false), + ), + ] { + let config = serde_json::from_value(json!({ + "config": {"windows": {"sandbox": configured}}, "origins": {} + })) + .unwrap(); + let requirements = serde_json::from_value(json!({ + "requirements": {"allowedWindowsSandboxImplementations": allowed} + })) + .unwrap(); + let state = WindowsSandboxConfig::from_responses(&config, requirements); + assert_eq!(state.mode, mode); + assert_eq!( + ( + state.allows(Elevated), + state.allows(Unelevated), + state.requires_elevated() + ), + expected + ); + } + for (key, mode) in [ + ("elevated_windows_sandbox", Elevated), + ("experimental_windows_sandbox", Unelevated), + ("enable_experimental_windows_sandbox", Unelevated), + ] { + for (explicit, expected) in [(None, mode), (Some("elevated"), Elevated)] { + let config = serde_json::from_value(json!({ + "config": {"windows": {"sandbox": explicit}, "features": {key: true}}, + "origins": {} + })) + .unwrap(); + let requirements = serde_json::from_value(json!({"requirements": null})).unwrap(); + assert_eq!( + WindowsSandboxConfig::from_responses(&config, requirements).mode, + Some(expected) + ); + } + } + let unknown = WindowsSandboxConfig::default(); + assert!(!unknown.allows(Elevated)); + assert!(!unknown.allows(Unelevated)); +}