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
This commit is contained in:
felixxia-oai
2026-09-02 12:07:32 +00:00
committed by copyberry
parent a0dcfe2ada
commit 8d32abcd01
15 changed files with 131 additions and 28 deletions
+1
View File
@@ -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 }
+3
View File
@@ -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`.
+4
View File
@@ -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(
+1
View File
@@ -3,6 +3,7 @@ mod handler;
mod process_handler;
mod processor;
mod registry;
mod release_version;
mod request_dispatcher;
mod session_registry;
mod transport;
+2 -2
View File
@@ -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<EnvironmentInfo, JSONRPCErrorError> {
self.require_initialized_for("environment info")?;
Ok(EnvironmentInfo::local())
Ok(super::release_version::local_environment_info())
}
pub(crate) async fn environment_config_read(
@@ -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,
@@ -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()
}
}
@@ -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<std::ffi::OsStr>,
{
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<Self> {
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()
}
+45 -4
View File
@@ -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::<EnvironmentInfo>(result)?,
expected_environment
);
server.shutdown().await?;