Respect execution hosts in Windows sandbox setup (#44939)

## Why

The Windows TUI cannot configure a remote executor's sandbox. Required elevated sandbox setup also needs to reflect the local app server's readiness rather than the TUI's local setup files.

## What changed

- Restrict setup prompts and actions to local connections with local executors, including local daemon connections. Let remote servers own Agent permission selection, and warn when local and remote executors are configured together.
- Query `WindowsSandboxReadiness` at startup when elevated sandboxing is required locally, and track successful setup across chat widget replacement and reconnects.
- Restore pending initial input to the composer when mixed executors prevent required setup or a local connection goes offline.

## Testing

Add regression coverage for local, remote, and mixed host selection, remote Agent permission selection, pending input restoration with mixed executors, and setup state preservation during daemon reconnects.

GitOrigin-RevId: bd45b308ac3ae4c2489b0bd2ac5d7e12a7fc35d6
This commit is contained in:
Eric Traut
2026-09-11 23:27:14 +00:00
committed by copyberry
parent 2e572378f4
commit c210f4c222
17 changed files with 278 additions and 32 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<Instant>,
}
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,

View File

@@ -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 =

View File

@@ -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 { .. }

View File

@@ -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| {

View File

@@ -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);

View File

@@ -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<AppEvent>) {
let (app_event_tx, app_event_rx) = unbounded_channel();
(

View File

@@ -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<String>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
pub(crate) initial_user_message: Option<UserMessage>,
status_account_display: Option<StatusAccountDisplay>,
pub(crate) remote_connection: Option<RemoteConnectionStatus>,
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<TokenUsageInfo>,
token_usage_pending: bool,
// Status and polling use account usage reads; response streams may identify meters differently.

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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))]

View File

@@ -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)

View File

@@ -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,