exec-server: prove the real app-server cross-OS flow

This commit is contained in:
Adam Perry
2026-06-13 06:26:56 +00:00
parent bc83fd5da6
commit 550f29ac2a
5 changed files with 560 additions and 0 deletions
@@ -26,3 +26,39 @@ wine_rust_test(
"@crates//:tokio",
],
)
wine_rust_test(
name = "wine-app-server-windows-exec-server-test",
timeout = "moderate",
srcs = [
"wine_app_server_windows_exec_server_test.rs",
"wine_exec_server_harness.rs",
],
crate_name = "wine_app_server_windows_exec_server_test",
crate_root = "wine_app_server_windows_exec_server_test.rs",
data = [
"//codex-rs/app-server:codex-app-server",
],
include_powershell = True,
rustc_env = {
"BAZEL_PACKAGE": "codex-rs/core/tests/remote_env_windows",
},
windows_binaries = {
"wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server",
},
deps = [
"//bazel/rules/testing/wine:wine_test_support",
"//codex-rs/app-server-protocol",
"//codex-rs/app-server/tests/common",
"//codex-rs/core/tests/common",
"//codex-rs/exec-server",
"//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",
],
)
@@ -15,6 +15,15 @@ bazel test \
--test_output=errors
```
The full cross-OS flow uses a real Linux app-server for two turns against the
same Windows exec-server:
```sh
bazel test \
//codex-rs/core/tests/remote_env_windows:wine-app-server-windows-exec-server-test \
--test_output=errors
```
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.
@@ -0,0 +1,489 @@
#![cfg(target_os = "linux")]
#[path = "wine_exec_server_harness.rs"]
mod wine_exec_server_harness;
use std::fs;
use std::path::Path;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_utils_path_uri::ApiPathString;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
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 APP_SERVER_TIMEOUT: Duration = Duration::from_secs(90);
const TEST_TIMEOUT: Duration = Duration::from_secs(240);
const FIRST_EXEC_CALL_ID: &str = "wine-app-server-pwsh-default";
const STICKY_EXEC_CALL_ID: &str = "wine-app-server-pwsh-explicit";
const FIRST_OUTPUT_MARKER: &str = "WINE_APP_SERVER_DEFAULT";
const STICKY_OUTPUT_MARKER: &str = "WINE_APP_SERVER_STICKY";
const RELATIVE_WORKDIR: &str = "relative";
const FIRST_WINDOWS_CWD: &str = r"C:\workspace\relative";
const ABSOLUTE_WINDOWS_CWD: &str = r"C:\workspace\absolute";
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn app_server_executes_sticky_windows_powershell_turns_under_wine() -> Result<()> {
timeout(TEST_TIMEOUT, async {
let (server, websocket_url) = WineExecServer::start().await?;
let test_result = exercise_app_server(websocket_url).await;
let shutdown_result = server.shutdown().await;
test_result?;
shutdown_result
})
.await
.context("Wine app-server end-to-end test timed out")?
}
async fn exercise_app_server(websocket_url: String) -> Result<()> {
let model_server = responses::start_mock_server().await;
let first_exec_arguments = json!({
"cmd": powershell_probe(FIRST_OUTPUT_MARKER),
"workdir": RELATIVE_WORKDIR,
"tty": false,
"yield_time_ms": 30_000,
"max_output_tokens": 2_000,
})
.to_string();
let sticky_exec_arguments = json!({
"cmd": powershell_probe(STICKY_OUTPUT_MARKER),
"shell": "powershell.exe",
"workdir": ABSOLUTE_WINDOWS_CWD,
"tty": false,
"yield_time_ms": 30_000,
"max_output_tokens": 2_000,
})
.to_string();
let response_mock = responses::mount_sse_sequence(
&model_server,
vec![
responses::sse(vec![
responses::ev_response_created("wine-app-server-response-1"),
responses::ev_function_call(
FIRST_EXEC_CALL_ID,
"exec_command",
&first_exec_arguments,
),
responses::ev_completed("wine-app-server-response-1"),
]),
responses::sse(vec![
responses::ev_response_created("wine-app-server-response-2"),
responses::ev_assistant_message("wine-app-server-message-1", "first done"),
responses::ev_completed("wine-app-server-response-2"),
]),
responses::sse(vec![
responses::ev_response_created("wine-app-server-response-3"),
responses::ev_function_call(
STICKY_EXEC_CALL_ID,
"exec_command",
&sticky_exec_arguments,
),
responses::ev_completed("wine-app-server-response-3"),
]),
responses::sse(vec![
responses::ev_response_created("wine-app-server-response-4"),
responses::ev_assistant_message("wine-app-server-message-2", "sticky done"),
responses::ev_completed("wine-app-server-response-4"),
]),
],
)
.await;
let codex_home = TempDir::new().context("create app-server CODEX_HOME")?;
write_config(codex_home.path(), &model_server.uri())?;
let app_server_program =
codex_utils_cargo_bin::find_resource!("../../../app-server/codex-app-server")?;
let mut app_server = TestAppServer::new_with_program_and_env(
codex_home.path(),
&app_server_program,
&[(
CODEX_EXEC_SERVER_URL_ENV_VAR,
Some(websocket_url.as_str()),
)],
)
.await?;
timeout(APP_SERVER_TIMEOUT, app_server.initialize()).await??;
let environment = remote_windows_environment();
let thread_request_id = app_server
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
environments: Some(vec![environment.clone()]),
..Default::default()
})
.await?;
let thread_response: JSONRPCResponse = timeout(
APP_SERVER_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_response)?;
let first_items = submit_turn_and_collect_command(
&mut app_server,
TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "Run the default Windows shell in a relative workdir.".to_string(),
text_elements: Vec::new(),
}],
environments: Some(vec![environment]),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
model: Some("mock-model".to_string()),
..Default::default()
},
FIRST_EXEC_CALL_ID,
)
.await?;
assert_command_items(
&first_items,
FIRST_EXEC_CALL_ID,
FIRST_WINDOWS_CWD,
FIRST_OUTPUT_MARKER,
POWERSHELL_PATH,
);
let sticky_items = submit_turn_and_collect_command(
&mut app_server,
TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: "Use explicit powershell.exe in an absolute workdir.".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
},
STICKY_EXEC_CALL_ID,
)
.await?;
assert_command_items(
&sticky_items,
STICKY_EXEC_CALL_ID,
ABSOLUTE_WINDOWS_CWD,
STICKY_OUTPUT_MARKER,
"powershell.exe",
);
let requests = response_mock.requests();
assert_eq!(requests.len(), 4);
let first_environment_context = requests[0]
.message_input_texts("user")
.into_iter()
.find(|text| text.starts_with("<environment_context>"))
.context("first model request should contain environment context")?;
assert_windows_environment_context(&first_environment_context);
assert_function_output(
&requests[1],
FIRST_EXEC_CALL_ID,
FIRST_OUTPUT_MARKER,
FIRST_WINDOWS_CWD,
)?;
assert_function_output(
&requests[3],
STICKY_EXEC_CALL_ID,
STICKY_OUTPUT_MARKER,
ABSOLUTE_WINDOWS_CWD,
)?;
let sticky_contexts = requests[2]
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<environment_context>") && text.contains("<cwd>"))
.collect::<Vec<_>>();
assert_eq!(
sticky_contexts.len(),
1,
"sticky turn should retain one environment context without redundant updates"
);
for environment_context in sticky_contexts {
assert_windows_environment_context(&environment_context);
}
let linux_cwd = codex_home.path().to_string_lossy();
for request in requests {
assert_no_linux_execution_artifacts(&request.body_json().to_string(), &linux_cwd);
}
assert_no_linux_execution_artifacts(&format!("{first_items:?}{sticky_items:?}"), &linux_cwd);
Ok(())
}
#[derive(Debug)]
struct CommandItems {
started: ThreadItem,
completed: ThreadItem,
}
async fn submit_turn_and_collect_command(
app_server: &mut TestAppServer,
params: TurnStartParams,
call_id: &str,
) -> Result<CommandItems> {
let turn_request_id = app_server.send_turn_start_request(params).await?;
let turn_response: JSONRPCResponse = timeout(
APP_SERVER_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_response)?;
let started = read_command_item_started(app_server, call_id).await?;
let completed = read_command_item_completed(app_server, call_id).await?;
let completed_notification = timeout(
APP_SERVER_TIMEOUT,
app_server.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed_turn: TurnCompletedNotification = serde_json::from_value(
completed_notification
.params
.context("turn/completed should include params")?,
)?;
assert_eq!(completed_turn.turn.id, turn.id);
assert_eq!(completed_turn.turn.status, TurnStatus::Completed);
Ok(CommandItems { started, completed })
}
async fn read_command_item_started(
app_server: &mut TestAppServer,
call_id: &str,
) -> Result<ThreadItem> {
timeout(APP_SERVER_TIMEOUT, async {
loop {
let notification = app_server
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification = serde_json::from_value(
notification
.params
.context("item/started should include params")?,
)?;
if matches!(&started.item, ThreadItem::CommandExecution { id, .. } if id == call_id) {
return Ok::<_, anyhow::Error>(started.item);
}
}
})
.await
.context("timed out waiting for command item/started")?
}
async fn read_command_item_completed(
app_server: &mut TestAppServer,
call_id: &str,
) -> Result<ThreadItem> {
timeout(APP_SERVER_TIMEOUT, async {
loop {
let notification = app_server
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
notification
.params
.context("item/completed should include params")?,
)?;
if matches!(&completed.item, ThreadItem::CommandExecution { id, .. } if id == call_id) {
return Ok::<_, anyhow::Error>(completed.item);
}
}
})
.await
.context("timed out waiting for command item/completed")?
}
fn assert_command_items(
items: &CommandItems,
call_id: &str,
cwd: &str,
output_marker: &str,
expected_shell: &str,
) {
let ThreadItem::CommandExecution {
id,
command,
cwd: started_cwd,
status: started_status,
exit_code: started_exit_code,
..
} = &items.started
else {
panic!("expected started command execution item")
};
assert_eq!(id, call_id);
let expected_shell_name = expected_shell.rsplit(['/', '\\']).next().unwrap_or(expected_shell);
assert!(
command.contains(expected_shell_name),
"expected {expected_shell_name:?} in started command {command:?}"
);
assert_eq!(started_cwd, &ApiPathString::new(cwd));
assert_eq!(started_status, &CommandExecutionStatus::InProgress);
assert_eq!(started_exit_code, &None);
assert_completed_command_item(&items.completed, call_id, cwd, output_marker);
}
fn assert_completed_command_item(
item: &ThreadItem,
call_id: &str,
cwd: &str,
output_marker: &str,
) {
let ThreadItem::CommandExecution {
id,
cwd: completed_cwd,
status: completed_status,
aggregated_output,
exit_code,
..
} = item
else {
panic!("expected completed command execution item")
};
assert_eq!(id, call_id);
assert_eq!(completed_cwd, &ApiPathString::new(cwd));
assert_eq!(completed_status, &CommandExecutionStatus::Completed);
assert_eq!(exit_code, &Some(0));
assert!(
aggregated_output
.as_deref()
.is_some_and(|output| output.contains(&expected_probe(output_marker, cwd))),
"unexpected completed command output: {aggregated_output:?}"
);
}
fn assert_function_output(
request: &responses::ResponsesRequest,
call_id: &str,
marker: &str,
cwd: &str,
) -> Result<()> {
let output = request
.function_call_output_text(call_id)
.with_context(|| format!("missing function_call_output for {call_id}"))?;
assert!(
output.contains(&expected_probe(marker, cwd)),
"unexpected function_call_output: {output:?}"
);
Ok(())
}
fn remote_windows_environment() -> TurnEnvironmentParams {
TurnEnvironmentParams {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: ApiPathString::new(WINDOWS_WORKSPACE),
}
}
fn powershell_probe(marker: &str) -> String {
format!(
"$ErrorActionPreference = 'Stop'; \
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); \
$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); \
Write-Output ('{marker}|' + $PSVersionTable.PSVersion.ToString() + '|' + \
$PSVersionTable.PSEdition + '|' + $IsWindows.ToString().ToLowerInvariant() + '|' + \
(Get-Location).ProviderPath + '|' + $separatorCode)"
)
}
fn expected_probe(marker: &str, cwd: &str) -> String {
format!("{marker}|{POWERSHELL_VERSION}|Core|true|{cwd}|92")
}
fn assert_windows_environment_context(environment_context: &str) {
assert!(
environment_context.contains(&format!("<cwd>{WINDOWS_WORKSPACE}</cwd>")),
"unexpected environment context: {environment_context:?}"
);
assert!(
environment_context.contains("<shell>powershell</shell>"),
"unexpected environment context: {environment_context:?}"
);
assert!(
environment_context.contains("<permission_profile type=\"disabled\">"),
"unexpected environment context: {environment_context:?}"
);
}
fn assert_no_linux_execution_artifacts(value: &str, linux_cwd: &str) {
for unexpected in [
"/bin/bash",
"/bin/sh",
"codex-linux-sandbox",
"codex-execve-wrapper",
] {
assert!(
!value.contains(unexpected),
"Linux execution artifact {unexpected:?} leaked into {value:?}"
);
}
let linux_cwd_element = format!("<cwd>{linux_cwd}</cwd>");
assert!(
!value.contains(&linux_cwd_element),
"Linux execution cwd leaked into {value:?}"
);
}
fn write_config(codex_home: &Path, server_uri: &str) -> Result<()> {
fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
DOTNET_CLI_TELEMETRY_OPTOUT = "1"
DOTNET_NOLOGO = "1"
POWERSHELL_TELEMETRY_OPTOUT = "1"
POWERSHELL_UPDATECHECK = "Off"
[features]
unified_exec = true
[model_providers.mock_provider]
name = "Mock provider for Wine app-server test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
supports_websockets = false
"#
),
)
.context("write app-server config")
}
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Stdio;
@@ -23,6 +24,8 @@ pub(crate) const POWERSHELL_PATH: &str = r"C:\Program Files\PowerShell\7\pwsh.ex
pub(crate) const POWERSHELL_VERSION: &str = "7.2.24";
pub(crate) const WINDOWS_WORKSPACE: &str = r"C:\workspace";
const POWERSHELL_DIRECTORY: &str = "drive_c/Program Files/PowerShell/7";
pub(crate) struct WineExecServer {
processes: Option<WineProcesses>,
}
@@ -41,6 +44,7 @@ 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())?;
install_powershell_exe_alias(prefix.path())?;
let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?;
let wine = codex_utils_cargo_bin::cargo_bin("wine")?;
@@ -81,6 +85,10 @@ impl WineExecServer {
("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()),
(
"WINEPATH".to_string(),
r"C:\Program Files\PowerShell\7".to_string(),
),
("DOTNET_CLI_TELEMETRY_OPTOUT".to_string(), "1".to_string()),
("DOTNET_NOLOGO".to_string(), "1".to_string()),
(
@@ -171,6 +179,22 @@ impl WineExecServer {
}
}
fn install_powershell_exe_alias(prefix: &std::path::Path) -> Result<()> {
let directory = prefix.join(POWERSHELL_DIRECTORY);
let source = directory.join("pwsh.exe");
let target = directory.join("powershell.exe");
if fs::hard_link(&source, &target).is_err() {
fs::copy(&source, &target).with_context(|| {
format!(
"copy PowerShell executable alias from {} to {}",
source.display(),
target.display()
)
})?;
}
Ok(())
}
impl Drop for WineExecServer {
fn drop(&mut self) {
if self.processes.is_some() && !std::thread::panicking() {
@@ -10,6 +10,8 @@ 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")?;
std::fs::create_dir_all(r"C:\workspace\relative")?;
std::fs::create_dir_all(r"C:\workspace\absolute")?;
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.