diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 18b7bc9190..4be95a6086 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,16 +402,20 @@ dependencies = [ "codex-app-server-protocol", "codex-config", "codex-core", + "codex-exec-server", "codex-features", "codex-login", "codex-models-manager", "codex-protocol", "codex-utils-cargo-bin", "core_test_support", + "pretty_assertions", "serde", "serde_json", "shlex", + "tempfile", "tokio", + "toml 0.9.11+spec-1.1.0", "uuid", "wiremock", ] diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index 534e7209cd..a8e7f50271 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -11,6 +11,7 @@ codex_rust_crate( "//codex-rs/rmcp-client:test_stdio_server", ], integration_test_timeout = "long", + run_tests_with_wine_exec = True, test_shard_counts = { # Note app-server-all-test has a large number of integration tests, so # even a single shard can be quite slow. When there is a legitimate diff --git a/codex-rs/app-server/tests/common/BUILD.bazel b/codex-rs/app-server/tests/common/BUILD.bazel index 82473248b9..07aa9203cb 100644 --- a/codex-rs/app-server/tests/common/BUILD.bazel +++ b/codex-rs/app-server/tests/common/BUILD.bazel @@ -4,4 +4,5 @@ codex_rust_crate( name = "common", crate_name = "app_test_support", crate_srcs = glob(["*.rs"]), + run_tests_with_wine_exec = True, ) diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 5b245f40d2..615643c80e 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -19,6 +19,7 @@ chrono = { workspace = true } codex-app-server-protocol = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } +codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-models-manager = { workspace = true } @@ -32,7 +33,12 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", ] } +toml = { workspace = true } uuid = { workspace = true } wiremock = { workspace = true } core_test_support = { path = "../../../core/tests/common" } shlex = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 76c4c85f26..86a110ec7b 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -46,7 +46,9 @@ pub use rollout::rollout_path; use serde::de::DeserializeOwned; pub use test_app_server::DEFAULT_CLIENT_NAME; pub use test_app_server::DISABLE_PLUGIN_STARTUP_TASKS_ARG; +pub use test_app_server::EnvironmentConfigFile; pub use test_app_server::TestAppServer; +pub use test_app_server::TestAppServerEnvironment; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { let value = serde_json::to_value(response.result)?; diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index e940d9a42b..2f1f9db711 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -107,13 +107,153 @@ use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::WindowsSandboxSetupStartParams; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; +use core_test_support::TestEnvironment; +use core_test_support::TestExecutionEnvironment; +use core_test_support::test_environment; use tokio::process::Command; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LocalEnvironmentRegistration { + Included, + Omitted, +} + +#[derive(serde::Serialize)] +struct GeneratedEnvironmentsToml<'a> { + default: &'a str, + include_local: bool, + environments: [GeneratedEnvironmentToml<'a>; 1], +} + +#[derive(serde::Serialize)] +struct GeneratedEnvironmentToml<'a> { + id: &'a str, + url: &'a str, +} + +const GENERATED_ENVIRONMENTS_TOML_HEADER: &str = + "# Generated by app_test_support for remote execution.\n"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EnvironmentConfigFile { + Missing, + HarnessGenerated, + TestOwned, +} + +/// Adapts the shared integration-test execution environment for app-server tests. +pub struct TestAppServerEnvironment { + state: TestAppServerEnvironmentState, +} + +enum TestAppServerEnvironmentState { + Local, + Remote { + execution: Box, + generated_thread_environment: Option, + local_environment_registration: LocalEnvironmentRegistration, + }, +} + +impl TestAppServerEnvironment { + pub async fn new() -> anyhow::Result { + let local_environment_registration = match test_environment() { + TestEnvironment::Local => { + return Ok(Self { + state: TestAppServerEnvironmentState::Local, + }); + } + TestEnvironment::Docker { .. } => LocalEnvironmentRegistration::Included, + TestEnvironment::WineExec => LocalEnvironmentRegistration::Omitted, + }; + Ok(Self { + state: TestAppServerEnvironmentState::Remote { + execution: Box::new(TestExecutionEnvironment::new().await?), + generated_thread_environment: None, + local_environment_registration, + }, + }) + } + + pub fn generated_environments_toml( + &mut self, + env_overrides: &[(&str, Option<&str>)], + environment_config_file: EnvironmentConfigFile, + ) -> anyhow::Result> { + let TestAppServerEnvironmentState::Remote { + execution, + generated_thread_environment, + local_environment_registration, + } = &mut self.state + else { + return Ok(None); + }; + // A caller-provided `CODEX_EXEC_SERVER_URL` override, including removal, is intentional. + // Do not generate a file because `environments.toml` takes precedence and would mask it. + let exec_server_url_overridden = env_overrides + .iter() + .any(|(key, _value)| *key == CODEX_EXEC_SERVER_URL_ENV_VAR); + if exec_server_url_overridden || environment_config_file == EnvironmentConfigFile::TestOwned + { + if matches!(test_environment(), TestEnvironment::WineExec) { + panic!( + "Wine-exec app-server tests cannot use a caller-provided \ + `CODEX_EXEC_SERVER_URL` or `environments.toml` because the harness cannot \ + guarantee that the app server selects its Wine executor with a \ + Windows-native cwd. Add a `skip_if_wine_exec!` guard with a TODO describing \ + how to support this configuration across operating systems." + ); + } + *generated_thread_environment = None; + return Ok(None); + } + + let exec_server_url = execution + .exec_server_url() + .context("remote test environment should have an exec-server URL")?; + let environment_id = execution.environment_id(); + let include_local = match local_environment_registration { + LocalEnvironmentRegistration::Included => true, + LocalEnvironmentRegistration::Omitted => false, + }; + *generated_thread_environment = Some(TurnEnvironmentParams { + environment_id: environment_id.to_string(), + cwd: execution.environment_cwd().clone(), + }); + if environment_config_file == EnvironmentConfigFile::HarnessGenerated { + return Ok(None); + } + let contents = toml::to_string(&GeneratedEnvironmentsToml { + default: environment_id, + include_local, + environments: [GeneratedEnvironmentToml { + id: environment_id, + url: exec_server_url, + }], + })?; + Ok(Some(format!( + "{GENERATED_ENVIRONMENTS_TOML_HEADER}{contents}" + ))) + } + + pub fn generated_thread_environment(&self) -> Option { + match &self.state { + TestAppServerEnvironmentState::Local => None, + TestAppServerEnvironmentState::Remote { + generated_thread_environment, + .. + } => generated_thread_environment.clone(), + } + } +} + pub struct TestAppServer { next_request_id: AtomicI64, /// Retain this child process until the client is dropped. The Tokio runtime @@ -124,6 +264,12 @@ pub struct TestAppServer { stdin: Option, stdout: BufReader, pending_messages: VecDeque, + /// Retains the environment resolved before app-server starts because it owns the remote + /// workspace and records whether startup configuration was harness-generated. Recomputing it + /// for `thread/start` would allocate a different cwd and lose that registration decision. + test_environment: TestAppServerEnvironment, + /// Remembers the initialize capability because later `thread/start` requests do not carry it. + experimental_api_enabled: bool, } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; @@ -216,6 +362,7 @@ impl TestAppServer { env_overrides: &[(&str, Option<&str>)], args: &[&str], ) -> anyhow::Result { + let mut test_environment = TestAppServerEnvironment::new().await?; let mut cmd = Command::new(program); cmd.stdin(Stdio::piped()); @@ -232,6 +379,41 @@ impl TestAppServer { cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); cmd.args(args); + // Register the selected remote test executor at process startup, independently of the + // client's experimental API capability. Wine-exec intentionally omits the local + // environment so compatible operations cannot accidentally exercise the host executor. + // Reuse harness-generated configuration across app-server restarts. Explicit overrides + // and test-owned configuration remain untouched; Wine rejects those escape hatches so a + // test cannot silently fall back to an executor with the wrong path convention. + let environments_toml = codex_home.join("environments.toml"); + let environment_config_file = match std::fs::read_to_string(&environments_toml) { + Ok(contents) if contents.starts_with(GENERATED_ENVIRONMENTS_TOML_HEADER) => { + EnvironmentConfigFile::HarnessGenerated + } + Ok(_contents) => EnvironmentConfigFile::TestOwned, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + EnvironmentConfigFile::Missing + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "read environment config from {}", + environments_toml.display() + ) + }); + } + }; + if let Some(contents) = + test_environment.generated_environments_toml(env_overrides, environment_config_file)? + { + std::fs::write(&environments_toml, contents).with_context(|| { + format!( + "write test environment config to {}", + environments_toml.display() + ) + })?; + } + for (k, v) in env_overrides { match v { Some(val) => { @@ -273,6 +455,8 @@ impl TestAppServer { stdin: Some(stdin), stdout, pending_messages: VecDeque::new(), + test_environment, + experimental_api_enabled: false, }) } @@ -322,8 +506,8 @@ impl TestAppServer { &mut self, params: InitializeParams, ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - let request_id = self.send_request("initialize", params).await?; + let request_params = Some(serde_json::to_value(¶ms)?); + let request_id = self.send_request("initialize", request_params).await?; let message = self.read_jsonrpc_message().await?; match message { JSONRPCMessage::Response(response) => { @@ -339,6 +523,14 @@ impl TestAppServer { self.send_notification(ClientNotification::Initialized) .await?; + // Client capabilities are negotiated during `initialize`; `thread/start` does + // not repeat them. Retain this bit after a successful handshake so the harness + // knows whether it may inject the experimental `environments` field later. + self.experimental_api_enabled = params + .capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.experimental_api); + Ok(JSONRPCMessage::Response(response)) } JSONRPCMessage::Error(error) => { @@ -443,8 +635,30 @@ impl TestAppServer { /// Send a `thread/start` JSON-RPC request. pub async fn send_thread_start_request( &mut self, - params: ThreadStartParams, + mut params: ThreadStartParams, ) -> anyhow::Result { + // `environments.toml` registers and defaults the remote executor, but websocket + // environments cannot configure a cwd. Core would therefore pair that executor with the + // Linux app-server's `Config::cwd`. When the client negotiated experimental API support, + // the harness automatically injects the generated environment with its target-native + // Wine/Docker cwd on `thread/start`. + let generated_environment = + if self.experimental_api_enabled && params.environments.is_none() { + self.test_environment.generated_thread_environment() + } else { + None + }; + if let Some(environment) = generated_environment { + params.environments = Some(vec![environment]); + } else if matches!(test_environment(), TestEnvironment::WineExec) { + panic!( + "Wine-exec app-server tests must start threads with the harness-generated remote \ + environment so the executor receives a Windows-native cwd. This test uses a \ + non-experimental client, explicit thread environments, or test-owned environment \ + configuration, so the harness cannot safely inject that selection. Add a \ + `skip_if_wine_exec!` guard with a TODO describing how to make this setup cross-OS." + ); + } let params = Some(serde_json::to_value(params)?); self.send_request("thread/start", params).await } diff --git a/codex-rs/app-server/tests/common/tests/test_app_server_environment.rs b/codex-rs/app-server/tests/common/tests/test_app_server_environment.rs new file mode 100644 index 0000000000..71f417f8b9 --- /dev/null +++ b/codex-rs/app-server/tests/common/tests/test_app_server_environment.rs @@ -0,0 +1,173 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::EnvironmentConfigFile; +use app_test_support::TestAppServerEnvironment; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use core_test_support::TestEnvironment; +use core_test_support::test_environment; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[tokio::test] +async fn generated_config_registers_selected_remote_as_default() -> Result<()> { + let mut environment = TestAppServerEnvironment::new().await?; + let environments_toml = + environment.generated_environments_toml(&[], EnvironmentConfigFile::Missing)?; + + match test_environment() { + TestEnvironment::Local => assert_eq!(environments_toml, None), + TestEnvironment::Docker { .. } | TestEnvironment::WineExec => { + let contents = environments_toml.context("generated environments.toml")?; + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("environments.toml"), &contents)?; + let local_runtime_paths = ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?; + let manager = + EnvironmentManager::from_codex_home(codex_home.path(), Some(local_runtime_paths)) + .await?; + + assert_eq!( + manager.default_environment_id(), + Some(REMOTE_ENVIRONMENT_ID) + ); + match test_environment() { + TestEnvironment::Local => unreachable!("remote test branch"), + TestEnvironment::Docker { .. } => { + assert!(manager.try_local_environment().is_some()); + } + TestEnvironment::WineExec => { + assert!(manager.try_local_environment().is_none()); + } + } + let remote = manager + .get_environment(REMOTE_ENVIRONMENT_ID) + .context("remote environment")?; + assert!(remote.exec_server_url().is_some()); + } + } + + Ok(()) +} + +#[tokio::test] +async fn generated_config_respects_explicit_overrides_or_rejects_them_under_wine() -> Result<()> { + let mut environment = TestAppServerEnvironment::new().await?; + let generated = environment.generated_environments_toml(&[], EnvironmentConfigFile::Missing)?; + + match test_environment() { + TestEnvironment::Local => { + assert_eq!(generated, None); + assert_eq!( + environment.generated_environments_toml( + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + EnvironmentConfigFile::Missing, + )?, + None + ); + } + TestEnvironment::Docker { .. } => { + assert!(generated.is_some()); + + for override_value in [Some("none"), None] { + assert_eq!( + environment.generated_environments_toml( + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, override_value)], + EnvironmentConfigFile::Missing, + )?, + None + ); + } + assert_eq!( + environment.generated_environments_toml(&[], EnvironmentConfigFile::TestOwned)?, + None + ); + } + TestEnvironment::WineExec => { + assert!(generated.is_some()); + + for override_value in [Some("none"), None] { + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + environment.generated_environments_toml( + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, override_value)], + EnvironmentConfigFile::Missing, + ) + })); + assert!(panic.is_err()); + } + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + environment.generated_environments_toml(&[], EnvironmentConfigFile::TestOwned) + })); + assert!(panic.is_err()); + } + } + + Ok(()) +} + +#[tokio::test] +async fn thread_environment_is_available_only_for_harness_generated_config() -> Result<()> { + match test_environment() { + TestEnvironment::Local => { + let mut environment = TestAppServerEnvironment::new().await?; + assert_eq!( + environment.generated_environments_toml(&[], EnvironmentConfigFile::Missing)?, + None + ); + assert_eq!(environment.generated_thread_environment(), None); + } + TestEnvironment::Docker { .. } | TestEnvironment::WineExec => { + let mut generated_environment = TestAppServerEnvironment::new().await?; + generated_environment + .generated_environments_toml(&[], EnvironmentConfigFile::Missing)? + .context("generated environments.toml")?; + let selected = generated_environment + .generated_thread_environment() + .context("generated thread environment")?; + assert_eq!(selected.environment_id, REMOTE_ENVIRONMENT_ID); + assert!(selected.cwd.to_inferred_path_uri().is_some()); + + let mut restarted_environment = TestAppServerEnvironment::new().await?; + assert_eq!( + restarted_environment + .generated_environments_toml(&[], EnvironmentConfigFile::HarnessGenerated,)?, + None + ); + assert!( + restarted_environment + .generated_thread_environment() + .is_some() + ); + + match test_environment() { + TestEnvironment::Local => unreachable!("remote test branch"), + TestEnvironment::Docker { .. } => { + let mut configured_environment = TestAppServerEnvironment::new().await?; + assert_eq!( + configured_environment + .generated_environments_toml(&[], EnvironmentConfigFile::TestOwned,)?, + None + ); + assert_eq!(configured_environment.generated_thread_environment(), None); + + let mut overridden_environment = TestAppServerEnvironment::new().await?; + assert_eq!( + overridden_environment.generated_environments_toml( + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("test-owned"))], + EnvironmentConfigFile::Missing, + )?, + None + ); + assert_eq!(overridden_environment.generated_thread_environment(), None); + } + TestEnvironment::WineExec => {} + } + } + } + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 7cad6c0388..0328ee5dbc 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; @@ -39,6 +40,9 @@ use super::connection_handling_websocket::spawn_websocket_server; #[tokio::test] async fn command_exec_without_streams_can_be_terminated() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -89,6 +93,9 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { #[tokio::test] async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -137,6 +144,9 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul #[tokio::test] async fn command_exec_env_overrides_merge_with_server_environment_and_support_unset() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -195,6 +205,9 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un #[tokio::test] async fn command_exec_accepts_permission_profile() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -242,6 +255,9 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { #[tokio::test] async fn command_exec_permission_profile_starts_selected_network_proxy() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -293,6 +309,9 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu #[tokio::test] async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -342,6 +361,9 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() #[cfg(unix)] #[tokio::test] async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; let command_dir = codex_home.path().join("command-cwd"); @@ -403,6 +425,10 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu #[tokio::test] async fn command_exec_returns_error_when_local_environment_is_disabled() -> Result<()> { + // TODO(anp): Cover the command/exec disabled-environment error with a cross-OS test + // configuration that does not replace the Wine harness's executor registration. + skip_if_wine_exec!(Ok(()), "uses an explicit executor-disable override"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -442,6 +468,9 @@ async fn command_exec_returns_error_when_local_environment_is_disabled() -> Resu #[tokio::test] async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -480,6 +509,9 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -518,6 +550,9 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { #[tokio::test] async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -556,6 +591,9 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu #[tokio::test] async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -594,6 +632,9 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { #[tokio::test] async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -632,6 +673,9 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { #[tokio::test] async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -679,6 +723,9 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { #[tokio::test] async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -743,6 +790,9 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { #[tokio::test] async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -819,6 +869,9 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { #[tokio::test] async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -890,6 +943,9 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( #[tokio::test] async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { + // TODO(anp): Let command/exec target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "command/exec requires the local environment"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 0c8a59a9c8..58a4948aa5 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use core_test_support::responses; +use core_test_support::skip_if_wine_exec; use futures::SinkExt; use futures::StreamExt; use std::process::Command as StdCommand; @@ -33,6 +34,9 @@ use wiremock::matchers::path_regex; #[tokio::test] async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { + // TODO(anp): Add Windows console-control coverage equivalent to this Unix signal behavior. + skip_if_wine_exec!(Ok(()), "tests Unix signal shutdown"); + let GracefulCtrlCFixture { _codex_home, _server, @@ -58,6 +62,9 @@ async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Resu #[tokio::test] async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { + // TODO(anp): Add Windows console-control coverage equivalent to this Unix signal behavior. + skip_if_wine_exec!(Ok(()), "tests Unix signal shutdown"); + let GracefulCtrlCFixture { _codex_home, _server, @@ -84,6 +91,9 @@ async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> R #[tokio::test] async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { + // TODO(anp): Add Windows console-control coverage equivalent to this Unix signal behavior. + skip_if_wine_exec!(Ok(()), "tests Unix signal shutdown"); + let GracefulCtrlCFixture { _codex_home, _server, @@ -109,6 +119,9 @@ async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Res #[tokio::test] async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { + // TODO(anp): Add Windows console-control coverage equivalent to this Unix signal behavior. + skip_if_wine_exec!(Ok(()), "tests Unix signal shutdown"); + let GracefulCtrlCFixture { _codex_home, _server, @@ -135,6 +148,9 @@ async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> #[tokio::test] async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> Result<()> { + // TODO(anp): Add Windows console-control coverage equivalent to this Unix signal behavior. + skip_if_wine_exec!(Ok(()), "tests Unix signal shutdown"); + let GracefulCtrlCFixture { _codex_home, _server, diff --git a/codex-rs/app-server/tests/suite/v2/environment_add.rs b/codex-rs/app-server/tests/suite/v2/environment_add.rs index bf44b27607..d6c79bed9a 100644 --- a/codex-rs/app-server/tests/suite/v2/environment_add.rs +++ b/codex-rs/app-server/tests/suite/v2/environment_add.rs @@ -6,6 +6,7 @@ use app_test_support::to_response; use codex_app_server_protocol::EnvironmentAddResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use core_test_support::skip_if_wine_exec; use serde_json::json; use tempfile::TempDir; use tokio::io::AsyncReadExt; @@ -17,6 +18,10 @@ const CONNECTION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); #[tokio::test] async fn environment_add_applies_connect_timeout() -> Result<()> { + // TODO(anp): Cover environment/add timeouts in cross-OS configurations by registering a + // second target-native environment without relying on the injected default selection. + skip_if_wine_exec!(Ok(()), "uses an explicit environment"); + let listener = TcpListener::bind("127.0.0.1:0").await?; let exec_server_url = format!("ws://{}", listener.local_addr()?); let stalled_server = tokio::spawn(async move { diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index 0eb22a395c..2e1b3bdcf4 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; use core_test_support::responses; +use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; use pretty_assertions::assert_eq; use serde_json::json; @@ -32,6 +33,10 @@ const TOOL_CALL_ID: &str = "executor-mcp-call"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_executor_plugin_exposes_its_stdio_mcp_only_to_that_thread() -> Result<()> { + // TODO(anp): Support executor-provided stdio MCP servers across host/target OS combinations by + // using the host runfile locally and a copied target-native path remotely. + skip_if_wine_exec!(Ok(()), "uses a host-local stdio MCP fixture"); + let responses_server = responses::start_mock_server().await; let codex_home = TempDir::new()?; write_mock_responses_config_toml( diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills.rs b/codex-rs/app-server/tests/suite/v2/executor_skills.rs index ede5b20fb8..bd835478bf 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; use core_test_support::responses; +use core_test_support::skip_if_wine_exec; use tempfile::TempDir; use tokio::time::timeout; @@ -22,6 +23,10 @@ const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER"; #[tokio::test] async fn selected_executor_root_exposes_plugin_skill() -> Result<()> { + // TODO(anp): Support executor plugin skills across host/target OS combinations by staging the + // fixture in the selected environment and using its target-native capability-root path. + skip_if_wine_exec!(Ok(()), "uses an explicit host-local capability root"); + let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_once( &server, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index d9d3469936..6a70f8dc1f 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_protocol::protocol::RealtimeOutputModality; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use std::path::Path; use std::time::Duration; @@ -225,6 +226,10 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< #[tokio::test] async fn thread_start_mock_field_requires_experimental_api_capability() -> Result<()> { + // TODO(anp): Let the Wine harness supply a target-native default cwd without using the + // experimental thread environment field so this capability check can run cross-OS. + skip_if_wine_exec!(Ok(()), "requires a non-experimental thread/start"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -264,6 +269,10 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul #[tokio::test] async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capability() -> Result<()> { + // TODO(anp): Let the Wine harness supply a target-native default cwd without using the + // experimental thread environment field so this capability check can run cross-OS. + skip_if_wine_exec!(Ok(()), "requires a non-experimental thread/start"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -302,6 +311,10 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa #[tokio::test] async fn thread_start_granular_approval_policy_requires_experimental_api_capability() -> Result<()> { + // TODO(anp): Let the Wine harness supply a target-native default cwd without using the + // experimental thread environment field so this capability check can run cross-OS. + skip_if_wine_exec!(Ok(()), "requires a non-experimental thread/start"); + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index 1f4a4dec38..ae66bcc7eb 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; @@ -67,6 +68,9 @@ fn absolute_path(path: PathBuf) -> AbsolutePathBuf { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let file_path = codex_home.path().join("note.txt"); std::fs::write(&file_path, "hello")?; @@ -121,6 +125,10 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result<()> { + // TODO(anp): Cover the fs RPC disabled-environment errors with a cross-OS test configuration + // that does not replace the Wine harness's executor registration. + skip_if_wine_exec!(Ok(()), "uses an explicit executor-disable override"); + let codex_home = TempDir::new()?; let absolute_file = codex_home.path().join("absolute.txt"); @@ -144,6 +152,9 @@ async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result< #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_reports_symlink() -> Result<()> { + // TODO(anp): Add Windows reparse-point coverage equivalent to this Unix symlink metadata case. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let file_path = codex_home.path().join("note.txt"); let symlink_path = codex_home.path().join("note-link.txt"); @@ -172,6 +183,9 @@ async fn fs_get_metadata_reports_symlink() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let source_dir = codex_home.path().join("source"); let nested_dir = source_dir.join("nested"); @@ -323,6 +337,9 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_write_file_accepts_base64_bytes() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let file_path = codex_home.path().join("blob.bin"); let bytes = [0_u8, 1, 2, 255]; @@ -518,6 +535,9 @@ async fn fs_methods_reject_relative_paths() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_copy_rejects_directory_without_recursive() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let source_dir = codex_home.path().join("source"); std::fs::create_dir_all(&source_dir)?; @@ -545,6 +565,9 @@ async fn fs_copy_rejects_directory_without_recursive() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let source_dir = codex_home.path().join("source"); std::fs::create_dir_all(source_dir.join("nested"))?; @@ -573,6 +596,9 @@ async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> { + // TODO(anp): Add Windows reparse-point coverage equivalent to this Unix symlink-copy case. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let source_dir = codex_home.path().join("source"); let nested_dir = source_dir.join("nested"); @@ -605,6 +631,10 @@ async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> { + // TODO(anp): Exercise this Unix FIFO-copy behavior through an explicit Unix environment when + // the default environment uses Windows paths. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let source_dir = codex_home.path().join("source"); let copied_dir = codex_home.path().join("copied"); @@ -646,6 +676,10 @@ async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { + // TODO(anp): Exercise this Unix FIFO rejection through an explicit Unix environment when the + // default environment uses Windows paths. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let fifo_path = codex_home.path().join("named-pipe"); let output = Command::new("mkfifo").arg(&fifo_path).output()?; @@ -678,6 +712,9 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let git_dir = codex_home.path().join("repo").join(".git"); let fetch_head = git_dir.join("FETCH_HEAD"); @@ -746,6 +783,9 @@ async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifi #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let git_dir = codex_home.path().join("repo").join(".git"); let head_path = git_dir.join("HEAD"); @@ -786,6 +826,9 @@ async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_watch_allows_missing_file_targets() -> Result<()> { + // TODO(anp): Let fs RPCs target the selected filesystem in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "fs RPCs require the local filesystem"); + let codex_home = TempDir::new()?; let git_dir = codex_home.path().join("repo").join(".git"); let fetch_head = git_dir.join("FETCH_HEAD"); diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 1fd1340a6c..8ba86f36fa 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -26,6 +26,7 @@ use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::skip_if_windows; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde::Serialize; use tempfile::TempDir; @@ -652,6 +653,9 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { #[tokio::test] async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); + // TODO(anp): Support hook execution across host/target OS configurations by copying a + // cross-platform fixture into the selected environment and reading its log there. + skip_if_wine_exec!(Ok(()), "uses a host-local hook command"); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, @@ -903,6 +907,9 @@ command = "python3 {hook_script_path}" #[tokio::test] async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); + // TODO(anp): Support hook execution across host/target OS configurations by copying a + // cross-platform fixture into the selected environment and reading its log there. + skip_if_wine_exec!(Ok(()), "uses a host-local hook command"); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 7a29bb3542..b4c77d3edf 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -227,6 +228,10 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> #[tokio::test] async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> { + // TODO(anp): Support attached-image editing across host/target OS configurations by copying + // the fixture into the selected environment and passing its target-native path. + skip_if_wine_exec!(Ok(()), "uses a host-local image path"); + let edit_request = run_image_edit_test(|codex_home| { let image_path = codex_home.join("attached.png"); std::fs::write(&image_path, TINY_PNG_BYTES)?; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 98b0fa3512..25919fd1fe 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -32,6 +32,7 @@ use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use core_test_support::responses; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::ListResourcesResult; @@ -129,6 +130,10 @@ async fn mcp_resource_read_returns_resource_contents() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() -> Result<()> { + // TODO(anp): Support orchestrator resource reads with an explicit empty environment list in + // cross-OS configurations without injecting the remote default. + skip_if_wine_exec!(Ok(()), "uses explicit environments"); + let responses_server = responses::start_mock_server().await; let (apps_server_url, apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; @@ -365,6 +370,10 @@ async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn local_executor_does_not_expose_orchestrator_skills() -> Result<()> { + // TODO(anp): Support local-executor capability filtering assertions in cross-OS + // configurations that also select a remote default environment. + skip_if_wine_exec!(Ok(()), "asserts local executor capabilities"); + let responses_server = responses::start_mock_server().await; let (apps_server_url, _apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; @@ -438,6 +447,10 @@ async fn local_executor_does_not_expose_orchestrator_skills() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn disabled_orchestrator_skills_do_not_expose_skills_namespace() -> Result<()> { + // TODO(anp): Cover disabled orchestrator skills in cross-OS configurations without replacing + // the harness-selected remote environment with an explicit empty selection. + skip_if_wine_exec!(Ok(()), "explicitly disables thread environments"); + let responses_server = responses::start_mock_server().await; let (apps_server_url, apps_server_calls, apps_server_handle) = start_resource_apps_mcp_server().await?; diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index ac4f6eeda3..4b4402adce 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::RequestId; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; @@ -22,6 +23,9 @@ use super::connection_handling_websocket::create_config_toml; #[tokio::test] async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Result<()> { + // TODO(anp): Let process RPCs target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "process RPCs require the local environment"); + let codex_home = TempDir::new()?; let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; @@ -105,6 +109,10 @@ async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Resu #[tokio::test] async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Result<()> { + // TODO(anp): Cover the process/spawn disabled-environment error with a cross-OS test + // configuration that does not replace the Wine harness's executor registration. + skip_if_wine_exec!(Ok(()), "uses an explicit executor-disable override"); + let codex_home = TempDir::new()?; let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home.path(), &server.uri(), "never")?; @@ -132,6 +140,9 @@ async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Res #[tokio::test] async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { + // TODO(anp): Let process RPCs target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "process RPCs require the local environment"); + let codex_home = TempDir::new()?; let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; @@ -181,6 +192,9 @@ async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { #[tokio::test] async fn process_kill_terminates_running_process() -> Result<()> { + // TODO(anp): Let process RPCs target the selected environment in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "process RPCs require the local environment"); + let codex_home = TempDir::new()?; let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 199e3a72d5..368b3aadb4 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -56,6 +56,7 @@ use core_test_support::responses::WebSocketTestServer; use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde::de::DeserializeOwned; use serde_json::Value; @@ -2491,6 +2492,9 @@ async fn websocket_v2_background_agent_progress_is_sent_before_function_output() #[tokio::test] async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<()> { skip_if_no_network!(Ok(())); + // TODO(anp): Support delegated shell execution across host/target OS configurations by + // choosing the command for the selected execution environment. + skip_if_wine_exec!(Ok(()), "uses a POSIX shell command"); // Phase 1: keep the two mocked OpenAI conversations explicit. The realtime sideband only // calls the `background_agent` function; the shell command is requested by the delegated diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index bea78901f5..18657526f3 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -16,12 +16,17 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::skip_if_wine_exec; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn request_permissions_round_trip() -> Result<()> { + // TODO(anp): Support request_permissions across host/target OS configurations by preserving + // the selected environment's cwd and granted filesystem paths without host projection. + skip_if_wine_exec!(Ok(()), "requires host-native permission paths"); + let codex_home = tempfile::TempDir::new()?; let responses = vec![ create_request_permissions_sse_response("call1")?, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index ef7ee6c1d1..fc8ebc2fcb 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -403,6 +404,10 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable #[tokio::test] async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { + // TODO(anp): Cover disabled-environment skill discovery with a cross-OS test configuration + // that does not replace the Wine harness's executor registration. + skip_if_wine_exec!(Ok(()), "uses an explicit executor-disable override"); + let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_skill(&codex_home, "home-skill")?; @@ -518,6 +523,10 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { #[tokio::test] async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { + // TODO(anp): Support skills/list cache invalidation across host/target OS configurations by + // reading each cwd through its selected environment. + skip_if_wine_exec!(Ok(()), "uses a host-local skills cwd"); + let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -740,6 +749,10 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { + // TODO(anp): Support skills-change notifications across host/target OS configurations by + // watching each user-skill root through its owning environment. + skip_if_wine_exec!(Ok(()), "uses host-local skill watcher paths"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; write_mock_responses_config_toml_with_chatgpt_base_url( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index ea7a1a9d46..929c6bdeea 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -85,6 +85,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::json; use std::fs::FileTimes; @@ -262,6 +263,10 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { #[tokio::test] async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + // TODO(anp): Support cached instruction sources across host/target OS configurations by + // seeding AGENTS.md in the selected environment and asserting its target-native path. + skip_if_wine_exec!(Ok(()), "uses host-local instruction source paths"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index df7b4991d6..296b3e8323 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -35,6 +35,7 @@ use codex_core::shell::default_user_shell; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::FEATURES; use codex_features::Feature; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::path::Path; @@ -46,6 +47,10 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test] async fn thread_shell_command_history_responses_exclude_persisted_command_executions() -> Result<()> { + // TODO(anp): Support thread/shellCommand history in cross-OS configurations by targeting the + // selected environment instead of requiring the app-server host. + skip_if_wine_exec!(Ok(()), "exercises the local thread shell command"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -182,6 +187,10 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut #[tokio::test] async fn thread_shell_command_returns_error_when_local_environment_is_disabled() -> Result<()> { + // TODO(anp): Let the Wine harness distinguish an intentionally environment-disabled thread + // from an unsafe fallback so this error path can run in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "intentionally disables all environments"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -225,6 +234,9 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled() #[tokio::test] async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { + // TODO(anp): Support thread/shellCommand on selected environments in cross-OS configurations. + skip_if_wine_exec!(Ok(()), "thread/shellCommand requires the local environment"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index dcfeb4fa94..913a7d141a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -33,6 +33,7 @@ use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -281,6 +282,10 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro #[tokio::test] async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { + // TODO(anp): Support unknown-environment validation in cross-OS configurations while + // preserving the caller's explicit selection over the injected default. + skip_if_wine_exec!(Ok(()), "uses explicit environments"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -317,6 +322,10 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result #[tokio::test] async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> Result<()> { + // TODO(anp): Exercise malformed environment cwd validation in cross-OS configurations without + // replacing the Wine harness's required target-native selection. + skip_if_wine_exec!(Ok(()), "uses an explicit invalid environment cwd"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; @@ -351,6 +360,10 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R #[tokio::test] async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { + // TODO(anp): Support instruction-source reporting across host/target OS configurations by + // seeding project AGENTS.md in the selected environment and preserving both path conventions. + skip_if_wine_exec!(Ok(()), "uses host-local instruction source paths"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; @@ -442,6 +455,10 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re #[tokio::test] async fn thread_start_without_selected_environment_includes_only_global_instruction_source() -> Result<()> { + // TODO(anp): Support explicit empty environment selections in cross-OS configurations while + // suppressing project instruction discovery from the remote default. + skip_if_wine_exec!(Ok(()), "uses explicit environments"); + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 335d32776d..3ee1fac158 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::skip_if_wine_exec; use tempfile::TempDir; use tokio::time::timeout; @@ -30,6 +31,10 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn turn_interrupt_aborts_running_turn() -> Result<()> { + // TODO(anp): Support turn interruption across host/target OS configurations by creating the + // workdir in the selected environment and using its native sleep command. + skip_if_wine_exec!(Ok(()), "uses a host-local workdir and sleep command"); + // Use a portable sleep command to keep the turn running. #[cfg(target_os = "windows")] let shell_command = vec![ diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3edd8da6fb..cc82aebbeb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -81,6 +81,7 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -2191,6 +2192,9 @@ async fn turn_start_forwards_custom_local_image_detail() -> Result<()> { #[tokio::test] async fn turn_start_exec_approval_toggle_v2() -> Result<()> { skip_if_no_network!(Ok(())); + // TODO(anp): Support approval-policy changes across host/target OS configurations by using an + // executor-native command and asserting the selected environment. + skip_if_wine_exec!(Ok(()), "uses a host-local Python shell command"); let tmp = TempDir::new()?; let codex_home = tmp.path().to_path_buf(); @@ -2817,6 +2821,10 @@ stream_max_retries = 0 #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { + // TODO(anp): Provide target-native Wine executor fixtures for sticky and turn-level + // environment selection so this resolution matrix can run cross-OS. + skip_if_wine_exec!(Ok(()), "uses test-owned environment configuration"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -2965,6 +2973,9 @@ fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option Result<()> { skip_if_no_network!(Ok(())); + // TODO(anp): Support file-change approval across host/target OS configurations by creating the + // patch workspace and asserting the result through the selected environment's filesystem. + skip_if_wine_exec!(Ok(()), "uses a host-local patch workspace"); let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -3951,6 +3962,9 @@ config_file = "./custom-role.toml" #[tokio::test] async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Result<()> { skip_if_no_network!(Ok(())); + // TODO(anp): Support session-scoped file-change approval across host/target OS configurations + // by creating and inspecting the patch workspace through the selected environment. + skip_if_wine_exec!(Ok(()), "uses a host-local patch workspace"); let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index f41a727b72..54957c42e8 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -7,4 +7,5 @@ codex_rust_crate( deps_extra = [ "@crates//:similar", ], + run_tests_with_wine_exec = True, ) diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 523d7f854e..9720ee8c9b 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -37,6 +37,7 @@ pub mod tracing; pub mod zsh_fork; pub use test_environment::TestEnvironment; +pub use test_environment::TestExecutionEnvironment; pub use test_environment::get_remote_test_env; pub use test_environment::test_environment; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index d843e0097b..88364efd02 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -3,16 +3,13 @@ use std::io::ErrorKind; use std::mem::swap; use std::path::Path; use std::path::PathBuf; -use std::process::Command; use std::sync::Arc; -use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Context; use anyhow::Result; -use anyhow::anyhow; use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::StartThreadOptions; @@ -57,9 +54,6 @@ use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; -use crate::TempDirExt; -use crate::TestEnvironment; -use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::load_default_config_for_test_with_cloud_config_bundle; use crate::responses::WebSocketTestServer; @@ -76,10 +70,11 @@ type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; type WorkspaceSetup = dyn FnOnce(AbsolutePathBuf, Arc) -> BoxFuture<'static, Result<()>> + Send; const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex"; -const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL"; -static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); const SUBMIT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); +pub use crate::test_environment::TestExecutionEnvironment as TestEnv; +pub use crate::test_environment::test_execution_environment as test_env; + pub struct RecordingUserInstructionsProvider { inner: Arc, load_count: AtomicUsize, @@ -116,130 +111,6 @@ pub fn local_selections(cwd: AbsolutePathBuf) -> TurnEnvironmentSelections { TurnEnvironmentSelections::new(cwd.clone(), vec![local(cwd)]) } -#[derive(Debug)] -pub struct TestEnv { - environment: codex_exec_server::Environment, - exec_server_url: Option, - cwd: AbsolutePathBuf, - local_cwd_temp_dir: Option>, - remote_container_name: Option, -} - -impl TestEnv { - pub async fn local() -> Result { - let local_cwd_temp_dir = Arc::new(TempDir::new()?); - let cwd = local_cwd_temp_dir.abs(); - let environment = - codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None)?; - Ok(Self { - environment, - exec_server_url: None, - cwd, - local_cwd_temp_dir: Some(local_cwd_temp_dir), - remote_container_name: None, - }) - } - - pub fn cwd(&self) -> &AbsolutePathBuf { - &self.cwd - } - - pub fn environment(&self) -> &codex_exec_server::Environment { - &self.environment - } - - fn local_cwd_temp_dir(&self) -> Option> { - self.local_cwd_temp_dir.clone() - } -} - -impl Drop for TestEnv { - fn drop(&mut self) { - if let Some(container_name) = &self.remote_container_name { - let script = format!("rm -rf {}", self.cwd.as_path().display()); - let _ = docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &script]); - } - } -} - -pub async fn test_env() -> Result { - match get_remote_test_env() { - Some(remote_env) => { - let websocket_url = remote_exec_server_url()?; - let environment = - codex_exec_server::Environment::create_for_tests(Some(websocket_url.clone()))?; - let cwd = remote_env - .remote_cwd(&remote_test_instance_id())? - .context("remote test environment should define a cwd")?; - let cwd_uri = cwd.to_path_uri(remote_env.path_convention())?; - environment - .get_filesystem() - .create_directory( - &cwd_uri, - CreateDirectoryOptions { recursive: true }, - /*sandbox*/ None, - ) - .await?; - let cwd = if remote_env == TestEnvironment::WineExec { - // TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this - // compatibility projection. - // `Config::cwd` still requires `AbsolutePathBuf`. Preserve the test harness's - // Linux-absolute `/C:/...` compatibility spelling so converting it back to a - // `PathUri` recovers the remote Windows convention. Production conversions stay - // strict: `PathUri::to_abs_path` intentionally rejects foreign paths. - let path = cwd_uri.to_url().to_file_path().map_err(|()| { - anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}") - })?; - AbsolutePathBuf::try_from(path)? - } else { - cwd_uri.to_abs_path()? - }; - Ok(TestEnv { - environment, - exec_server_url: Some(websocket_url), - cwd, - local_cwd_temp_dir: None, - remote_container_name: remote_env.docker_container_name().map(str::to_owned), - }) - } - None => TestEnv::local().await, - } -} - -fn remote_exec_server_url() -> Result { - let listen_url = std::env::var(REMOTE_EXEC_SERVER_URL_ENV_VAR).with_context(|| { - format!("{REMOTE_EXEC_SERVER_URL_ENV_VAR} must be set for remote tests") - })?; - let listen_url = listen_url.trim(); - if listen_url.is_empty() { - return Err(anyhow!( - "{REMOTE_EXEC_SERVER_URL_ENV_VAR} must not be empty" - )); - } - Ok(listen_url.to_string()) -} - -fn remote_test_instance_id() -> String { - let instance = REMOTE_TEST_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{}-{instance}", std::process::id()) -} - -fn docker_command_capture_stdout(args: [&str; N]) -> Result { - let output = Command::new("docker") - .args(args) - .output() - .with_context(|| format!("run docker {args:?}"))?; - if !output.status.success() { - return Err(anyhow!( - "docker {:?} failed: stdout={} stderr={}", - args, - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - String::from_utf8(output.stdout).context("docker stdout must be utf-8") -} - /// Non-default apply_patch model output shapes used by compatibility tests. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ApplyPatchModelOutput { @@ -515,7 +386,7 @@ impl TestCodexBuilder { let exec_server_url = self .exec_server_url .clone() - .or_else(|| test_env.exec_server_url.clone()); + .or_else(|| test_env.exec_server_url().map(str::to_owned)); #[cfg(target_os = "linux")] let codex_linux_sandbox_exe = Some( crate::find_codex_linux_sandbox_exe() diff --git a/codex-rs/core/tests/common/test_environment.rs b/codex-rs/core/tests/common/test_environment.rs index ad2a335f56..c774af66bf 100644 --- a/codex-rs/core/tests/common/test_environment.rs +++ b/codex-rs/core/tests/common/test_environment.rs @@ -1,13 +1,27 @@ use std::ffi::OsStr; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::Environment; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; +use tempfile::TempDir; pub const TEST_ENVIRONMENT_ENV_VAR: &str = "CODEX_TEST_ENVIRONMENT"; pub const LEGACY_REMOTE_ENV_ENV_VAR: &str = "CODEX_TEST_REMOTE_ENV"; pub const DOCKER_CONTAINER_ENV_VAR: &str = "CODEX_TEST_REMOTE_ENV_CONTAINER_NAME"; +const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL"; +static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Debug, Eq, PartialEq)] pub enum TestEnvironment { @@ -27,32 +41,154 @@ impl TestEnvironment { Self::Local | Self::WineExec => None, } } +} - pub(crate) fn remote_cwd(&self, instance_id: &str) -> Result> { - let path_uri = match self { - Self::Local => return Ok(None), - Self::Docker { .. } => { - PathUri::parse(&format!("file:///tmp/codex-core-test-cwd-{instance_id}"))? - } - Self::WineExec => { - // Each Wine-exec test process has an isolated filesystem root, so this drive-root - // path cannot collide with a different Bazel shard. - PathUri::parse(&format!("file:///C:/codex-core-test-cwd-{instance_id}"))? - } - }; - Ok(Some(LegacyAppPathString::from_path_uri( - &path_uri, - self.path_convention(), - )?)) - } +/// Owns the execution environment and workspace selected for an integration test. +/// +/// Local tests receive a temporary local directory. Docker and Wine-exec tests receive a unique +/// directory created through the configured remote exec server. +#[derive(Debug)] +pub struct TestExecutionEnvironment { + environment: Environment, + cwd: AbsolutePathBuf, + environment_cwd: LegacyAppPathString, + state: TestExecutionEnvironmentState, +} - pub(crate) fn path_convention(&self) -> PathConvention { - match self { - Self::Local => PathConvention::native(), - Self::Docker { .. } => PathConvention::Posix, - Self::WineExec => PathConvention::Windows, +impl TestExecutionEnvironment { + pub async fn new() -> Result { + match test_environment() { + TestEnvironment::Local => Self::local().await, + TestEnvironment::Docker { container_name } => { + Self::remote(RemoteTestEnvironment::Docker { container_name }).await + } + TestEnvironment::WineExec => Self::remote(RemoteTestEnvironment::WineExec).await, } } + + async fn remote(remote: RemoteTestEnvironment) -> Result { + let websocket_url = remote_exec_server_url()?; + let environment = Environment::create_for_tests(Some(websocket_url))?; + let instance_id = remote_test_instance_id(); + let (cwd_uri, path_convention) = match &remote { + RemoteTestEnvironment::Docker { .. } => ( + PathUri::parse(&format!("file:///tmp/codex-test-cwd-{instance_id}"))?, + PathConvention::Posix, + ), + RemoteTestEnvironment::WineExec => { + // Each Wine-exec test process has an isolated filesystem root, so this drive-root + // path cannot collide with a different Bazel shard. + ( + PathUri::parse(&format!("file:///C:/codex-test-cwd-{instance_id}"))?, + PathConvention::Windows, + ) + } + }; + let environment_cwd = LegacyAppPathString::from_path_uri(&cwd_uri, path_convention)?; + environment + .get_filesystem() + .create_directory( + &cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + let cwd = match &remote { + RemoteTestEnvironment::Docker { .. } => cwd_uri.to_abs_path()?, + RemoteTestEnvironment::WineExec => { + // TODO(anp): Change `Config::cwd` to `LegacyAppPathString` so this host-compatible + // projection can be removed. + let path = cwd_uri.to_url().to_file_path().map_err(|()| { + anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}") + })?; + AbsolutePathBuf::try_from(path)? + } + }; + let state = match remote { + RemoteTestEnvironment::Docker { container_name } => { + TestExecutionEnvironmentState::Docker { container_name } + } + RemoteTestEnvironment::WineExec => TestExecutionEnvironmentState::WineExec, + }; + Ok(Self { + environment, + cwd, + environment_cwd, + state, + }) + } + + pub async fn local() -> Result { + let local_cwd_temp_dir = Arc::new(TempDir::new()?); + let cwd = AbsolutePathBuf::from_absolute_path_checked(local_cwd_temp_dir.path())?; + let environment = Environment::create_for_tests(/*exec_server_url*/ None)?; + Ok(Self { + environment, + environment_cwd: LegacyAppPathString::from_abs_path(&cwd), + cwd, + state: TestExecutionEnvironmentState::Local { + temp_dir: local_cwd_temp_dir, + }, + }) + } + + pub fn environment_id(&self) -> &'static str { + match self.state { + TestExecutionEnvironmentState::Local { .. } => LOCAL_ENVIRONMENT_ID, + TestExecutionEnvironmentState::Docker { .. } + | TestExecutionEnvironmentState::WineExec => REMOTE_ENVIRONMENT_ID, + } + } + + pub fn exec_server_url(&self) -> Option<&str> { + self.environment.exec_server_url() + } + + pub fn cwd(&self) -> &AbsolutePathBuf { + &self.cwd + } + + pub fn environment_cwd(&self) -> &LegacyAppPathString { + &self.environment_cwd + } + + pub fn environment(&self) -> &Environment { + &self.environment + } + + pub(crate) fn local_cwd_temp_dir(&self) -> Option> { + match &self.state { + TestExecutionEnvironmentState::Local { temp_dir, .. } => Some(Arc::clone(temp_dir)), + TestExecutionEnvironmentState::Docker { .. } + | TestExecutionEnvironmentState::WineExec => None, + } + } +} + +impl Drop for TestExecutionEnvironment { + fn drop(&mut self) { + if let TestExecutionEnvironmentState::Docker { container_name } = &self.state { + let script = format!("rm -rf {}", self.cwd.as_path().display()); + let _ = docker_command_capture_stdout(["exec", container_name, "sh", "-lc", &script]); + } + } +} + +#[derive(Debug)] +enum TestExecutionEnvironmentState { + Local { temp_dir: Arc }, + Docker { container_name: String }, + WineExec, +} + +#[derive(Debug)] +enum RemoteTestEnvironment { + Docker { container_name: String }, + WineExec, +} + +pub async fn test_execution_environment() -> Result { + TestExecutionEnvironment::new().await } pub fn test_environment() -> TestEnvironment { @@ -129,6 +265,40 @@ fn non_empty_utf8(name: &str, value: &OsStr) -> Result { Ok(value.to_string()) } +fn remote_exec_server_url() -> Result { + let listen_url = std::env::var(REMOTE_EXEC_SERVER_URL_ENV_VAR).with_context(|| { + format!("{REMOTE_EXEC_SERVER_URL_ENV_VAR} must be set for remote tests") + })?; + let listen_url = listen_url.trim(); + if listen_url.is_empty() { + return Err(anyhow!( + "{REMOTE_EXEC_SERVER_URL_ENV_VAR} must not be empty" + )); + } + Ok(listen_url.to_string()) +} + +fn remote_test_instance_id() -> String { + let instance = REMOTE_TEST_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{instance}", std::process::id()) +} + +fn docker_command_capture_stdout(args: [&str; N]) -> Result { + let output = Command::new("docker") + .args(args) + .output() + .with_context(|| format!("run docker {args:?}"))?; + if !output.status.success() { + return Err(anyhow!( + "docker {:?} failed: stdout={} stderr={}", + args, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("docker stdout must be utf-8") +} + #[cfg(test)] #[path = "test_environment_tests.rs"] mod tests; diff --git a/codex-rs/core/tests/common/tests/test_environment.rs b/codex-rs/core/tests/common/tests/test_environment.rs new file mode 100644 index 0000000000..83b5a5f6e6 --- /dev/null +++ b/codex-rs/core/tests/common/tests/test_environment.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_exec_server::ExecOutputStream; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecProcessEvent; +use codex_exec_server::ProcessId; +use core_test_support::TestEnvironment; +use core_test_support::TestExecutionEnvironment; +use core_test_support::test_environment; +use pretty_assertions::assert_eq; +use tokio::time::timeout; + +const EXPECTED_EXEC_OUTPUT: &str = "hello from the selected executor"; + +#[tokio::test] +async fn selected_execution_environment_runs_target_native_command() -> Result<()> { + let execution = TestExecutionEnvironment::new().await?; + let shell = execution.environment().info().await?.shell; + let command = match test_environment() { + TestEnvironment::WineExec => vec![ + "cmd.exe".to_string(), + "/d".to_string(), + "/c".to_string(), + format!("echo {EXPECTED_EXEC_OUTPUT}"), + ], + TestEnvironment::Local if cfg!(windows) => vec![ + "cmd.exe".to_string(), + "/d".to_string(), + "/c".to_string(), + format!("echo {EXPECTED_EXEC_OUTPUT}"), + ], + TestEnvironment::Local | TestEnvironment::Docker { .. } => vec![ + shell.path, + "-lc".to_string(), + format!("printf '{EXPECTED_EXEC_OUTPUT}'"), + ], + }; + let cwd = execution + .environment_cwd() + .to_inferred_path_uri() + .context("test environment cwd should be absolute")?; + let started = execution + .environment() + .get_exec_backend() + .start(ExecParams { + process_id: ProcessId::from("test-execution-environment-smoke"), + argv: command, + cwd, + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await?; + let mut events = started.process.subscribe_events(); + let mut output = Vec::new(); + let mut exit_code = None; + loop { + match timeout(Duration::from_secs(10), events.recv()).await?? { + ExecProcessEvent::Output(chunk) + if matches!( + chunk.stream, + ExecOutputStream::Stdout | ExecOutputStream::Pty + ) => + { + output.extend(chunk.chunk.0); + } + ExecProcessEvent::Output(_) => {} + ExecProcessEvent::Exited { + exit_code: code, .. + } => exit_code = Some(code), + ExecProcessEvent::Closed { .. } => break, + ExecProcessEvent::Failed(error) => bail!("test execution failed: {error}"), + } + } + + let output = String::from_utf8(output)?; + assert_eq!(output.trim_end_matches(['\r', '\n']), EXPECTED_EXEC_OUTPUT); + assert_eq!(exit_code, Some(0)); + Ok(()) +}