diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 4daa29eb0c..056c1cef1b 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -13,6 +13,8 @@ use std::collections::HashMap; pub use codex_protocol::shell_environment::CODEX_SESSION_ID_ENV_VAR; pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; +pub(crate) const CODEX_VERSION_ENV_VAR: &str = "CODEX_VERSION"; + /// Informational name of the active permission profile. Child processes can /// overwrite this value, so it must not be treated as proof of enforcement. pub const CODEX_PERMISSION_PROFILE_ENV_VAR: &str = "CODEX_PERMISSION_PROFILE"; @@ -35,9 +37,16 @@ pub fn create_env( shell_environment::create_env(policy, thread_id.as_deref()) } -/// Exposes the shared root-session identity to model-reachable shell commands. -pub(crate) fn inject_session_id_env(env: &mut HashMap, session_id: SessionId) { +/// Exposes the shared root-session identity and harness version to shell commands. +pub(crate) fn inject_session_env(env: &mut HashMap, session_id: SessionId) { env.insert(CODEX_SESSION_ID_ENV_VAR.to_string(), session_id.to_string()); + if cfg!(windows) { + env.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_VERSION_ENV_VAR)); + } + env.insert( + CODEX_VERSION_ENV_VAR.to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ); } /// Injects the selected named permission profile into a shell tool's environment. diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 7d72cde569..ad7097126e 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -15,7 +15,7 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::exec_env::create_env; use crate::exec_env::inject_apply_patch_env; -use crate::exec_env::inject_session_id_env; +use crate::exec_env::inject_session_env; use crate::sandboxing::ExecRequest; use crate::session::TurnInput; use crate::session::turn_context::TurnContext; @@ -166,7 +166,7 @@ pub(crate) async fn execute_user_shell_command( let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); let shell_environment_policy = turn_environment.shell_environment_policy(); let mut exec_env_map = create_env(shell_environment_policy, Some(session.thread_id)); - inject_session_id_env(&mut exec_env_map, session.session_id()); + inject_session_env(&mut exec_env_map, session.session_id()); inject_apply_patch_env(&mut exec_env_map, &turn_context.config.features); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { strip_managed_proxy_env(&mut exec_env_map); diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 3dc7e18526..9f4b2063b2 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -7,6 +7,7 @@ small and focused and reuses the orchestrator for approvals + sandbox + retry. use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::CODEX_SESSION_ID_ENV_VAR; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; +use crate::exec_env::CODEX_VERSION_ENV_VAR; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; @@ -264,6 +265,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( for key in [ CODEX_SESSION_ID_ENV_VAR, CODEX_THREAD_ID_ENV_VAR, + CODEX_VERSION_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR, CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, PLUGIN_METRICS_OUTPUT_ENV_VAR, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index d978777f40..d2a3292d54 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -494,12 +494,12 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() { } #[test] -fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() { +fn maybe_wrap_shell_lc_with_snapshot_restores_codex_metadata_from_env() { let dir = tempdir().expect("create temp dir"); let snapshot_path = dir.path().join("snapshot.sh"); std::fs::write( &snapshot_path, - "# Snapshot file\nexport CODEX_THREAD_ID='parent-thread'\n", + "# Snapshot file\nexport CODEX_THREAD_ID='parent-thread'\nexport CODEX_VERSION='old-version'\n", ) .expect("write snapshot"); let (session_shell, shell_snapshot) = @@ -507,24 +507,34 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() { let command = vec![ "/bin/bash".to_string(), "-lc".to_string(), - "printf '%s' \"$CODEX_THREAD_ID\"".to_string(), + "printf '%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_VERSION\"".to_string(), ]; + let env = HashMap::from([ + ("CODEX_THREAD_ID".to_string(), "nested-thread".to_string()), + ( + "CODEX_VERSION".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ), + ]); let rewritten = maybe_wrap_shell_lc_with_snapshot( &command, &session_shell, Some(&shell_snapshot), &HashMap::new(), - &HashMap::from([("CODEX_THREAD_ID".to_string(), "nested-thread".to_string())]), + &env, &RuntimePathPrepends::default(), ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) - .env("CODEX_THREAD_ID", "nested-thread") + .envs(env) .output() .expect("run rewritten command"); assert!(output.status.success(), "command failed: {output:?}"); - assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + concat!("nested-thread|", env!("CARGO_PKG_VERSION")), + ); } #[test] diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 56d962c1fc..8d3114845c 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -45,6 +45,7 @@ use codex_utils_git_discovery::GitRootDiscovery; const MEMORY_GIT_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const MODEL_KEY: &str = "model"; +const CODEX_VERSION_KEY: &str = "codex_version"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; const WORKSPACE_KIND_KEY: &str = "workspace_kind"; @@ -246,6 +247,10 @@ impl TurnMetadataState { MODEL_KEY.to_string(), Value::String(context.model.to_string()), ); + metadata.insert( + CODEX_VERSION_KEY.to_string(), + Value::String(env!("CARGO_PKG_VERSION").to_string()), + ); match context.reasoning_effort { Some(reasoning_effort) => { metadata.insert( diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 64cb45b8d7..247b01c47f 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -739,6 +739,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ("workspace_kind".to_string(), "projectless".to_string()), ("source".to_string(), "client-source".to_string()), ("model".to_string(), "client-supplied".to_string()), + ("codex_version".to_string(), "client-supplied".to_string()), ( "reasoning_effort".to_string(), "client-supplied".to_string(), @@ -932,6 +933,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); + assert_eq!(meta["codex_version"], env!("CARGO_PKG_VERSION")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(LEGACY_CODE_MODE_TOOL_NAMES_KEY).is_none()); assert!(meta.get(TOOL_NAMESPACES_INFO_KEY).is_none()); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 81d91c73a4..5becfea9bc 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -17,10 +17,11 @@ use super::oneshot::Completion; use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; +use crate::exec_env::CODEX_VERSION_ENV_VAR; use crate::exec_env::create_env; use crate::exec_env::inject_apply_patch_env; use crate::exec_env::inject_permission_profile_env; -use crate::exec_env::inject_session_id_env; +use crate::exec_env::inject_session_env; use crate::exec_policy::ExecApprovalRequest; use crate::guardian::GuardianReviewContext; use crate::plugins::metrics::finish_and_track_measurements; @@ -138,6 +139,7 @@ pub(super) fn exec_env_policy_from_shell_policy( .collect::>(); exclude.extend([ CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + CODEX_VERSION_ENV_VAR.to_string(), codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR.to_string(), PLUGIN_METRICS_OUTPUT_ENV_VAR.to_string(), ]); @@ -145,6 +147,7 @@ pub(super) fn exec_env_policy_from_shell_policy( r#set.retain(|key, _| { ![ CODEX_PERMISSION_PROFILE_ENV_VAR, + CODEX_VERSION_ENV_VAR, codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, PLUGIN_METRICS_OUTPUT_ENV_VAR, ] @@ -176,6 +179,7 @@ fn env_overlay_for_exec_server( && (matches!( key.as_str(), CODEX_PERMISSION_PROFILE_ENV_VAR + | CODEX_VERSION_ENV_VAR | codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR ) || local_policy_env.get(*key) != Some(*value)) }) @@ -1362,7 +1366,7 @@ impl UnifiedExecProcessManager { CODEX_THREAD_ID_ENV_VAR.to_string(), context.session.thread_id.to_string(), ); - inject_session_id_env(&mut env, context.session.session_id()); + inject_session_env(&mut env, context.session.session_id()); inject_apply_patch_env(&mut env, &turn.config.features); let active_permission_profile = request.turn_environment.active_permission_profile(); inject_permission_profile_env(&mut env, active_permission_profile.as_ref()); diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index c1103e569b..29f9e8839c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -53,6 +53,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/client-path".to_string()), ("SHELL_SET".to_string(), "policy".to_string()), + ( + CODEX_VERSION_ENV_VAR.to_string(), + "client-version".to_string(), + ), ( CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), "current-profile".to_string(), @@ -68,6 +72,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { ("OpenAI_Federation_Rule_Id".to_string(), "rule".to_string()), ("SHELL_SET".to_string(), "policy".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + CODEX_VERSION_ENV_VAR.to_string(), + "client-version".to_string(), + ), ( CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), "current-profile".to_string(), @@ -87,6 +95,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { HashMap::from([ ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + CODEX_VERSION_ENV_VAR.to_string(), + "client-version".to_string() + ), ( CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), "current-profile".to_string(), @@ -107,6 +119,7 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { fn exec_env_policy_excludes_non_inheritable_and_runtime_variables() { let policy = ShellEnvironmentPolicy { r#set: HashMap::from([ + ("codex_version".to_string(), "stale-version".to_string()), ( "codex_permission_profile".to_string(), "stale-profile".to_string(), @@ -135,6 +148,7 @@ fn exec_env_policy_excludes_non_inheritable_and_runtime_variables() { ignore_default_excludes: policy.ignore_default_excludes, exclude: vec![ CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(), + CODEX_VERSION_ENV_VAR.to_string(), codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR.to_string(), PLUGIN_METRICS_OUTPUT_ENV_VAR.to_string(), ], diff --git a/codex-rs/core/src/unified_exec/shell_snapshot.rs b/codex-rs/core/src/unified_exec/shell_snapshot.rs index fdce56e3b3..c04b7d541e 100644 --- a/codex-rs/core/src/unified_exec/shell_snapshot.rs +++ b/codex-rs/core/src/unified_exec/shell_snapshot.rs @@ -23,7 +23,7 @@ use crate::config::NetworkProxySpec; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::exec_env::inject_apply_patch_env; use crate::exec_env::inject_permission_profile_env; -use crate::exec_env::inject_session_id_env; +use crate::exec_env::inject_session_env; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::shell::ShellType; @@ -114,7 +114,7 @@ impl Session { CODEX_THREAD_ID_ENV_VAR.to_string(), session.thread_id().to_string(), ); - inject_session_id_env(&mut env, session.session_id()); + inject_session_env(&mut env, session.session_id()); inject_apply_patch_env(&mut env, &config.features); inject_permission_profile_env( &mut env, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index d3d5ecf07f..899e680921 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -527,6 +527,10 @@ async fn approved_mcp_tool_call_metadata_records_prior_user_input_request( let mcp_turn_metadata = apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata") .expect("MCP tools/call turn metadata"); + assert_eq!( + mcp_turn_metadata["codex_version"], + env!("CARGO_PKG_VERSION") + ); assert_eq!( ( mcp_turn_metadata.get("root_turn_id"), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 94cc55dd1f..1c9142dd3f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -376,6 +376,10 @@ async fn exec_command_uses_installed_environment_shell_policy_with_explicit_over r#set: HashMap::from([ ("KEEP".to_string(), "preserved".to_string()), ("DROP".to_string(), "filtered".to_string()), + ( + "CODEX_VERSION".to_string(), + "configured-version".to_string(), + ), ]), ..Default::default() }; @@ -428,10 +432,10 @@ async fn exec_command_uses_installed_environment_shell_policy_with_explicit_over let call_id = "exec-command-environment-shell-policy"; let command = match core_test_support::test_target_os() { core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => { - r#"printf '%s:%s:%s' "$KEEP" "${DROP:-missing}" "${OWNER_ONLY:-missing}""# + r#"printf '%s:%s:%s:%s' "$KEEP" "${DROP:-missing}" "${OWNER_ONLY:-missing}" "$CODEX_VERSION""# } core_test_support::TestTargetOs::Windows => { - r#"if (Test-Path Env:DROP) { $drop = $env:DROP } else { $drop = 'missing' }; if (Test-Path Env:OWNER_ONLY) { $owner = $env:OWNER_ONLY } else { $owner = 'missing' }; Write-Output "${env:KEEP}:${drop}:${owner}""# + r#"if (Test-Path Env:DROP) { $drop = $env:DROP } else { $drop = 'missing' }; if (Test-Path Env:OWNER_ONLY) { $owner = $env:OWNER_ONLY } else { $owner = 'missing' }; Write-Output "${env:KEEP}:${drop}:${owner}:${env:CODEX_VERSION}""# } }; let arguments = json!({ @@ -462,7 +466,10 @@ async fn exec_command_uses_installed_environment_shell_policy_with_explicit_over ) .await?; let output = parse_unified_exec_output(&harness.function_call_stdout(call_id).await)?; - assert_eq!(output.output.trim(), "preserved:missing:missing"); + assert_eq!( + output.output.trim(), + concat!("preserved:missing:missing:", env!("CARGO_PKG_VERSION")), + ); Ok(()) }