From 80623581502c4f322dd1906b2e600b25e642ce88 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 13 Jun 2026 22:40:10 +0000 Subject: [PATCH] exec-server: add hermetic Windows shell smoke coverage --- .../core/tests/remote_env_windows/BUILD.bazel | 11 +- .../non_native_cwd_tests.rs | 142 +++++++ .../remote_env_windows_test.rs | 332 ++++++++------- codex-rs/exec-server/testing/BUILD.bazel | 18 +- codex-rs/exec-server/testing/README.md | 7 +- .../testing/wine_exec_server_harness.rs | 386 ++++++++++++++++++ 6 files changed, 741 insertions(+), 155 deletions(-) create mode 100644 codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs create mode 100644 codex-rs/exec-server/testing/wine_exec_server_harness.rs diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index a620965c89c7..5feb1021fcea 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -2,20 +2,23 @@ load("//bazel/rules/testing:wine.bzl", "wine_rust_test") wine_rust_test( name = "smoke-test", - timeout = "short", - srcs = ["remote_env_windows_test.rs"], + timeout = "moderate", + srcs = [ + "non_native_cwd_tests.rs", + "remote_env_windows_test.rs", + ], crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", windows_binaries = { "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", }, deps = [ - "//bazel/rules/testing/wine:wine_test_support", "//codex-rs/core/tests/common", "//codex-rs/exec-server", + "//codex-rs/exec-server/testing:wine-exec-server-harness", "//codex-rs/features", "//codex-rs/protocol", - "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/path-uri", "@crates//:anyhow", "@crates//:pretty_assertions", "@crates//:serde_json", diff --git a/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs b/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs new file mode 100644 index 000000000000..0b5c0917b16c --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs @@ -0,0 +1,142 @@ +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_features::Feature; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use serde_json::json; +use wine_exec_server_harness::WineExecServerHarness; + +const CALL_ID: &str = "wine-cmd-smoke"; +const COMMAND: &str = "echo WINE_BAZEL_OK&&cd"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_exec_server_rejects_non_native_cwd_uri() -> Result<()> { + let (exec_server, exec_server_url) = WineExecServerHarness::builder().start().await?; + + exec_server + .scope(async move { + let server = start_mock_server().await; + let arguments = serde_json::to_string(&json!({ + "cmd": COMMAND, + "login": false, + "yield_time_ms": 5_000, + }))?; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(CALL_ID, "exec_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_model("gpt-5.2") + .with_exec_server_url(exec_server_url) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let environments = TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: test.config.cwd.clone(), + }], + ); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run the Windows smoke command".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(environments), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let mut saw_exec_event = false; + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { + saw_exec_event = true + } + EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { + saw_exec_event = true + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert!( + !saw_exec_event, + "a non-native cwd should be rejected before process lifecycle events", + ); + + let request = response_mock + .last_request() + .context("model should receive the rejected command output")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("rejected command output should be present")?; + let output = output.context("rejected command output should contain text")?; + assert!( + output.contains("exec-server rejected request (-32602)") + && output.contains("cwd URI") + && output.contains("is not valid on this exec-server host"), + "unexpected command output: {output:?}", + ); + assert_ne!(success, Some(true)); + + Ok(()) + }) + .await +} diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index b505227b6748..7484f17ab737 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -1,161 +1,201 @@ -//! Bazel-only integration coverage for a Windows exec-server running under Wine. +#[cfg(not(target_os = "linux"))] +compile_error!("the Wine exec-server test can only run on Linux"); + +#[path = "non_native_cwd_tests.rs"] +mod non_native_cwd_tests; + +use std::collections::HashMap; +use std::time::Duration; use anyhow::Context; use anyhow::Result; -use codex_exec_server::REMOTE_ENVIRONMENT_ID; -use codex_features::Feature; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_protocol::protocol::TurnEnvironmentSelections; -use codex_protocol::user_input::UserInput; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::sse; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; -use core_test_support::test_codex::turn_permission_fields; -use core_test_support::wait_for_event; -use serde_json::json; -use tokio::io::AsyncBufReadExt; -use tokio::io::BufReader; -use wine_test_support::WineTestCommand; +use codex_exec_server::ExecEnvPolicy; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecServerClient; +use codex_exec_server::ProcessId; +use codex_exec_server::ReadParams; +use codex_exec_server::RemoteExecServerConnectArgs; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; +use tokio::time::timeout; +use wine_exec_server_harness::POWERSHELL_PATH; +use wine_exec_server_harness::WineExecServerHarness; -const CALL_ID: &str = "wine-cmd-smoke"; -const COMMAND: &str = "echo WINE_BAZEL_OK&&cd"; +const TEST_TIMEOUT: Duration = Duration::from_secs(180); +const POWERSHELL_PREFLIGHT_MARKER: &str = "WINE_PWSH_PREFLIGHT"; +const WINDOWS_WORKSPACE: &str = r"C:\workspace"; +const POWERSHELL_PREFLIGHT_SCRIPT: &str = concat!( + "$ErrorActionPreference = 'Stop'; ", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ", + "Write-Output ('WINE_PWSH_PREFLIGHT|' + ", + "$PSVersionTable.PSVersion.ToString() + '|' + ", + "$PSVersionTable.PSEdition + '|' + ", + "$IsWindows.ToString().ToLowerInvariant() + '|' + ", + "(Get-Location).ProviderPath + '|' + $separatorCode)", +); + +struct CommandOutput { + exit_code: Option, + output: String, +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn windows_exec_server_rejects_non_native_cwd_uri() -> Result<()> { - let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; - let mut exec_server = WineTestCommand::new(executable) - .env("CODEX_HOME", r"C:\codex-home") - .spawn()?; - let stdout = exec_server.take_stdout(); +async fn windows_exec_server_runs_powershell_and_cmd_under_wine() -> Result<()> { + timeout(TEST_TIMEOUT, async { + let (server, websocket_url) = WineExecServerHarness::builder() + .workspace(WINDOWS_WORKSPACE) + .start() + .await?; + server.scope(exercise_exec_server(websocket_url)).await + }) + .await + .context("Wine exec-server test timed out")? +} - exec_server - .scope(async move { - let mut lines = BufReader::new(stdout).lines(); - let exec_server_url = loop { - let line = lines - .next_line() - .await? - .context("Wine exec-server exited before reporting its URL")?; - if line.starts_with("ws://") { - break line; - } - }; +async fn exercise_exec_server(websocket_url: String) -> Result<()> { + let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + websocket_url, + "wine-windows-bazel-test".to_string(), + )) + .await?; - let server = start_mock_server().await; - let arguments = serde_json::to_string(&json!({ - "cmd": COMMAND, - "login": false, - "yield_time_ms": 5_000, - }))?; - let response_mock = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(CALL_ID, "exec_command", &arguments), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ], - ) - .await; + let info = client.environment_info().await?; + assert_eq!(info.shell.name, "powershell"); + assert!( + info.shell.path.eq_ignore_ascii_case(POWERSHELL_PATH), + "expected pinned PowerShell path, got {info:?}", + ); - let mut builder = test_codex() - .with_model("gpt-5.2") - .with_exec_server_url(exec_server_url) - .with_config(|config| { - config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); - }); - let test = builder.build(&server).await?; - let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); - let environments = TurnEnvironmentSelections::new( - test.config.cwd.clone(), - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: test.config.cwd.clone(), - }], - ); + let powershell = run_non_tty_command( + &client, + "wine-pwsh-preflight", + vec![ + info.shell.path, + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + POWERSHELL_PREFLIGHT_SCRIPT.to_string(), + ], + PathUri::parse("file:///C:/workspace")?, + ) + .await?; + assert_eq!( + powershell.exit_code, + Some(0), + "unexpected PowerShell output: {:?}", + powershell.output + ); + let preflight = powershell + .output + .lines() + .find(|line| line.starts_with(POWERSHELL_PREFLIGHT_MARKER)) + .with_context(|| { + format!( + "PowerShell preflight marker was missing from {:?}", + powershell.output + ) + })?; + let fields = preflight.split('|').collect::>(); + anyhow::ensure!(fields.len() == 6, "unexpected PowerShell preflight: {preflight}"); + assert_eq!(fields[0], POWERSHELL_PREFLIGHT_MARKER); + assert_eq!( + fields[1].split('.').next(), + Some("7"), + "expected PowerShell 7.x, got {}", + fields[1], + ); + assert_eq!( + &fields[2..], + &["Core", "true", WINDOWS_WORKSPACE, "92"] + ); - test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "run the Windows smoke command".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - environments: Some(environments), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model: test.session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - }) - .await?; + let cmd = run_non_tty_command( + &client, + "wine-cmd-smoke", + vec![ + r"C:\windows\system32\cmd.exe".to_string(), + "/D".to_string(), + "/C".to_string(), + "echo WINE_BAZEL_OK&&cd".to_string(), + ], + PathUri::parse("file:///C:/workspace")?, + ) + .await?; + assert_eq!(cmd.exit_code, Some(0)); + assert!( + cmd.output.contains("WINE_BAZEL_OK"), + "unexpected output: {:?}", + cmd.output + ); + assert!( + cmd.output.contains(WINDOWS_WORKSPACE), + "unexpected output: {:?}", + cmd.output + ); - let mut saw_exec_event = false; - loop { - match wait_for_event(&test.codex, |_| true).await { - EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { - saw_exec_event = true - } - EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { - saw_exec_event = true - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } + Ok(()) +} - assert!( - !saw_exec_event, - "a non-native cwd should be rejected before process lifecycle events", - ); +async fn run_non_tty_command( + client: &ExecServerClient, + process_id: &str, + argv: Vec, + cwd: PathUri, +) -> Result { + let process_id = ProcessId::from(process_id); + let response = client + .exec(ExecParams { + process_id: process_id.clone(), + argv, + cwd, + env_policy: Some(ExecEnvPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ignore_default_excludes: true, + exclude: Vec::new(), + r#set: HashMap::new(), + include_only: Vec::new(), + }), + env: HashMap::from([ + ("DOTNET_CLI_TELEMETRY_OPTOUT".to_string(), "1".to_string()), + ("DOTNET_NOLOGO".to_string(), "1".to_string()), + ( + "POWERSHELL_TELEMETRY_OPTOUT".to_string(), + "1".to_string(), + ), + ("POWERSHELL_UPDATECHECK".to_string(), "Off".to_string()), + ]), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await?; + assert_eq!(response.process_id, process_id); - let request = response_mock - .last_request() - .context("model should receive the rejected command output")?; - let (output, success) = request - .function_call_output_content_and_success(CALL_ID) - .context("rejected command output should be present")?; - let output = output.context("rejected command output should contain text")?; - assert!( - output.contains("exec-server rejected request (-32602)") - && output.contains("cwd URI") - && output.contains("is not valid on this exec-server host"), - "unexpected command output: {output:?}", - ); - assert_ne!(success, Some(true)); + let mut after_seq = None; + let mut output = Vec::new(); + let exit_code = loop { + let response = client + .read(ReadParams { + process_id: process_id.clone(), + after_seq, + max_bytes: Some(1024 * 1024), + wait_ms: Some(5_000), + }) + .await?; + for chunk in response.chunks { + output.extend(chunk.chunk.into_inner()); + } + if response.closed { + break response.exit_code; + } + after_seq = response.next_seq.checked_sub(1); + }; - Ok(()) - }) - .await + Ok(CommandOutput { + exit_code, + output: String::from_utf8(output)?, + }) } diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel index ad0f5b855147..a6f3d27fdf5b 100644 --- a/codex-rs/exec-server/testing/BUILD.bazel +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -1,4 +1,20 @@ -load("@rules_rust//rust:defs.bzl", "rust_binary") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") + +rust_library( + name = "wine-exec-server-harness", + testonly = True, + srcs = ["wine_exec_server_harness.rs"], + crate_name = "wine_exec_server_harness", + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//codex-rs/core/tests/remote_env_windows:__pkg__"], + deps = [ + "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/pty", + "@crates//:anyhow", + "@crates//:tempfile", + "@crates//:tokio", + ], +) rust_binary( name = "windows-exec-server", diff --git a/codex-rs/exec-server/testing/README.md b/codex-rs/exec-server/testing/README.md index 77f8e990d430..f6d6486300a4 100644 --- a/codex-rs/exec-server/testing/README.md +++ b/codex-rs/exec-server/testing/README.md @@ -1,5 +1,4 @@ -# Windows exec-server fixture +# Exec-server test fixtures -This directory contains the small Windows exec-server binary used by -foreign-OS tests. It links only `codex-exec-server` because the full Codex -Windows graph does not yet cross-build with Bazel. +This package contains the minimal Windows exec-server binary used by the +cross-platform tests in `//codex-rs/core/tests/remote_env_windows`. diff --git a/codex-rs/exec-server/testing/wine_exec_server_harness.rs b/codex-rs/exec-server/testing/wine_exec_server_harness.rs new file mode 100644 index 000000000000..668849973421 --- /dev/null +++ b/codex-rs/exec-server/testing/wine_exec_server_harness.rs @@ -0,0 +1,386 @@ +use std::collections::HashMap; +use std::fs; +use std::future::Future; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tempfile::TempDir; +use tokio::process::Command; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const START_TIMEOUT: Duration = Duration::from_secs(30); +const BLOCKING_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// PowerShell's installation path inside prefixes prepared by the harness. +pub const POWERSHELL_PATH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe"; + +/// Runs the Windows exec-server fixture in an isolated Wine prefix. +pub struct WineExecServerHarness { + processes: Option, +} + +/// Configures a Wine-backed Windows exec-server fixture. +pub struct WineExecServerHarnessBuilder { + powershell_runtime_dir: Option, + workspace: Option, +} + +struct WineProcesses { + cleanup_complete: bool, + exit_rx: Option>, + prefix: TempDir, + process: ProcessHandle, + stderr_task: JoinHandle<()>, + stdout_task: JoinHandle<()>, + wineserver: PathBuf, +} + +impl WineExecServerHarness { + /// Creates a builder for an isolated fixture. + pub fn builder() -> WineExecServerHarnessBuilder { + WineExecServerHarnessBuilder { + powershell_runtime_dir: None, + workspace: None, + } + } +} + +impl WineExecServerHarnessBuilder { + /// Overrides the pinned PowerShell runtime directory supplied by Bazel. + #[must_use] + pub fn powershell_runtime_dir(mut self, runtime_dir: impl Into) -> Self { + self.powershell_runtime_dir = Some(runtime_dir.into()); + self + } + + /// Creates `workspace` inside the isolated Wine prefix before startup. + #[must_use] + pub fn workspace(mut self, workspace: impl Into) -> Self { + self.workspace = Some(workspace.into()); + self + } + + /// Starts the configured fixture. + pub async fn start(self) -> Result<(WineExecServerHarness, String)> { + let prefix = TempDir::new().context("create Wine prefix")?; + let powershell_runtime = match self.powershell_runtime_dir { + Some(runtime_dir) => runtime_dir, + None => codex_utils_cargo_bin::cargo_bin("pwsh-runtime-marker")? + .parent() + .context("locate PowerShell runtime directory")? + .to_path_buf(), + }; + install_powershell_runtime(prefix.path(), &powershell_runtime)?; + if let Some(workspace) = self.workspace.as_deref() { + create_windows_directory(prefix.path(), workspace)?; + } + + let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; + let wine = codex_utils_cargo_bin::cargo_bin("wine")?; + let wine_runtime_marker = codex_utils_cargo_bin::cargo_bin("wine-runtime-marker")?; + let wine_dll_path = wine_runtime_marker + .parent() + .context("locate Wine runtime directory")?; + let wineserver = codex_utils_cargo_bin::cargo_bin("wineserver")?; + let mut env = std::env::vars().collect::>(); + env.remove("DISPLAY"); + env.extend([ + ("HOME".to_string(), prefix.path().to_string_lossy().into_owned()), + ( + "XDG_RUNTIME_DIR".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ("WINEARCH".to_string(), "win64".to_string()), + ( + "WINEPREFIX".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ( + "WINEDLLPATH".to_string(), + wine_dll_path.to_string_lossy().into_owned(), + ), + ( + "WINESERVER".to_string(), + wineserver.to_string_lossy().into_owned(), + ), + ("WINEDEBUG".to_string(), "-all".to_string()), + ( + "WINEDLLOVERRIDES".to_string(), + "mscoree,mshtml,winegstreamer=".to_string(), + ), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), + ("TEMP".to_string(), r"C:\windows\temp".to_string()), + ("TMP".to_string(), r"C:\windows\temp".to_string()), + ("CODEX_HOME".to_string(), r"C:\codex-home".to_string()), + ("DOTNET_CLI_TELEMETRY_OPTOUT".to_string(), "1".to_string()), + ("DOTNET_NOLOGO".to_string(), "1".to_string()), + ( + "POWERSHELL_TELEMETRY_OPTOUT".to_string(), + "1".to_string(), + ), + ("POWERSHELL_UPDATECHECK".to_string(), "Off".to_string()), + ]); + let wine = wine.to_string_lossy().into_owned(); + let executable = executable.to_string_lossy().into_owned(); + let SpawnedProcess { + session: process, + mut stdout_rx, + mut stderr_rx, + exit_rx, + } = codex_utils_pty::spawn_pty_process( + &wine, + &[executable], + prefix.path(), + &env, + /*arg0*/ &None, + TerminalSize::default(), + ) + .await + .context("start Windows exec-server under Wine")?; + let stderr_task = tokio::spawn(async move { + while let Some(chunk) = stderr_rx.recv().await { + let _ = std::io::stderr().lock().write_all(&chunk); + } + }); + let websocket_url_result = timeout(START_TIMEOUT, async { + let mut output = Vec::new(); + loop { + let chunk = stdout_rx + .recv() + .await + .context("Wine exec-server exited before reporting its URL")?; + output.extend_from_slice(&chunk); + if output.len() > 64 * 1024 { + output.drain(..output.len() - 64 * 1024); + } + let rendered = String::from_utf8_lossy(&output); + if let Some(start) = rendered.find("ws://") { + let url = &rendered[start..]; + let end = url.find(char::is_whitespace).unwrap_or(url.len()); + return Ok::<_, anyhow::Error>(url[..end].to_string()); + } + } + }) + .await + .context("Wine exec-server startup timed out") + .and_then(std::convert::identity); + let stdout_task = tokio::spawn(async move { while stdout_rx.recv().await.is_some() {} }); + let server = WineExecServerHarness { + processes: Some(WineProcesses { + cleanup_complete: false, + exit_rx: Some(exit_rx), + prefix, + process, + stderr_task, + stdout_task, + wineserver, + }), + }; + + match websocket_url_result { + Ok(websocket_url) => Ok((server, websocket_url)), + Err(start_error) => { + if let Err(shutdown_error) = server.shutdown().await { + return Err(start_error.context(format!( + "Wine cleanup after startup failure also failed: {shutdown_error:#}" + ))); + } + Err(start_error) + } + } + } + +} + +impl WineExecServerHarness { + /// Runs an operation and then performs bounded asynchronous teardown. + pub async fn scope(self, future: impl Future>) -> Result { + let scope_result = future.await; + let shutdown_result = self.shutdown().await; + match (scope_result, shutdown_result) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(error), Err(shutdown_error)) => { + Err(error.context(format!("Wine teardown also failed: {shutdown_error:#}"))) + } + } + } + + /// Stops the exec-server process and its isolated wineserver. + pub async fn shutdown(mut self) -> Result<()> { + let result = self + .processes + .as_mut() + .context("Wine process guard is missing")? + .shutdown() + .await; + self.processes.take(); + result + } +} + +impl Drop for WineExecServerHarness { + fn drop(&mut self) { + if self.processes.is_some() && !std::thread::panicking() { + panic!("WineExecServerHarness dropped without calling async shutdown"); + } + } +} + +fn create_windows_directory(prefix: &Path, windows_path: &str) -> Result<()> { + let relative = windows_path + .strip_prefix(r"C:\") + .context("Wine test workspace must be an absolute C: path")?; + let host_path = relative + .split('\\') + .fold(prefix.join("drive_c"), |path, segment| path.join(segment)); + std::fs::create_dir_all(&host_path) + .with_context(|| format!("create Wine test workspace {}", host_path.display())) +} + +fn install_powershell_runtime(prefix: &Path, runtime: &Path) -> Result<()> { + let destination = prefix + .join("drive_c") + .join("Program Files") + .join("PowerShell") + .join("7"); + materialize_runtime_directory(runtime, &destination) +} + +fn materialize_runtime_directory(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| { + format!( + "create PowerShell runtime directory {}", + destination.display() + ) + })?; + for entry in fs::read_dir(source) + .with_context(|| format!("read PowerShell runtime directory {}", source.display()))? + { + let entry = entry.context("read PowerShell runtime entry")?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + let file_type = entry + .file_type() + .with_context(|| format!("inspect PowerShell runtime entry {}", source_path.display()))?; + if file_type.is_dir() { + materialize_runtime_directory(&source_path, &destination_path)?; + } else if file_type.is_file() { + if fs::hard_link(&source_path, &destination_path).is_err() { + fs::copy(&source_path, &destination_path).with_context(|| { + format!( + "copy PowerShell runtime file {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + } + } else { + anyhow::bail!( + "unsupported PowerShell runtime entry type at {}", + source_path.display() + ); + } + } + Ok(()) +} + +impl WineProcesses { + async fn shutdown(&mut self) -> Result<()> { + self.process.request_terminate(); + let wait_result = async { + let exit_rx = self + .exit_rx + .take() + .context("Wine process exit receiver is missing")?; + timeout(START_TIMEOUT, exit_rx) + .await + .context("wait for Windows exec-server process timed out")? + .context("wait for Windows exec-server process")?; + Ok::<_, anyhow::Error>(()) + } + .await; + self.stderr_task.abort(); + self.stdout_task.abort(); + let wineserver_result = timeout(START_TIMEOUT, async { + let status = Command::new(&self.wineserver) + .args(["-k", "-w"]) + .env("HOME", self.prefix.path()) + .env("WINEPREFIX", self.prefix.path()) + .env("XDG_RUNTIME_DIR", self.prefix.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .context("stop isolated wineserver")?; + anyhow::ensure!(status.success(), "wineserver exited with {status}"); + Ok::<_, anyhow::Error>(()) + }) + .await + .context("stop isolated wineserver timed out") + .and_then(std::convert::identity); + + let result = wait_result.and(wineserver_result); + if result.is_ok() { + self.cleanup_complete = true; + } else { + self.shutdown_blocking(); + } + result + } + + fn shutdown_blocking(&mut self) { + self.stderr_task.abort(); + self.stdout_task.abort(); + self.process.terminate(); + let Ok(mut child) = std::process::Command::new(&self.wineserver) + .arg("-k") + .env("HOME", self.prefix.path()) + .env("WINEPREFIX", self.prefix.path()) + .env("XDG_RUNTIME_DIR", self.prefix.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + self.cleanup_complete = true; + return; + }; + let deadline = Instant::now() + BLOCKING_CLEANUP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break; + } + } + } + self.cleanup_complete = true; + } +} + +impl Drop for WineProcesses { + fn drop(&mut self) { + if !self.cleanup_complete && std::thread::panicking() { + self.shutdown_blocking(); + } + } +}