mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
codex: split exec-server smoke into child PR (#28120)
This commit is contained in:
@@ -1,3 +1 @@
|
||||
exports_files([
|
||||
"wine.MODULE.bazel",
|
||||
])
|
||||
exports_files(["wine.MODULE.bazel"])
|
||||
|
||||
@@ -16,6 +16,7 @@ rust_library(
|
||||
target_compatible_with = ["@platforms//os:linux"],
|
||||
deps = [
|
||||
"//codex-rs/utils/cargo-bin",
|
||||
"//codex-rs/utils/pty",
|
||||
"@crates//:anyhow",
|
||||
"@crates//:tempfile",
|
||||
"@crates//:tokio",
|
||||
|
||||
@@ -337,15 +337,6 @@ fn configure_wine_environment(command: &mut StdCommand, runtime: &WineRuntimePat
|
||||
.env("TMP", r"C:\windows\temp");
|
||||
}
|
||||
|
||||
/// Installs the pinned PowerShell runtime into a Wine prefix.
|
||||
pub fn install_pinned_powershell_runtime(prefix: &Path) -> Result<()> {
|
||||
let marker = codex_utils_cargo_bin::cargo_bin("pwsh-runtime-marker")?;
|
||||
let runtime = marker
|
||||
.parent()
|
||||
.context("locate PowerShell runtime directory")?;
|
||||
install_powershell_runtime(prefix, runtime)
|
||||
}
|
||||
|
||||
fn install_powershell_runtime(prefix: &Path, runtime: &Path) -> Result<()> {
|
||||
let powershell_parent = prefix
|
||||
.join("drive_c")
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::fs;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_utils_pty::SpawnedProcess;
|
||||
use codex_utils_pty::TerminalSize;
|
||||
use futures::FutureExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::WineTestCommand;
|
||||
use super::WineTestProcess;
|
||||
use super::WineRuntimePaths;
|
||||
use super::install_powershell_runtime;
|
||||
|
||||
const POWERSHELL_SMOKE_MARKER: &str = "WINE_PWSH_SMOKE";
|
||||
const POWERSHELL_SMOKE_SCRIPT: &str = concat!(
|
||||
"$ErrorActionPreference = 'Stop'; ",
|
||||
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ",
|
||||
"$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ",
|
||||
"Write-Output ('WINE_PWSH_SMOKE|' + ",
|
||||
"$PSVersionTable.PSVersion.ToString() + '|' + ",
|
||||
"$PSVersionTable.PSEdition + '|' + ",
|
||||
"$IsWindows.ToString().ToLowerInvariant() + '|' + $separatorCode)",
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn powershell_runtime_is_materialized_at_the_windows_fallback_path() -> Result<()> {
|
||||
let prefix = TempDir::new()?;
|
||||
@@ -43,6 +60,109 @@ fn powershell_runtime_is_materialized_at_the_windows_fallback_path() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pinned_powershell_runs_under_wine() -> Result<()> {
|
||||
let runtime = WineRuntimePaths::from_runfiles()?;
|
||||
let prefix = TempDir::new()?;
|
||||
install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?;
|
||||
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(),
|
||||
runtime.dll_path.to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"WINESERVER".to_string(),
|
||||
runtime.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()),
|
||||
]);
|
||||
let wine = runtime.wine.to_string_lossy().into_owned();
|
||||
let args = [
|
||||
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
|
||||
"-NoLogo".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
POWERSHELL_SMOKE_SCRIPT.to_string(),
|
||||
];
|
||||
let SpawnedProcess {
|
||||
session: process,
|
||||
mut stdout_rx,
|
||||
mut stderr_rx,
|
||||
exit_rx,
|
||||
} = codex_utils_pty::spawn_pty_process(
|
||||
&wine,
|
||||
&args,
|
||||
prefix.path(),
|
||||
&env,
|
||||
/*arg0*/ &None,
|
||||
TerminalSize::default(),
|
||||
)
|
||||
.await?;
|
||||
let stderr_task = tokio::spawn(async move { while stderr_rx.recv().await.is_some() {} });
|
||||
let output = timeout(Duration::from_secs(30), async {
|
||||
let mut output = Vec::new();
|
||||
while let Some(chunk) = stdout_rx.recv().await {
|
||||
output.extend(chunk);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(output)
|
||||
})
|
||||
.await
|
||||
.context("PowerShell smoke test timed out")??;
|
||||
let exit_code = exit_rx.await.context("wait for PowerShell")?;
|
||||
drop(process);
|
||||
stderr_task.abort();
|
||||
let _status = TokioCommand::new(&runtime.wineserver)
|
||||
.args(["-k", "-w"])
|
||||
.env("HOME", prefix.path())
|
||||
.env("WINEPREFIX", prefix.path())
|
||||
.env("XDG_RUNTIME_DIR", prefix.path())
|
||||
.status()
|
||||
.await?;
|
||||
anyhow::ensure!(exit_code == 0, "PowerShell exited with {exit_code}");
|
||||
let output = String::from_utf8(output)?;
|
||||
let marker_start = output
|
||||
.find(POWERSHELL_SMOKE_MARKER)
|
||||
.with_context(|| format!("PowerShell smoke marker was missing from {output:?}"))?;
|
||||
let smoke = output[marker_start..]
|
||||
.lines()
|
||||
.next()
|
||||
.context("PowerShell smoke marker line was incomplete")?
|
||||
.trim_end_matches('\r');
|
||||
let fields = smoke.split('|').collect::<Vec<_>>();
|
||||
assert_eq!(fields.len(), 5, "unexpected PowerShell smoke output: {smoke}");
|
||||
assert_eq!(fields[0], POWERSHELL_SMOKE_MARKER);
|
||||
assert_eq!(
|
||||
fields[1].split('.').next(),
|
||||
Some("7"),
|
||||
"expected PowerShell 7.x, got {}",
|
||||
fields[1],
|
||||
);
|
||||
assert_eq!(&fields[2..], &["Core", "true", "92"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn waiting_smoke_process() -> Result<WineTestProcess> {
|
||||
let executable = codex_utils_cargo_bin::cargo_bin("wine-smoke")?;
|
||||
let mut process = WineTestCommand::new(executable).arg("--wait").spawn()?;
|
||||
|
||||
@@ -2,23 +2,20 @@ load("//bazel/rules/testing:wine.bzl", "wine_rust_test")
|
||||
|
||||
wine_rust_test(
|
||||
name = "smoke-test",
|
||||
timeout = "moderate",
|
||||
srcs = [
|
||||
"non_native_cwd_tests.rs",
|
||||
"remote_env_windows_test.rs",
|
||||
],
|
||||
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/exec-server/testing:wine-exec-server-harness",
|
||||
"//codex-rs/features",
|
||||
"//codex-rs/protocol",
|
||||
"//codex-rs/utils/path-uri",
|
||||
"//codex-rs/utils/cargo-bin",
|
||||
"@crates//:anyhow",
|
||||
"@crates//:pretty_assertions",
|
||||
"@crates//:serde_json",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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::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
|
||||
}
|
||||
@@ -1,199 +1,161 @@
|
||||
#[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;
|
||||
//! Bazel-only integration coverage for a Windows exec-server running under Wine.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
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;
|
||||
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;
|
||||
|
||||
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<i32>,
|
||||
output: String,
|
||||
}
|
||||
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_runs_powershell_and_cmd_under_wine() -> Result<()> {
|
||||
timeout(TEST_TIMEOUT, async {
|
||||
let (server, websocket_url) =
|
||||
WineExecServerHarness::start_with_workspace(WINDOWS_WORKSPACE).await?;
|
||||
server.scope(exercise_exec_server(websocket_url)).await
|
||||
})
|
||||
.await
|
||||
.context("Wine exec-server test timed out")?
|
||||
}
|
||||
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 exercise_exec_server(websocket_url: String) -> Result<()> {
|
||||
let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new(
|
||||
websocket_url,
|
||||
"wine-windows-bazel-test".to_string(),
|
||||
))
|
||||
.await?;
|
||||
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 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 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 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"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
})?;
|
||||
let fields = preflight.split('|').collect::<Vec<_>>();
|
||||
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"]
|
||||
);
|
||||
.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 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(),
|
||||
}],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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?;
|
||||
|
||||
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,
|
||||
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?;
|
||||
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)?,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
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 = [
|
||||
"//bazel/rules/testing/wine:wine_test_support",
|
||||
"//codex-rs/utils/cargo-bin",
|
||||
"//codex-rs/utils/pty",
|
||||
"@crates//:anyhow",
|
||||
"@crates//:tempfile",
|
||||
"@crates//:tokio",
|
||||
],
|
||||
)
|
||||
load("@rules_rust//rust:defs.bzl", "rust_binary")
|
||||
|
||||
rust_binary(
|
||||
name = "windows-exec-server",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Exec-server test fixtures
|
||||
# Windows exec-server fixture
|
||||
|
||||
This package contains the minimal Windows exec-server binary used by the
|
||||
cross-platform tests in `//codex-rs/core/tests/remote_env_windows`.
|
||||
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.
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
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<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 WineExecServerHarness {
|
||||
/// Starts the fixture without creating an application workspace.
|
||||
pub async fn start() -> Result<(Self, String)> {
|
||||
Self::start_inner(/*workspace*/ None).await
|
||||
}
|
||||
|
||||
/// Starts the fixture after creating `workspace` inside the Wine prefix.
|
||||
pub async fn start_with_workspace(workspace: &str) -> Result<(Self, String)> {
|
||||
Self::start_inner(Some(workspace)).await
|
||||
}
|
||||
|
||||
async fn start_inner(workspace: Option<&str>) -> Result<(Self, String)> {
|
||||
let prefix = TempDir::new().context("create Wine prefix")?;
|
||||
wine_test_support::install_pinned_powershell_runtime(prefix.path())?;
|
||||
if let Some(workspace) = workspace {
|
||||
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::<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an operation and then performs bounded asynchronous teardown.
|
||||
pub async fn scope<T>(self, future: impl Future<Output = Result<T>>) -> Result<T> {
|
||||
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()))
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user