From 9d938a46d9119c3aaa4aac056950951027adec34 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 12 Jun 2026 20:20:23 -0700 Subject: [PATCH] [codex] Add hermetic Wine exec-server test (#27937) ## Why We want to make it possible for an app-server orchestrator on one OS to control an exec-server on another host running a different OS. In practice this kinda already works if you get lucky and the two hosts have the same path format, but we mangle quite a lot of operations if either end is Windows. This test starts exercising that interaction, although right now the initial bootstrap fails. Future changes will expand the test's assertions to match improved support. ## What Stacked on #27964. This adds a small Windows exec-server fixture and a Linux protocol smoke test using the reusable Wine harness, covering Windows environment discovery, non-TTY `cmd.exe` execution, output, exit status, and working directory. Once we've got the full codex binary cross-building under Bazel we could consider moving to the real binary instead of the stripped down exec-server-only binary used here. --- bazel/rules/testing/wine.bzl | 12 +- codex-rs/core/Cargo.toml | 1 + .../core/tests/remote_env_windows/BUILD.bazel | 24 ++ .../core/tests/remote_env_windows/README.md | 24 ++ .../remote_env_windows_test.rs | 207 ++++++++++++++++++ codex-rs/exec-server/testing/BUILD.bazel | 16 ++ codex-rs/exec-server/testing/README.md | 5 + .../testing/windows_exec_server.rs | 18 ++ scripts/list-bazel-release-targets.sh | 3 + .../list-bazel-targets.sh | 5 +- 10 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 codex-rs/core/tests/remote_env_windows/BUILD.bazel create mode 100644 codex-rs/core/tests/remote_env_windows/README.md create mode 100644 codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs create mode 100644 codex-rs/exec-server/testing/BUILD.bazel create mode 100644 codex-rs/exec-server/testing/README.md create mode 100644 codex-rs/exec-server/testing/windows_exec_server.rs diff --git a/bazel/rules/testing/wine.bzl b/bazel/rules/testing/wine.bzl index 5dc535a5e..ac1f8d317 100644 --- a/bazel/rules/testing/wine.bzl +++ b/bazel/rules/testing/wine.bzl @@ -13,6 +13,7 @@ _WINE_RUNTIME_BINARIES = { def wine_rust_test( name, windows_binaries, + host_binaries = {}, data = [], target_compatible_with = [], **kwargs): @@ -22,7 +23,8 @@ def wine_rust_test( every Rust dependency receives the repository's Windows linker flags while the test stays on x86-64 Linux. Its environment-variable contract is: - * Each entry contributes `CARGO_BIN_EXE_` for its executable. + * Each `host_binaries` and `windows_binaries` entry contributes + `CARGO_BIN_EXE_` for its executable. * `CARGO_BIN_EXE_wine` and `CARGO_BIN_EXE_wineserver` identify Wine tools. * `CARGO_BIN_EXE_wine-runtime-marker` identifies a file whose parent is the Wine DLL directory to use as `WINEDLLPATH`. @@ -34,14 +36,20 @@ def wine_rust_test( Args: name: Name of the generated Linux `rust_test`. windows_binaries: Map from `CARGO_BIN_EXE_*` suffixes to Windows targets. + host_binaries: Map from `CARGO_BIN_EXE_*` suffixes to Linux host targets. data: Additional runtime data for the Linux test. target_compatible_with: Additional compatibility constraints. **kwargs: Remaining attributes forwarded to `rust_test`. """ binaries = dict(_WINE_RUNTIME_BINARIES) + for binary_name in sorted(host_binaries.keys()): + if binary_name in binaries: + fail("host test binary name collides with Wine runtime: {}".format(binary_name)) + binaries[binary_name] = host_binaries[binary_name] + for index, binary_name in enumerate(sorted(windows_binaries.keys())): if binary_name in binaries: - fail("Windows test binary name collides with Wine runtime: {}".format(binary_name)) + fail("Windows test binary name collides with existing binary: {}".format(binary_name)) transitioned_binary = name + "-windows-binary-" + str(index) foreign_platform_binary( name = transitioned_binary, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d21245c63..3273379f9 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -163,3 +163,4 @@ zstd = { workspace = true } [package.metadata.cargo-shear] ignored = ["openssl-sys"] +ignored-paths = ["tests/remote_env_windows/*.rs"] diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel new file mode 100644 index 000000000..a620965c8 --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -0,0 +1,24 @@ +load("//bazel/rules/testing:wine.bzl", "wine_rust_test") + +wine_rust_test( + name = "smoke-test", + timeout = "short", + srcs = ["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/features", + "//codex-rs/protocol", + "//codex-rs/utils/cargo-bin", + "@crates//:anyhow", + "@crates//:pretty_assertions", + "@crates//:serde_json", + "@crates//:tokio", + ], +) diff --git a/codex-rs/core/tests/remote_env_windows/README.md b/codex-rs/core/tests/remote_env_windows/README.md new file mode 100644 index 000000000..92c9a8973 --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/README.md @@ -0,0 +1,24 @@ +# Windows remote-environment test + +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. + +## Running the test + +```sh +bazel test \ + //codex-rs/core/tests/remote_env_windows:smoke-test \ + --test_output=errors +``` + +No system Wine is required. Every process gets a fresh `WINEPREFIX` and isolated +wineserver. + +## Current limitations + +- PowerShell and ConPTY/TTY behavior are 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. 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 new file mode 100644 index 000000000..aa70ab1a5 --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -0,0 +1,207 @@ +//! Bazel-only integration coverage for a Windows exec-server running under Wine. + +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 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; + +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_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 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 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), + ( + test.config.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, + test.config.cwd.clone(), + 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 +} diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel new file mode 100644 index 000000000..ad0f5b855 --- /dev/null +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -0,0 +1,16 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary") + +rust_binary( + name = "windows-exec-server", + testonly = True, + srcs = ["windows_exec_server.rs"], + crate_name = "windows_exec_server", + crate_root = "windows_exec_server.rs", + tags = ["manual"], + target_compatible_with = ["@platforms//os:windows"], + visibility = ["//codex-rs/core/tests/remote_env_windows:__pkg__"], + deps = [ + "//codex-rs/exec-server", + "@crates//:tokio", + ], +) diff --git a/codex-rs/exec-server/testing/README.md b/codex-rs/exec-server/testing/README.md new file mode 100644 index 000000000..77f8e990d --- /dev/null +++ b/codex-rs/exec-server/testing/README.md @@ -0,0 +1,5 @@ +# Windows exec-server fixture + +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. diff --git a/codex-rs/exec-server/testing/windows_exec_server.rs b/codex-rs/exec-server/testing/windows_exec_server.rs new file mode 100644 index 000000000..9efc5286f --- /dev/null +++ b/codex-rs/exec-server/testing/windows_exec_server.rs @@ -0,0 +1,18 @@ +//! Minimal Windows exec-server fixture for cross-platform tests. +//! +//! Keeping this wrapper separate avoids depending on the full Codex binary's +//! Windows cross-build, which is not yet supported by the Bazel graph. Linking +//! only the exec-server also makes the Wine test substantially faster to +//! iterate on. + +use codex_exec_server::ExecServerRuntimePaths; + +#[tokio::main] +async fn main() -> Result<(), Box> { + 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. + let runtime_paths = + ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; + codex_exec_server::run_main("ws://127.0.0.1:0", runtime_paths).await +} diff --git a/scripts/list-bazel-release-targets.sh b/scripts/list-bazel-release-targets.sh index 900f58b02..154f0b358 100644 --- a/scripts/list-bazel-release-targets.sh +++ b/scripts/list-bazel-release-targets.sh @@ -11,6 +11,9 @@ cd "${repo_root}" # Exclude the experimental `v8-poc` target because it pulls in expensive V8 # build machinery that is unrelated to the release-only Rust regression this # workflow is meant to catch. +# The normal test job covers the Wine smoke test; omit its downloaded runtime +# and cross-compile from this build-only release sweep. printf '%s\n' \ "//codex-rs/..." \ + "-//codex-rs/core/tests/remote_env_windows:smoke-test" \ "-//codex-rs/v8-poc:all" diff --git a/tools/argument-comment-lint/list-bazel-targets.sh b/tools/argument-comment-lint/list-bazel-targets.sh index f8cb4f5e2..679aa3edd 100755 --- a/tools/argument-comment-lint/list-bazel-targets.sh +++ b/tools/argument-comment-lint/list-bazel-targets.sh @@ -18,5 +18,8 @@ if [[ "${RUNNER_OS:-}" != "Windows" ]]; then manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-windows-cross-bin$' || true)" fi -printf '%s\n' "//codex-rs/..." +# The lint configuration does not register the transitioned Windows toolchain. +printf '%s\n' \ + "//codex-rs/..." \ + "-//codex-rs/core/tests/remote_env_windows:smoke-test" printf '%s\n' "${manual_rust_test_targets}"