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
This commit is contained in:
Eric Traut
2026-09-16 00:21:49 +00:00
committed by copyberry
parent 58e2e8cf3c
commit 7f501cd334
22 changed files with 336 additions and 3 deletions
@@ -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,
+1
View File
@@ -16,6 +16,7 @@ pub(crate) enum WindowsSandboxHost {
Local,
Mixed,
Remote,
Unknown,
}
#[derive(Default)]
+15 -1
View File
@@ -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<dyn HistoryCell> {
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,
@@ -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"))
+33
View File
@@ -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::<Vec<_>>()
});
assert_eq!(
crate::windows_sandbox::host_from_environments(environments.as_deref()),
expected
);
}
}
+1
View File
@@ -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,
+3
View File
@@ -1366,6 +1366,9 @@ impl App {
) -> Option<ThreadSessionState> {
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
@@ -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,
+7
View File
@@ -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<String>,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
@@ -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,
@@ -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(),
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+1
View File
@@ -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,
-1
View File
@@ -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;
+1
View File
@@ -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<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
+155 -1
View File
@@ -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<WindowsSandboxSetupMode>,
// None means policy has not been loaded; a loaded null list allows both modes.
pub(crate) requirements: Option<Option<Vec<WindowsSandboxSetupMode>>>,
}
#[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<Self> {
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;
+82
View File
@@ -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));
}