exec-server: add hermetic Windows shell smoke coverage

This commit is contained in:
Adam Perry
2026-06-13 06:26:56 +00:00
parent 4e39c1cd20
commit 7e93c916da
6 changed files with 488 additions and 218 deletions
@@ -2,27 +2,27 @@ 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 = [
"remote_env_windows_test.rs",
"wine_exec_server_harness.rs",
],
crate_name = "remote_env_windows_test",
crate_root = "remote_env_windows_test.rs",
# Environment identity validation ends this fixture's host-shell-mismatch premise.
# A hermetic target-shell smoke test replaces it once remote shell routing lands.
target_compatible_with = ["@platforms//:incompatible"],
include_powershell = True,
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/features",
"//codex-rs/protocol",
"//codex-rs/utils/cargo-bin",
"//codex-rs/utils/path-uri",
"//codex-rs/utils/pty",
"@crates//:anyhow",
"@crates//:pretty_assertions",
"@crates//:serde_json",
"@crates//:tempfile",
"@crates//:tokio",
],
)
@@ -1,8 +1,11 @@
# Windows remote-environment test
# Windows remote-environment tests
This Bazel-only `test_codex` integration test runs a Windows exec-server fixture
under pinned Wine and exercises the normal model tool-call and remote-execution
path.
This directory cross-builds a small Windows exec-server fixture and runs it
under a pinned Wine runtime from x86-64 Linux Bazel. The Windows fixture links
only `codex-exec-server` because the full Codex Windows graph does not yet
cross-build, and the smaller target is faster to iterate on. The direct smoke
test runs pinned PowerShell and built-in `cmd.exe` through the real exec-server
with Windows cwd URI values.
## Running the test
@@ -12,13 +15,18 @@ bazel test \
--test_output=errors
```
No system Wine is required. Every process gets a fresh `WINEPREFIX` and isolated
wineserver.
No system Wine or apt packages are required. The target is intentionally
limited to x86-64 for simplicity, so Bazel marks it incompatible elsewhere.
Every process gets a fresh `WINEPREFIX` and isolated wineserver.
The reusable rules and Rust harness live under `//bazel/rules/testing`. Tests
resolve their Windows executable with `codex_utils_cargo_bin::cargo_bin`, then
use `WineTestProcess::scope` for async teardown and panic-safe cleanup.
## Current limitations
- PowerShell and ConPTY/TTY behavior are not yet covered.
- ConPTY/TTY behavior is not yet covered.
- Wine loads shared objects and PE DLLs at runtime, so the host must still
provide the declared compatible glibc version.
- The target is intentionally limited to x86-64 for simplicity. It can expand
if we find aarch64-specific behavior worth testing.
- ARM64 Linux is intentionally unsupported for now. It is more complicated to
support, and the behavior covered here should not vary by architecture.
@@ -1,206 +1,202 @@
//! 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 = "wine_exec_server_harness.rs"]
mod wine_exec_server_harness;
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::ExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::user_input::UserInput;
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::PathConvention;
use codex_utils_path_uri::PathUri;
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 pretty_assertions::assert_eq;
use serde_json::json;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use wine_test_support::WineTestCommand;
use tokio::time::timeout;
use wine_exec_server_harness::POWERSHELL_PATH;
use wine_exec_server_harness::POWERSHELL_VERSION;
use wine_exec_server_harness::WINDOWS_WORKSPACE;
use wine_exec_server_harness::WineExecServer;
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 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<i32>,
output: String,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn windows_exec_server_records_host_shell_mismatch() -> 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();
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;
}
};
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 selected_cwd = PathUri::parse("file:///C:/workspace")?;
let environments = TurnEnvironmentSelections::new(
test.config.cwd.clone(),
vec![TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: selected_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 begin = None;
let mut end = None;
loop {
match wait_for_event(&test.codex, |_| true).await {
EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => {
begin = Some(event)
}
EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => {
end = Some(event)
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
let begin = begin.context("exec_command should emit a begin event")?;
let expected_commands = [
vec![
"/bin/bash".to_string(),
"-c".to_string(),
COMMAND.to_string(),
],
vec!["/bin/sh".to_string(), "-c".to_string(), COMMAND.to_string()],
];
// This intentionally records the current cross-OS failure mode: the Linux
// orchestrator resolves its own shell before sending the command to the
// Windows exec-server, where that Unix shell cannot start.
assert!(
expected_commands.contains(&begin.command),
"unexpected command: {:?}",
begin.command,
);
assert_eq!(
(begin.cwd.clone(), begin.source),
(selected_cwd.clone(), ExecCommandSource::UnifiedExecStartup),
);
let end = end.context("exec_command should emit an end event")?;
assert_eq!(
(
end.command,
end.cwd,
end.source,
end.stdout,
end.stderr,
end.aggregated_output,
end.exit_code,
end.status,
),
(
begin.command,
selected_cwd,
ExecCommandSource::UnifiedExecStartup,
String::new(),
String::new(),
String::new(),
-1,
ExecCommandStatus::Failed,
),
);
let request = response_mock
.last_request()
.context("model should receive the failed command output")?;
let (output, success) = request
.function_call_output_content_and_success(CALL_ID)
.context("failed command output should be present")?;
let output = output.context("failed command output should contain text")?;
assert!(
output.contains("Process exited with code -1"),
"unexpected command output: {output:?}",
);
assert_ne!(success, Some(true));
Ok(())
})
.await
async fn windows_exec_server_runs_powershell_and_cmd_under_wine() -> Result<()> {
timeout(TEST_TIMEOUT, async {
let (server, websocket_url) = WineExecServer::start().await?;
let test_result = exercise_exec_server(websocket_url).await;
let shutdown_result = server.shutdown().await;
test_result?;
shutdown_result
})
.await
.context("Wine exec-server test timed out")?
}
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 info = client.environment_info().await?;
assert_eq!(info.path_convention, PathConvention::Windows);
assert_eq!(info.shell.name, "powershell");
assert!(
info.shell.path.eq_ignore_ascii_case(POWERSHELL_PATH),
"expected pinned PowerShell path, got {info:?}",
);
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
)
})?;
assert_eq!(
preflight.split('|').collect::<Vec<_>>(),
vec![
POWERSHELL_PREFLIGHT_MARKER,
POWERSHELL_VERSION,
"Core",
"true",
WINDOWS_WORKSPACE,
"92",
]
);
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
);
Ok(())
}
async fn run_non_tty_command(
client: &ExecServerClient,
process_id: &str,
argv: Vec<String>,
cwd: PathUri,
) -> Result<CommandOutput> {
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 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(CommandOutput {
exit_code,
output: String::from_utf8(output)?,
})
}
@@ -0,0 +1,266 @@
use std::collections::HashMap;
use std::io::Write;
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);
pub(crate) const POWERSHELL_PATH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe";
pub(crate) const POWERSHELL_VERSION: &str = "7.2.24";
pub(crate) const WINDOWS_WORKSPACE: &str = r"C:\workspace";
pub(crate) struct WineExecServer {
processes: Option<WineProcesses>,
}
struct WineProcesses {
cleanup_complete: bool,
exit_rx: Option<oneshot::Receiver<i32>>,
prefix: TempDir,
process: ProcessHandle,
stderr_task: JoinHandle<()>,
stdout_task: JoinHandle<()>,
wineserver: PathBuf,
}
impl WineExecServer {
pub(crate) async fn start() -> Result<(Self, String)> {
let prefix = TempDir::new().context("create Wine prefix")?;
wine_test_support::install_pinned_powershell_runtime(prefix.path())?;
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::<HashMap<_, _>>();
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 = Self {
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)
}
}
}
pub(crate) 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 WineExecServer {
fn drop(&mut self) {
if self.processes.is_some() && !std::thread::panicking() {
panic!("WineExecServer dropped without calling async shutdown");
}
}
}
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();
}
}
}
+3 -4
View File
@@ -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`.
@@ -9,6 +9,7 @@ use codex_exec_server::ExecServerRuntimePaths;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::fs::create_dir_all(r"C:\workspace")?;
let current_exe = std::env::current_exe()?;
// This fixture is always a Windows executable, so it neither invokes nor
// needs the separate Linux sandbox binary.