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}"