From 8d32abcd017d06511b46050cff9dbba8738fc2fa Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Wed, 2 Sep 2026 11:56:44 +0000 Subject: [PATCH] Report the exec-server release version in environment info (#42270) ## Why Clients need the executor's release version to make version-based compatibility decisions. ## What changed - Add `executorVersion` to `EnvironmentInfo` and return it from both `initialize` and `environment/info`. - Resolve the package release version at process startup so the advertised value remains stable for the server's lifetime. - Use `0.0.0` when the version is unknown or omitted by a legacy executor. ## Testing - Cover packaged and source-build initialization, startup caching, protocol serialization, and client-side metadata caching. GitOrigin-RevId: a0c7f0b701a8e7e50345cd5fb1b5c5d2d38ff3b1 --- codex-rs/Cargo.lock | 2 + codex-rs/Cargo.toml | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 1 + codex-rs/cli/tests/exec_server.rs | 22 ++++++++- codex-rs/exec-server-protocol/src/protocol.rs | 15 +++++- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/README.md | 3 ++ codex-rs/exec-server/src/client.rs | 4 ++ codex-rs/exec-server/src/server.rs | 1 + codex-rs/exec-server/src/server/handler.rs | 4 +- codex-rs/exec-server/src/server/processor.rs | 3 ++ .../exec-server/src/server/release_version.rs | 12 +++++ .../exec-server/tests/common/exec_server.rs | 40 ++++++++------- codex-rs/exec-server/tests/initialize.rs | 49 +++++++++++++++++-- 15 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 codex-rs/exec-server/src/server/release_version.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5c3da721a4..9685f5f1a6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2427,6 +2427,7 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", + "codex-build-info", "codex-chatgpt", "codex-cloud-config", "codex-cloud-tasks", @@ -3069,6 +3070,7 @@ dependencies = [ "bytes", "clatter", "codex-api", + "codex-build-info", "codex-config", "codex-exec-server-protocol", "codex-exec-server-test-support", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 523813b9aa..d257adee34 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -177,6 +177,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-build-info = { path = "build-info" } codex-chatgpt = { path = "chatgpt" } codex-client = { path = "codex-client" } codex-collaboration-mode-templates = { path = "collaboration-mode-templates" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c94dd50662..bad12f5594 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -32,6 +32,7 @@ codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-build-info = { workspace = true } codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-config = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 71d0323735..432f242743 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1036,6 +1036,7 @@ fn stage_str(stage: Stage) -> &'static str { } fn main() -> anyhow::Result<()> { + codex_build_info::initialize!(); let remote_control_disabled = codex_app_server::take_remote_control_disabled_env(); arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move { cli_main(arg0_paths, remote_control_disabled).await?; diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index b54b3159a3..6e975a6226 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -19,6 +19,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; +use codex_exec_server::EnvironmentInfo; use codex_exec_server::ExecParams; use codex_exec_server::ExecServerClient; use codex_exec_server::NoiseChannelIdentity; @@ -32,6 +33,7 @@ use futures::SinkExt; use futures::StreamExt; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; +use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; @@ -175,7 +177,15 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr "# ), )?; - let mut command = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + let package = TempDir::new()?; + let bin_dir = package.path().join("bin"); + std::fs::create_dir(&bin_dir)?; + let executable = bin_dir.join(format!("codex{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(codex_utils_cargo_bin::cargo_bin("codex")?, &executable)?; + let manifest = package.path().join("codex-package.json"); + std::fs::write(&manifest, r#"{"version":"1.2.3-alpha.4"}"#)?; + + let mut command = tokio::process::Command::new(executable); command .env("CODEX_HOME", codex_home.path()) .env("CODEX_API_KEY", "test-api-key") @@ -205,6 +215,8 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr .ok_or_else(|| anyhow::anyhow!("remote exec-server stdin was not piped"))?; let environment_websocket = accept_parent_lifetime_websocket(&listener, TEST_TIMEOUT).await?; + // Remote startup must capture the version before registration, not on the first initialize. + std::fs::write(&manifest, r#"{"version":"9.9.9"}"#)?; let executor_public_key = registered_parent_lifetime_executor_public_key(®istry).await?; let harness_args = NoiseRendezvousConnectArgs { bundle: NoiseRendezvousConnectBundle { @@ -232,6 +244,14 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr .await .context("remote harness did not connect")???; + let expected_info = EnvironmentInfo { + executor_version: "1.2.3-alpha.4".to_string(), + ..EnvironmentInfo::local() + }; + assert_eq!(client.environment_info().await?, expected_info); + std::fs::remove_file(&manifest)?; + assert_eq!(client.force_environment_info().await?, expected_info); + #[cfg(windows)] let argv = vec![ "cmd.exe", diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 1eadc29770..16150d3a60 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -94,6 +94,10 @@ pub struct InitializeResponse { #[serde(rename_all = "camelCase")] pub struct EnvironmentInfo { pub shell: ShellInfo, + /// Executor release version for version-based compatibility decisions. + /// `0.0.0` when unknown, including responses from legacy executors. + #[serde(default = "unknown_executor_version")] + pub executor_version: String, /// Working directory inherited by the exec-server process. #[serde(default)] pub cwd: Option, @@ -115,6 +119,10 @@ pub struct EnvironmentInfo { pub capabilities: EnvironmentCapabilities, } +fn unknown_executor_version() -> String { + "0.0.0".to_string() +} + /// Features supported by the selected exec-server environment. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -214,6 +222,7 @@ impl EnvironmentInfo { Self { shell: codex_shell_command::shell_detect::default_user_shell().into(), + executor_version: unknown_executor_version(), cwd: cwd.and_then(|cwd| PathUri::from_host_native_path(cwd).ok()), user_home_dir: PathUri::from_host_native_path("~").ok(), platform_os: Some(std::env::consts::OS.to_string()), @@ -980,6 +989,7 @@ mod tests { name: "zsh".to_string(), path: "/bin/zsh".to_string(), }, + executor_version: "0.0.0".to_string(), cwd: None, user_home_dir: None, platform_os: None, @@ -1012,9 +1022,10 @@ mod tests { } #[test] - fn environment_info_preserves_executor_temporary_directories() { + fn environment_info_preserves_executor_metadata() { let expected = serde_json::json!({ "shell": { "name": "powershell", "path": "powershell.exe" }, + "executorVersion": "1.2.3-alpha.4", "cwd": null, "userHomeDir": "file:///C:/Users/remote", "platformOs": "windows", @@ -1029,7 +1040,7 @@ mod tests { }, }); let info: EnvironmentInfo = serde_json::from_value(expected.clone()) - .expect("environment info with executor temporary directories should deserialize"); + .expect("environment info with executor metadata should deserialize"); assert_eq!( serde_json::to_value(info).expect("environment info should serialize"), diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 746bfb231d..ffad2503f8 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,6 +17,7 @@ base64 = { workspace = true } bytes = { workspace = true } clatter = { workspace = true } codex-api = { workspace = true } +codex-build-info = { workspace = true } codex-config = { workspace = true } codex-http-client = { workspace = true } codex-exec-server-protocol = { workspace = true } diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index c5fd2c2d59..5a4c3e3f5b 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -157,6 +157,7 @@ Response: "sessionId": "00000000-0000-4000-8000-000000000001", "environmentInfo": { "shell": { "name": "bash", "path": "/bin/bash" }, + "executorVersion": "1.2.3-alpha.4", "cwd": "file:///workspace" } } @@ -165,6 +166,8 @@ Response: `environmentInfo` contains the same executor metadata returned by `environment/info`, so clients can use it without a second request. +`executorVersion` is the executor's package release version, or `0.0.0` when unknown. + Rust clients cache this metadata for the client's lifetime, including session resumption. If initialization omits it, the first metadata request fetches and caches `environment/info`. diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index bd758cdd9c..94eecc2c0e 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -2428,6 +2428,10 @@ mod tests { } #[test_case::test_case(Some(EnvironmentInfo::local()); "from_initialize")] + #[test_case::test_case(Some(EnvironmentInfo { + executor_version: "1.2.3-alpha.4".to_string(), + ..EnvironmentInfo::local() + }); "with_executor_version")] #[test_case::test_case(None; "legacy_server")] #[tokio::test] async fn environment_info_is_cached( diff --git a/codex-rs/exec-server/src/server.rs b/codex-rs/exec-server/src/server.rs index c04e1fa166..15a5c877cf 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -3,6 +3,7 @@ mod handler; mod process_handler; mod processor; mod registry; +mod release_version; mod request_dispatcher; mod session_registry; mod transport; diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 087ddef2bd..92fcd547be 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -158,7 +158,7 @@ impl ExecServerHandler { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session); Ok(InitializeResponse { session_id, - environment_info: Some(EnvironmentInfo::local()), + environment_info: Some(super::release_version::local_environment_info()), }) } @@ -179,7 +179,7 @@ impl ExecServerHandler { pub(crate) fn environment_info(&self) -> Result { self.require_initialized_for("environment info")?; - Ok(EnvironmentInfo::local()) + Ok(super::release_version::local_environment_info()) } pub(crate) async fn environment_config_read( diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index edc8d41c46..19da5f9361 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::time::Instant; +use codex_build_info::BuildInfo; use codex_exec_server_protocol::JSONRPCMessage; use tokio::sync::mpsc; use tracing::debug; @@ -53,6 +54,8 @@ impl ConnectionProcessor { http_client_factory: HttpClientFactory, request_dispatch_mode: RequestDispatchMode, ) -> Self { + // Library callers may bypass CLI startup. Capture the version before serving clients. + let _ = BuildInfo::get(); Self { session_registry: SessionRegistry::new(telemetry.clone()), runtime_paths, diff --git a/codex-rs/exec-server/src/server/release_version.rs b/codex-rs/exec-server/src/server/release_version.rs new file mode 100644 index 0000000000..a68fb0e9c2 --- /dev/null +++ b/codex-rs/exec-server/src/server/release_version.rs @@ -0,0 +1,12 @@ +//! Include the startup-cached package release version in executor metadata. + +use codex_build_info::BuildInfo; + +use crate::protocol::EnvironmentInfo; + +pub(super) fn local_environment_info() -> EnvironmentInfo { + EnvironmentInfo { + executor_version: BuildInfo::get().version().to_string(), + ..EnvironmentInfo::local() + } +} diff --git a/codex-rs/exec-server/tests/common/exec_server.rs b/codex-rs/exec-server/tests/common/exec_server.rs index ea4f5b166c..439466f4fb 100644 --- a/codex-rs/exec-server/tests/common/exec_server.rs +++ b/codex-rs/exec-server/tests/common/exec_server.rs @@ -33,7 +33,6 @@ const EVENT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) struct ExecServerHarness { codex_home: TempDir, - _helper_paths: TestCodexHelperPaths, child: Child, websocket_url: String, websocket: tokio_tungstenite::WebSocketStream< @@ -89,31 +88,34 @@ where V: AsRef, { let helper_paths = test_codex_helper_paths()?; - let codex_home = TempDir::new()?; let mut child = Command::new(&helper_paths.codex_exe); child.args(["exec-server", "--listen", "ws://127.0.0.1:0"]); child.args(args); - child.stdin(Stdio::null()); - child.stdout(Stdio::piped()); - child.stderr(Stdio::inherit()); - child.kill_on_drop(true); - child.env("CODEX_HOME", codex_home.path()); child.envs(env); - let mut child = child.spawn()?; - - let websocket_url = read_listen_url_from_stdout(&mut child).await?; - let (websocket, _) = connect_websocket_when_ready(&websocket_url).await?; - Ok(ExecServerHarness { - codex_home, - _helper_paths: helper_paths, - child, - websocket_url, - websocket, - next_request_id: 1, - }) + ExecServerHarness::start(child).await } impl ExecServerHarness { + pub(crate) async fn start(mut command: Command) -> anyhow::Result { + let codex_home = TempDir::new()?; + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + command.kill_on_drop(true); + command.env("CODEX_HOME", codex_home.path()); + let mut child = command.spawn()?; + + let websocket_url = read_listen_url_from_stdout(&mut child).await?; + let (websocket, _) = connect_websocket_when_ready(&websocket_url).await?; + Ok(Self { + codex_home, + child, + websocket_url, + websocket, + next_request_id: 1, + }) + } + pub(crate) fn codex_home(&self) -> &std::path::Path { self.codex_home.path() } diff --git a/codex-rs/exec-server/tests/initialize.rs b/codex-rs/exec-server/tests/initialize.rs index 64ca63f551..76ed47e843 100644 --- a/codex-rs/exec-server/tests/initialize.rs +++ b/codex-rs/exec-server/tests/initialize.rs @@ -7,14 +7,36 @@ use codex_exec_server_protocol::JSONRPCError; use codex_exec_server_protocol::JSONRPCErrorError; use codex_exec_server_protocol::JSONRPCMessage; use codex_exec_server_protocol::JSONRPCResponse; -use common::exec_server::exec_server; +use common::exec_server::ExecServerHarness; use common::exec_server::exec_server_with_env; use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::process::Command; use uuid::Uuid; +#[test_case::test_case(Some("1.2.3-alpha.4"); "packaged")] +#[test_case::test_case(None; "without_manifest")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_server_accepts_initialize() -> anyhow::Result<()> { - let mut server = exec_server().await?; +async fn exec_server_accepts_initialize(version: Option<&str>) -> anyhow::Result<()> { + let package = TempDir::new()?; + let bin_dir = package.path().join("bin"); + std::fs::create_dir(&bin_dir)?; + let executable = bin_dir.join(format!("codex{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(std::env::current_exe()?, &executable)?; + let manifest = package.path().join("codex-package.json"); + if let Some(version) = version { + std::fs::write( + &manifest, + serde_json::to_vec(&serde_json::json!({ "version": version }))?, + )?; + } + + let mut command = Command::new(&executable); + command.args(["exec-server", "--listen", "ws://127.0.0.1:0"]); + let mut server = ExecServerHarness::start(command).await?; + + // Updates after startup cannot change the advertised release version. + std::fs::write(&manifest, r#"{"version":"9.9.9"}"#)?; let initialize_id = server .send_request( "initialize", @@ -32,9 +54,28 @@ async fn exec_server_accepts_initialize() -> anyhow::Result<()> { assert_eq!(id, initialize_id); let initialize_response: InitializeResponse = serde_json::from_value(result)?; Uuid::parse_str(&initialize_response.session_id)?; + let mut expected_environment = EnvironmentInfo::local(); + expected_environment.executor_version = version.unwrap_or("0.0.0").to_string(); assert_eq!( initialize_response.environment_info, - Some(EnvironmentInfo::local()) + Some(expected_environment.clone()) + ); + + server + .send_notification("initialized", serde_json::json!({})) + .await?; + std::fs::remove_file(&manifest)?; + let environment_id = server + .send_request("environment/info", serde_json::json!({})) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = server.next_event().await? + else { + panic!("expected environment info response"); + }; + assert_eq!(id, environment_id); + assert_eq!( + serde_json::from_value::(result)?, + expected_environment ); server.shutdown().await?;