diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 6e38336e73..a3dd6c834b 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -255,6 +255,8 @@ jobs: fi build_args+=(--bin "$binary") done + STABLE_GIT_COMMIT="$(git rev-parse HEAD)" + export STABLE_GIT_COMMIT cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings diff --git a/MODULE.bazel b/MODULE.bazel index 1712f1b7f8..bdb15bf876 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -200,6 +200,8 @@ rules_rust.patch( # Skip transient native-Windows linker outputs while consolidating # dependency search paths. "//patches:rules_rust_windows_process_wrapper_skip_temp_outputs.patch", + # Group build-script argument files to avoid Windows command-line limits. + "//patches:rules_rust_group_build_script_arg_files.patch", ], strip = 1, ) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fc30b657e8..45fca1b150 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2533,6 +2533,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "tempfile", ] diff --git a/codex-rs/build-info/Cargo.toml b/codex-rs/build-info/Cargo.toml index f9cc3d040f..bd9bb036dc 100644 --- a/codex-rs/build-info/Cargo.toml +++ b/codex-rs/build-info/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-install-context = { workspace = true } semver = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } +sha2 = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/build-info/build.rs b/codex-rs/build-info/build.rs new file mode 100644 index 0000000000..4ac3e0d42c --- /dev/null +++ b/codex-rs/build-info/build.rs @@ -0,0 +1,8 @@ +//! Embed the compilation target, including its architecture and ABI. + +fn main() -> Result<(), std::env::VarError> { + let target = std::env::var("TARGET")?; + println!("cargo:rustc-env=CODEX_BUILD_TARGET={target}"); + println!("cargo:rerun-if-changed=build.rs"); + Ok(()) +} diff --git a/codex-rs/build-info/src/build_info_tests.rs b/codex-rs/build-info/src/build_info_tests.rs index eeaefe340e..d5420e373a 100644 --- a/codex-rs/build-info/src/build_info_tests.rs +++ b/codex-rs/build-info/src/build_info_tests.rs @@ -6,6 +6,7 @@ use semver::Version; use tempfile::tempdir; use crate::BuildInfo; +use crate::build_id; const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; @@ -34,6 +35,7 @@ fn packaged_runtime_uses_manifest_version() { BuildInfo { version: Version::parse("1.2.3-alpha.4").expect("valid release version"), build_commit: BUILD_COMMIT.to_string(), + target: Some(env!("CODEX_BUILD_TARGET").to_string()), }, ); } @@ -52,6 +54,7 @@ fn unpackaged_runtime_uses_build_commit() { BuildInfo { version: Version::new(0, 0, 0), build_commit: BUILD_COMMIT.to_string(), + target: Some(env!("CODEX_BUILD_TARGET").to_string()), }, ); } @@ -78,6 +81,7 @@ fn legacy_package_without_version_uses_build_commit() { BuildInfo { version: Version::new(0, 0, 0), build_commit: BUILD_COMMIT.to_string(), + target: Some(env!("CODEX_BUILD_TARGET").to_string()), }, ); } @@ -107,27 +111,87 @@ fn invalid_package_version_uses_build_commit() { BuildInfo { version: Version::new(0, 0, 0), build_commit: BUILD_COMMIT.to_string(), + target: Some(env!("CODEX_BUILD_TARGET").to_string()), }, ); } -/// Serializing build information preserves both its release version and commit. +/// Serializing build information preserves release version, commit, and target. #[test] fn build_info_serialization_preserves_build_provenance() { let build_info = BuildInfo { version: Version::parse("1.2.3-alpha.4").expect("valid release version"), build_commit: BUILD_COMMIT.to_string(), + target: Some("x86_64-pc-windows-msvc".to_string()), }; + let serialized = serde_json::json!({ + "version": "1.2.3-alpha.4", + "build_commit": BUILD_COMMIT, + "target": "x86_64-pc-windows-msvc", + }); assert_eq!( - serde_json::to_string(&build_info).expect("serialize build information"), - format!("{{\"version\":\"1.2.3-alpha.4\",\"build_commit\":\"{BUILD_COMMIT}\"}}"), + serde_json::to_value(&build_info).expect("serialize build information"), + serialized, ); assert_eq!( - serde_json::from_str::(&format!( - "{{\"version\":\"1.2.3-alpha.4\",\"build_commit\":\"{BUILD_COMMIT}\"}}" - )) - .expect("deserialize build information"), + serde_json::from_value::(serialized).expect("deserialize build information"), build_info, ); } + +#[test] +fn historical_build_info_does_not_infer_the_current_target() { + let legacy = serde_json::json!({ "version": "1.2.3", "build_commit": BUILD_COMMIT }); + for info in [ + serde_json::from_value::(legacy).expect("deserialize legacy build information"), + BuildInfo::from_version("1.2.3"), + BuildInfo::from_version(BUILD_COMMIT), + ] { + assert_eq!(info.target(), None); + } +} + +#[test] +fn build_id_is_stable_and_uses_the_supplied_commit_and_target() { + // These vectors also cover targets different from the machine running CI. + for (target, digest) in [ + ( + "aarch64-apple-darwin", + "90138d7ee35f3f61cd61eb55e8d64105f856055641af175388b102dffa772594", + ), + ( + "x86_64-unknown-linux-gnu", + "89f3a373036537bde64861b3ad8b1c9494924b0174622e47acda695619630d09", + ), + ( + "x86_64-unknown-linux-musl", + "fb4f62da3e84f6864dcec8ede7bc66f1c96ecaeaf55f8a786b85df994057c8ac", + ), + ] { + let expected = Some(format!("sha256:{digest}")); + assert_eq!(build_id(BUILD_COMMIT, target), expected); + assert_eq!( + build_id(&BUILD_COMMIT.to_ascii_uppercase(), target), + expected + ); + assert_ne!( + build_id("1123456789abcdef0123456789abcdef01234567", target), + expected + ); + } +} + +#[test] +fn build_id_requires_a_valid_stamp_and_target() { + for commit in [ + "", + "dev", + "unknown", + "0123456", + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert_eq!(build_id(commit, "x86_64-unknown-linux-musl"), None); + } + assert_eq!(build_id(BUILD_COMMIT, ""), None); +} diff --git a/codex-rs/build-info/src/lib.rs b/codex-rs/build-info/src/lib.rs index 833c64be24..619918b15c 100644 --- a/codex-rs/build-info/src/lib.rs +++ b/codex-rs/build-info/src/lib.rs @@ -1,4 +1,4 @@ -//! Resolve the release identity of the current Codex runtime. +//! Resolve the packaged release version, compiled commit, and target of the current runtime. use std::fmt; use std::sync::OnceLock; @@ -7,6 +7,8 @@ use codex_install_context::InstallContext; use semver::Version; use serde::Deserialize; use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; static BUILD_INFO: OnceLock = OnceLock::new(); @@ -14,6 +16,7 @@ static BUILD_INFO: OnceLock = OnceLock::new(); /// /// The environment lookup intentionally expands at the macro call site so Git /// changes invalidate only final binary actions, not this shared library. +/// Cargo builds must supply STABLE_GIT_COMMIT in the build environment. #[macro_export] macro_rules! initialize { () => { @@ -26,6 +29,8 @@ macro_rules! initialize { pub struct BuildInfo { version: Version, build_commit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, } impl BuildInfo { @@ -59,10 +64,12 @@ impl BuildInfo { "unknown".to_string() }, version: parsed_version, + target: None, }, Err(_) => Self { version: Version::new(0, 0, 0), build_commit: version, + target: None, }, } } @@ -93,21 +100,46 @@ impl BuildInfo { &self.build_commit } + /// Return the compiler target triple, or `None` for historical metadata without a target. + pub fn target(&self) -> Option<&str> { + self.target.as_deref() + } + fn resolve(install_context: &InstallContext, build_commit: &'static str) -> Self { if let Some(manifest) = install_context.package_manifest() { return Self { version: manifest.version, build_commit: build_commit.to_owned(), + target: Some(env!("CODEX_BUILD_TARGET").to_owned()), }; } Self { version: Version::new(0, 0, 0), build_commit: build_commit.to_owned(), + target: Some(env!("CODEX_BUILD_TARGET").to_owned()), } } } +/// Derive a standard build's opaque ID from its stamped commit and compiler target. +/// +/// Explicit inputs let CI identify cross-compiled builds without running them. +/// Runtime callers must use the running executable's stamp and target. The stable +/// encoding is SHA-256 of `git::`, excluding version. +/// This identifies a standard build configuration, not exact executable bytes. +pub fn build_id(build_commit: &str, target: &str) -> Option { + if build_commit.len() != 40 + || !build_commit.bytes().all(|byte| byte.is_ascii_hexdigit()) + || target.is_empty() + { + return None; + } + let identity = format!("git:{}:{target}", build_commit.to_ascii_lowercase()); + let digest = Sha256::digest(identity.as_bytes()); + Some(format!("sha256:{digest:x}")) +} + impl fmt::Display for BuildInfo { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { if self.is_source_build() { diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index 0ce5ef814e..34d1f7e516 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -244,11 +244,14 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr .await .context("remote harness did not connect")???; + let environment_info = client.environment_info().await?; let expected_info = EnvironmentInfo { executor_version: "1.2.3-alpha.4".to_string(), + // The build identity belongs to the spawned CLI, not this test process. + provider_id: environment_info.provider_id.clone(), ..EnvironmentInfo::local() }; - assert_eq!(client.environment_info().await?, expected_info); + assert_eq!(environment_info, expected_info); std::fs::remove_file(&manifest)?; assert_eq!(client.force_environment_info().await?, expected_info); diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 2b95ea64f8..64b9e2a346 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -99,6 +99,12 @@ pub struct EnvironmentInfo { /// `0.0.0` when unknown, including responses from legacy executors. #[serde(default = "unknown_executor_version")] pub executor_version: String, + /// Opaque executor build identity for looking up behavioral verification. + /// Derived from the compiled commit and target for standard builds; + /// absent for legacy or unstamped builds. This is not an artifact checksum + /// or a security attestation, and evidence must not be shared across build variants. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_id: Option, /// Working directory inherited by the exec-server process. #[serde(default)] pub cwd: Option, @@ -224,6 +230,7 @@ impl EnvironmentInfo { Self { shell: codex_shell_command::shell_detect::default_user_shell().into(), executor_version: unknown_executor_version(), + provider_id: None, 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()), @@ -1022,6 +1029,7 @@ mod tests { path: "/bin/zsh".to_string(), }, executor_version: "0.0.0".to_string(), + provider_id: None, cwd: None, user_home_dir: None, platform_os: None, @@ -1058,6 +1066,7 @@ mod tests { let expected = serde_json::json!({ "shell": { "name": "powershell", "path": "powershell.exe" }, "executorVersion": "1.2.3-alpha.4", + "providerId": "sha256:e0a0cebe63ab8189ffe3eed378ccf6aa89ef15bc75e39dbbf1fc55951ec6888b", "cwd": null, "userHomeDir": "file:///C:/Users/remote", "platformOs": "windows", diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 5fbf943fba..c3d0f3210b 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -193,6 +193,7 @@ Response: "environmentInfo": { "shell": { "name": "bash", "path": "/bin/bash" }, "executorVersion": "1.2.3-alpha.4", + "providerId": "sha256:fb4f62da3e84f6864dcec8ede7bc66f1c96ecaeaf55f8a786b85df994057c8ac", "cwd": "file:///workspace" } } @@ -203,6 +204,12 @@ Response: `executorVersion` is the executor's package release version, or `0.0.0` when unknown. +The executor caches optional `providerId` at startup using +`codex_build_info::build_id(commit, target)`, which CI can also call for an +explicit build target. This opaque compatibility key excludes package version +and requires no manifest. It identifies a standard build configuration, not exact +executable bytes. Unstamped and legacy executors may omit it. + 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 d05a0c14b0..a8baa33fde 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -2543,8 +2543,9 @@ 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(), + provider_id: Some("sha256:fb4f62da3e84f6864dcec8ede7bc66f1c96ecaeaf55f8a786b85df994057c8ac".to_string()), ..EnvironmentInfo::local() - }); "with_executor_version")] + }); "with_executor_metadata")] #[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 aea6d6a73f..c52cf3222e 100644 --- a/codex-rs/exec-server/src/server.rs +++ b/codex-rs/exec-server/src/server.rs @@ -1,3 +1,4 @@ +mod build_identity; mod file_system_handler; mod handler; mod process_handler; @@ -50,6 +51,7 @@ pub async fn run_main_with_telemetry( http_client_factory: HttpClientFactory, request_dispatch_mode: RequestDispatchMode, ) -> Result<(), Box> { + std::sync::LazyLock::force(&build_identity::PROVIDER_ID); transport::run_transport( listen_url, runtime_paths, diff --git a/codex-rs/exec-server/src/server/build_identity.rs b/codex-rs/exec-server/src/server/build_identity.rs new file mode 100644 index 0000000000..691b592535 --- /dev/null +++ b/codex-rs/exec-server/src/server/build_identity.rs @@ -0,0 +1,21 @@ +//! Cache the running executor's build identity before accepting connections. + +use std::sync::LazyLock; + +use codex_build_info::BuildInfo; +use codex_build_info::build_id; + +use crate::protocol::EnvironmentInfo; + +pub(super) static PROVIDER_ID: LazyLock> = LazyLock::new(|| { + let info = BuildInfo::get(); + info.target() + .and_then(|target| build_id(info.build_commit(), target)) +}); + +pub(super) fn local_environment_info() -> EnvironmentInfo { + EnvironmentInfo { + provider_id: PROVIDER_ID.clone(), + ..super::release_version::local_environment_info() + } +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index feb0f5b93a..3041a102a7 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -67,6 +67,7 @@ use crate::rpc::RpcNotificationSender; use crate::rpc::internal_error; use crate::rpc::invalid_params; use crate::rpc::invalid_request; +use crate::server::build_identity::local_environment_info; use crate::server::file_system_handler::FileSystemHandler; use crate::server::session_registry::SessionHandle; use crate::server::session_registry::SessionRegistry; @@ -162,7 +163,7 @@ impl ExecServerHandler { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session); Ok(InitializeResponse { session_id, - environment_info: Some(super::release_version::local_environment_info()), + environment_info: Some(local_environment_info()), }) } @@ -197,7 +198,7 @@ impl ExecServerHandler { pub(crate) fn environment_info(&self) -> Result { self.require_initialized_for("environment info")?; - Ok(super::release_version::local_environment_info()) + Ok(local_environment_info()) } pub(crate) async fn environment_config_read( diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index ca62ab52a8..d3e46aeeb8 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -21,6 +21,8 @@ use ctor::ctor; pub(crate) mod exec_server; +pub(crate) const TEST_BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + pub(crate) const DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG: &str = "--codex-test-delayed-output-after-exit-parent"; pub(crate) const SYSTEM_PROXY_REQUEST_URL_ENV: &str = @@ -150,6 +152,8 @@ fn maybe_run_exec_server_from_test_binary(guard: Option<&TestBinaryDispatchGuard if command != "exec-server" { return; } + // Initialize in the executor child, just as the real CLI does at startup. + codex_build_info::BuildInfo::initialize(TEST_BUILD_COMMIT); let Some(flag) = args.next() else { eprintln!("expected --listen"); diff --git a/codex-rs/exec-server/tests/health.rs b/codex-rs/exec-server/tests/health.rs index 05915fe968..e1b484494d 100644 --- a/codex-rs/exec-server/tests/health.rs +++ b/codex-rs/exec-server/tests/health.rs @@ -38,7 +38,9 @@ async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<() assert!(environment.is_remote()); let remote_info = environment.info().await?; - let local_info = Environment::default_for_tests().info().await?; + let mut local_info = Environment::default_for_tests().info().await?; + // Only the remote executor advertises its optional build identity. + local_info.provider_id = remote_info.provider_id.clone(); assert_eq!(remote_info, local_info); server.shutdown().await?; diff --git a/codex-rs/exec-server/tests/initialize.rs b/codex-rs/exec-server/tests/initialize.rs index 76ed47e843..8300f62164 100644 --- a/codex-rs/exec-server/tests/initialize.rs +++ b/codex-rs/exec-server/tests/initialize.rs @@ -1,5 +1,8 @@ mod common; +use anyhow::Context; +use codex_build_info::BuildInfo; +use codex_build_info::build_id; use codex_exec_server::EnvironmentInfo; use codex_exec_server::InitializeParams; use codex_exec_server::InitializeResponse; @@ -7,6 +10,7 @@ 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::TEST_BUILD_COMMIT; use common::exec_server::ExecServerHarness; use common::exec_server::exec_server_with_env; use pretty_assertions::assert_eq; @@ -33,6 +37,15 @@ async fn exec_server_accepts_initialize(version: Option<&str>) -> anyhow::Result let mut command = Command::new(&executable); command.args(["exec-server", "--listen", "ws://127.0.0.1:0"]); + // Runtime environment variables cannot replace the executable's build stamp. + command.envs([ + ( + "STABLE_GIT_COMMIT", + "ffffffffffffffffffffffffffffffffffffffff", + ), + ("GITHUB_SHA", "ffffffffffffffffffffffffffffffffffffffff"), + ("CODEX_BUILD_TARGET", "runtime-override"), + ]); let mut server = ExecServerHarness::start(command).await?; // Updates after startup cannot change the advertised release version. @@ -56,6 +69,12 @@ async fn exec_server_accepts_initialize(version: Option<&str>) -> anyhow::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(); + let build_info = BuildInfo::get(); + let target = build_info + .target() + .context("the test binary has a compiled target")?; + expected_environment.provider_id = build_id(TEST_BUILD_COMMIT, target); + assert!(expected_environment.provider_id.is_some()); assert_eq!( initialize_response.environment_info, Some(expected_environment.clone()) diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 49f0d6c9dc..a82bcbc626 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -134,7 +134,7 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res })?, ) .await?; - let _ = server + let response = server .wait_for_event(|event| { matches!( event, @@ -142,6 +142,10 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res ) }) .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected initialize response"); + }; + let initialization: InitializeResponse = serde_json::from_value(result)?; server .send_notification("initialized", serde_json::json!({})) .await?; @@ -213,6 +217,9 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res }; assert_eq!(id, queued_environment_info_id); let mut expected_environment_info = EnvironmentInfo::local(); + expected_environment_info.provider_id = initialization + .environment_info + .and_then(|info| info.provider_id); expected_environment_info.temporary_directories = Some(vec![PathUri::from_host_native_path( temporary_directory.path(), )?]); diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 9f42275b8d..d906c20ed2 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -5,6 +5,7 @@ exports_files([ "llvm_windows_arm64_powl.patch", "llvm_windows_mingw_compat.patch", "rules_rust_build_script_tools_transition.patch", + "rules_rust_group_build_script_arg_files.patch", "rules_rust_windows_msvc_direct_link_args.patch", "rules_rust_windows_process_wrapper_skip_temp_outputs.patch", "rules_cc_rusty_v8_custom_libcxx.patch", diff --git a/patches/rules_rust_group_build_script_arg_files.patch b/patches/rules_rust_group_build_script_arg_files.patch new file mode 100644 index 0000000000..5e65873ba5 --- /dev/null +++ b/patches/rules_rust_group_build_script_arg_files.patch @@ -0,0 +1,12 @@ +diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl +--- a/rust/private/rustc.bzl ++++ b/rust/private/rustc.bzl +@@ -1125,5 +1125,7 @@ + for build_env_file in build_env_files: + process_wrapper_flags.add("--env-file", build_env_file) + +- process_wrapper_flags.add_all(build_flags_files, before_each = "--arg-file") ++ # The wrapper accepts multiple files per flag. Reduce native Windows ++ # command-line length without changing the files or their order. ++ process_wrapper_flags.add_all("--arg-file", build_flags_files) +