use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex; use anyhow::Context; use anyhow::Result; use codex_exec_server::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; use codex_exec_server::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR; use codex_exec_server::CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server_test_support::relay::TEST_TIMEOUT as VERSION_SKEW_TIMEOUT; use codex_exec_server_test_support::relay::accept_websocket; use codex_exec_server_test_support::relay::assert_relay_data_is_encrypted; use codex_exec_server_test_support::relay::proxy_relay_frames; use codex_exec_server_test_support::relay::registered_executor_public_key; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::io::Lines; use tokio::net::TcpListener; use tokio::process::ChildStdin; use tokio::process::ChildStdout; use tokio::process::Command; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; const ENVIRONMENT_ID: &str = "env-noise-relay-test"; const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; const HARNESS_KEY_AUTHORIZATION: &str = "harness-key-authorization"; const REGISTRY_TOKEN: &str = "registry-token"; const RELEASED_CODEX_ENV_VAR: &str = "CODEX_TEST_RELEASED_CODEX"; const CURRENT_CODEX_ENV_VAR: &str = "CODEX_TEST_CURRENT_CODEX"; const EXECUTOR_MARKER_ENV_VAR: &str = "CODEX_EXECUTOR_VERSION_SKEW_MARKER"; const EXPECTED_OUTPUT: &str = "executor-version-skew-ok"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn current_app_server_runs_commands_on_released_exec_server_over_noise() -> Result<()> { let Some((current, released)) = version_skew_binaries()? else { return Ok(()); }; assert_noise_version_skew(¤t, &released).await } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn released_app_server_runs_commands_on_current_exec_server_over_noise() -> Result<()> { let Some((current, released)) = version_skew_binaries()? else { return Ok(()); }; assert_noise_version_skew(&released, ¤t).await } fn version_skew_binaries() -> Result> { let Some(released) = std::env::var_os(RELEASED_CODEX_ENV_VAR) else { return Ok(None); }; let current = std::env::var_os(CURRENT_CODEX_ENV_VAR) .with_context(|| format!("{CURRENT_CODEX_ENV_VAR} must name the current Codex binary"))?; let current = PathBuf::from(current); let released = PathBuf::from(released); anyhow::ensure!( current.is_file(), "current Codex does not exist: {}", current.display() ); anyhow::ensure!( released.is_file(), "released Codex does not exist: {}", released.display() ); Ok(Some((current, released))) } async fn assert_noise_version_skew(app_binary: &Path, executor_binary: &Path) -> Result<()> { let codex_home = TempDir::new()?; let model = mock_model(codex_home.path()).await?; let model_url = model.uri(); std::fs::write( codex_home.path().join("config.toml"), format!( r#" model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" model_provider = "mock_provider" [model_providers.mock_provider] name = "Mock provider" base_url = "{model_url}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "# ), )?; let listener = TcpListener::bind("127.0.0.1:0").await?; let rendezvous_url = format!("ws://{}", listener.local_addr()?); let registry = MockServer::start().await; let registry_url = registry.uri(); Mock::given(method("POST")) .and(path(format!( "/cloud/environment/{ENVIRONMENT_ID}/register" ))) .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "environment_id": ENVIRONMENT_ID, "url": format!("{rendezvous_url}/relay?role=environment"), "security_profile": "noise_hybrid_ik_v1", "executor_registration_id": EXECUTOR_REGISTRATION_ID, }))) .expect(1) .mount(®istry) .await; Mock::given(method("POST")) .and(path(format!( "/cloud/environment/{ENVIRONMENT_ID}/validate" ))) .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"valid": true}))) .expect(1) .mount(®istry) .await; let mut executor = Command::new(executor_binary) .args([ "exec-server", "--remote", registry_url.as_str(), "--environment-id", ENVIRONMENT_ID, ]) .current_dir(codex_home.path()) .env("CODEX_HOME", codex_home.path()) .env("CODEX_API_KEY", REGISTRY_TOKEN) .env(EXECUTOR_MARKER_ENV_VAR, EXPECTED_OUTPUT) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn() .with_context(|| format!("start remote executor from {}", executor_binary.display()))?; let environment_websocket = accept_websocket(&listener, "environment").await?; let executor_public_key = registered_executor_public_key(®istry).await?; Mock::given(method("POST")) .and(path(format!("/cloud/environment/{ENVIRONMENT_ID}/connect"))) .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "environment_id": ENVIRONMENT_ID, "url": format!("{rendezvous_url}/relay?role=harness"), "security_profile": "noise_hybrid_ik_v1", "executor_registration_id": EXECUTOR_REGISTRATION_ID, "executor_public_key": executor_public_key, "harness_key_authorization": HARNESS_KEY_AUTHORIZATION, }))) .expect(1) .mount(®istry) .await; let captured_frames = Arc::new(Mutex::new(Vec::new())); let captured_relay_frames = Arc::clone(&captured_frames); let relay = tokio::spawn(async move { let harness_websocket = accept_websocket(&listener, "harness").await?; proxy_relay_frames( environment_websocket, harness_websocket, captured_relay_frames, ) .await }); let mut app_server = Command::new(app_binary) .arg("app-server") .current_dir(codex_home.path()) .env("CODEX_HOME", codex_home.path()) .env("CODEX_API_KEY", REGISTRY_TOKEN) .env( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", codex_home.path().join("managed_config.toml"), ) .env(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR, ®istry_url) .env( CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR, ENVIRONMENT_ID, ) .env(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, REGISTRY_TOKEN) .env_remove(CODEX_EXEC_SERVER_URL_ENV_VAR) .env_remove(EXECUTOR_MARKER_ENV_VAR) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn() .with_context(|| format!("start app-server from {}", app_binary.display()))?; let mut stdin = app_server.stdin.take().context("app-server stdin")?; let stdout = app_server.stdout.take().context("app-server stdout")?; let mut stdout = BufReader::new(stdout).lines(); let mut notifications = Vec::new(); app_server_request( &mut stdin, &mut stdout, &mut notifications, /*id*/ 1, "initialize", json!({ "clientInfo": {"name": "noise-version-skew", "version": "0.1.0"}, "capabilities": {"experimentalApi": true}, }), ) .await?; stdin.write_all(b"{\"method\":\"initialized\"}\n").await?; let thread = app_server_request( &mut stdin, &mut stdout, &mut notifications, /*id*/ 2, "thread/start", json!({"cwd": codex_home.path()}), ) .await?; let thread_id = thread["thread"]["id"] .as_str() .context("thread/start should return a thread id")?; app_server_request( &mut stdin, &mut stdout, &mut notifications, /*id*/ 3, "turn/start", json!({ "threadId": thread_id, "input": [{ "type": "text", "text": "run the Noise compatibility command", "textElements": [], }], }), ) .await?; while !notifications .iter() .any(|notification: &Value| notification["method"] == "turn/completed") { let line = timeout(VERSION_SKEW_TIMEOUT, stdout.next_line()) .await .context("waiting for turn/completed")?? .context("app-server exited before turn/completed")?; notifications.push(serde_json::from_str(&line)?); } assert_eq!( std::fs::read_to_string(codex_home.path().join("version-skew-output.txt"))?, EXPECTED_OUTPUT ); assert_relay_data_is_encrypted(&captured_frames)?; registry.verify().await; model.verify().await; let _ = app_server.start_kill(); let _ = executor.start_kill(); relay.abort(); Ok(()) } async fn app_server_request( stdin: &mut ChildStdin, stdout: &mut Lines>, notifications: &mut Vec, id: u64, method: &str, params: Value, ) -> Result { let request = json!({"id": id, "method": method, "params": params}); stdin.write_all(request.to_string().as_bytes()).await?; stdin.write_all(b"\n").await?; loop { let line = timeout(VERSION_SKEW_TIMEOUT, stdout.next_line()) .await .with_context(|| format!("waiting for {method} response"))?? .with_context(|| format!("app-server exited before {method} response"))?; let response: Value = serde_json::from_str(&line)?; if response["id"] == id { anyhow::ensure!( response.get("error").is_none(), "{method} failed: {}", response["error"] ); return response.get("result").cloned().context("missing result"); } notifications.push(response); } } async fn mock_model(codex_home: &Path) -> Result { let server = MockServer::start().await; let arguments = serde_json::to_string(&json!({ "cmd": format!("printf '%s' \"${EXECUTOR_MARKER_ENV_VAR}\" > version-skew-output.txt"), "workdir": codex_home, "yield_time_ms": 5_000, }))?; let completed = |id| { json!({ "type": "response.completed", "response": { "id": id, "usage": { "input_tokens": 0, "input_tokens_details": null, "output_tokens": 0, "output_tokens_details": null, "total_tokens": 0, }, }, }) }; let responses = vec![ event_stream(vec![ json!({"type": "response.created", "response": {"id": "response-1"}}), json!({ "type": "response.output_item.done", "item": { "type": "function_call", "call_id": "noise-version-skew-command", "name": "exec_command", "arguments": arguments, }, }), completed("response-1"), ])?, event_stream(vec![ json!({"type": "response.created", "response": {"id": "response-2"}}), json!({ "type": "response.output_item.done", "item": { "type": "message", "role": "assistant", "id": "message-1", "content": [{"type": "output_text", "text": "done"}], }, }), completed("response-2"), ])?, ]; for response in responses { Mock::given(method("POST")) .and(path("/v1/responses")) .respond_with(ResponseTemplate::new(200).set_body_raw(response, "text/event-stream")) .up_to_n_times(1) .expect(1) .mount(&server) .await; } Ok(server) } fn event_stream(events: Vec) -> Result { events .into_iter() .map(|event| { let event_type = event["type"].as_str().context("SSE event type")?; Ok(format!("event: {event_type}\ndata: {event}\n\n")) }) .collect() }