From 9b738582b13c2cdbeff54af0afd04c50c3e7ba09 Mon Sep 17 00:00:00 2001 From: Leonardo Di Donato Date: Thu, 8 Oct 2026 13:36:24 +0000 Subject: [PATCH] Expose originating tool call metadata in command environments (#52119) ## Why Command processes need thread and tool call labels for correlation, without stale values from inherited environments or cached shell snapshots. ## What changed - Export the originating `exec_command` call ID as `CODEX_TOOL_CALL_ID`; later `write_stdin` calls leave it unchanged. - Apply executor metadata as `CODEX_THREAD_ID` and `CODEX_TOOL_CALL_ID` after environment policies, overlays, and snapshot replay. Clear omitted fields when metadata is present; without metadata, preserve thread context and remove the call ID. - Exclude both IDs from newly captured snapshots and omit the call ID from capture and validation helpers. - Preserve empty call IDs, omit NUL-containing IDs, and handle case-insensitive environment names on Windows. Document that these labels support correlation, not authorization. ## Testing Add regression coverage for metadata precedence, missing and invalid IDs, snapshot capture and replay, local and remote execution with pipes and TTYs, sandboxed remote execution, and preservation of the originating call ID across later input. GitOrigin-RevId: 1027212b5d495d8e7cfac0f498c3134e060aa897 --- codex-rs/core/src/exec_env.rs | 2 + codex-rs/core/src/shell_snapshot.rs | 6 +- codex-rs/core/src/shell_snapshot_tests.rs | 74 ++++++++ codex-rs/core/src/tools/runtimes/mod.rs | 3 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 28 +-- .../core/src/unified_exec/process_manager.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 22 +-- codex-rs/core/tests/suite/unified_exec.rs | 74 ++++++++ codex-rs/exec-server-protocol/src/protocol.rs | 2 +- codex-rs/exec-server/README.md | 20 +++ codex-rs/exec-server/src/local_process.rs | 99 +++++++++++ codex-rs/exec-server/src/shell_snapshot.rs | 7 + .../exec-server/src/shell_snapshot_tests.rs | 20 ++- codex-rs/exec-server/tests/exec_process.rs | 163 +++++++++++++++++- codex-rs/protocol/src/shell_environment.rs | 14 ++ .../protocol/src/shell_environment_tests.rs | 44 +++++ .../src/shell_snapshot_capture.rs | 9 + .../shell-command/src/shell_snapshot_tests.rs | 53 +++++- 18 files changed, 610 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index ca1dcb6a93..a119fa33a2 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -10,6 +10,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 use codex_protocol::shell_environment::CODEX_TOOL_CALL_ID_ENV_VAR; +pub(crate) use codex_protocol::shell_environment::set_tool_call_id_env_var; pub(crate) const CODEX_VERSION_ENV_VAR: &str = "CODEX_VERSION"; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 994506d24c..01bd13688a 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -23,6 +23,7 @@ use codex_network_proxy::brokered_credential_value_env_keys; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::shell_environment::CODEX_TOOL_CALL_ID_ENV_VAR; use codex_protocol::shell_environment::create_env_from_vars; use codex_shell_command::shell_snapshot::CapturedSnapshot; use codex_shell_command::shell_snapshot::PreparedSnapshot; @@ -1053,11 +1054,13 @@ async fn run_script_with_timeout( SnapshotShellMode::Validation(path) => Some(path), SnapshotShellMode::Login | SnapshotShellMode::NonLogin => None, }; + let mut env = prepared_env.unwrap_or_else(|| std::env::vars().collect()); + remove_env_value(&mut env, CODEX_TOOL_CALL_ID_ENV_VAR); return sandbox .run( args, cwd, - prepared_env.unwrap_or_else(|| std::env::vars().collect()), + env, snapshot_timeout, shell_name, snapshot_read_path, @@ -1081,6 +1084,7 @@ async fn run_script_with_timeout( handler.env_clear(); handler.envs(env); } + handler.env_remove(CODEX_TOOL_CALL_ID_ENV_VAR); codex_protocol::shell_environment::scrub_non_inheritable_env_vars(handler.as_std_mut()); #[cfg(unix)] let output = { diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index d6e754f6ad..44241ae045 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -165,6 +165,80 @@ async fn get_snapshot(shell_type: ShellType) -> Result { Ok(content) } +#[cfg(unix)] +#[tokio::test] +async fn snapshot_helpers_preserve_thread_without_caching_exec_metadata() -> Result<()> { + let output = Command::new(std::env::current_exe()?) + .arg("--exact") + .arg("shell_snapshot::tests::snapshot_helper_exec_metadata_child") + .arg("--nocapture") + .env("CODEX_TEST_SNAPSHOT_HELPER_CHILD", "1") + .env("CODEX_THREAD_ID", "helper-thread") + .env("CODEX_TOOL_CALL_ID", "stale-call") + .env("BASH_ENV", "/dev/null") + .output()?; + + assert!( + output.status.success(), + "snapshot helper child failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn snapshot_helper_exec_metadata_child() -> Result<()> { + if std::env::var_os("CODEX_TEST_SNAPSHOT_HELPER_CHILD").is_none() { + return Ok(()); + } + assert_eq!( + std::env::var("CODEX_THREAD_ID").as_deref(), + Ok("helper-thread") + ); + assert_eq!( + std::env::var("CODEX_TOOL_CALL_ID").as_deref(), + Ok("stale-call") + ); + + let dir = tempdir()?; + let shell = crate::shell::get_shell(ShellType::Bash).context("No available Bash shell")?; + let helper_env = run_script_with_timeout( + &shell, + "printf '%s|%s' \"${CODEX_THREAD_ID-unset}\" \"${CODEX_TOOL_CALL_ID-unset}\"", + SNAPSHOT_TIMEOUT, + SnapshotShellMode::NonLogin, + &dir.path().abs(), + /*credential_broker*/ None, + /*sandbox*/ None, + ) + .await?; + assert_eq!(helper_env, "helper-thread|unset"); + let snapshot = get_snapshot(ShellType::Bash).await?; + assert!(!snapshot.contains("CODEX_THREAD_ID")); + assert!(!snapshot.contains("CODEX_TOOL_CALL_ID")); + assert!(!snapshot.contains("helper-thread")); + assert!(!snapshot.contains("stale-call")); + + let validation_path = dir.path().join("validation.sh").abs(); + fs::write( + &validation_path, + "test \"${CODEX_THREAD_ID-missing}\" = helper-thread && \ + test \"${CODEX_TOOL_CALL_ID-missing}\" = missing\n", + ) + .await?; + validate_snapshot( + &shell, + &validation_path, + &dir.path().abs(), + /*credential_broker*/ None, + /*sandbox*/ None, + ) + .await?; + Ok(()) +} + #[test] fn snapshot_file_name_parser_supports_legacy_and_suffixed_names() { let session_id = "019cf82b-6a62-7700-bbbd-46909794ef89"; diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 7145665cc5..17a427be2f 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_TOOL_CALL_ID_ENV_VAR; use crate::exec_env::CODEX_VERSION_ENV_VAR; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; @@ -352,6 +353,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( for key in [ CODEX_SESSION_ID_ENV_VAR, CODEX_THREAD_ID_ENV_VAR, + CODEX_TOOL_CALL_ID_ENV_VAR, CODEX_VERSION_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR, CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, @@ -368,6 +370,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( CODEX_PERMISSION_PROFILE_ENV_VAR, CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, PLUGIN_METRICS_OUTPUT_ENV_VAR, + CODEX_TOOL_CALL_ID_ENV_VAR, ], ); let (proxy_captures, proxy_exports) = build_proxy_env_exports(env); diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 3ee918cb58..e1a25d1d82 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -552,7 +552,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_metadata_from_env() { let snapshot_path = dir.path().join("snapshot.sh"); std::fs::write( &snapshot_path, - "# Snapshot file\nexport CODEX_THREAD_ID='parent-thread'\nexport CODEX_VERSION='old-version'\n", + "# Snapshot file\nexport CODEX_THREAD_ID='parent-thread'\nexport CODEX_TOOL_CALL_ID='parent-call'\nexport CODEX_VERSION='old-version'\n", ) .expect("write snapshot"); let (session_shell, shell_snapshot) = @@ -560,10 +560,12 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_metadata_from_env() { let command = vec![ "/bin/bash".to_string(), "-lc".to_string(), - "printf '%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_VERSION\"".to_string(), + "printf '%s|%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" \"$CODEX_VERSION\"" + .to_string(), ]; let env = HashMap::from([ ("CODEX_THREAD_ID".to_string(), "nested-thread".to_string()), + ("CODEX_TOOL_CALL_ID".to_string(), "nested-call".to_string()), ( "CODEX_VERSION".to_string(), env!("CARGO_PKG_VERSION").to_string(), @@ -586,7 +588,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_metadata_from_env() { assert!(output.status.success(), "command failed: {output:?}"); assert_eq!( String::from_utf8_lossy(&output.stdout), - concat!("nested-thread|", env!("CARGO_PKG_VERSION")), + concat!("nested-thread|nested-call|", env!("CARGO_PKG_VERSION")), ); } @@ -628,21 +630,21 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_permission_profile_from_env() { assert_eq!(String::from_utf8_lossy(&output.stdout), "current-profile\n"); } -#[test] -fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() { - let dir = tempdir().expect("create temp dir"); +#[test_case::test_case(CODEX_PERMISSION_PROFILE_ENV_VAR; "permission profile")] +#[test_case::test_case(CODEX_TOOL_CALL_ID_ENV_VAR; "tool call ID")] +fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_metadata(key: &str) -> anyhow::Result<()> { + let dir = tempdir()?; let snapshot_path = dir.path().join("snapshot.sh"); std::fs::write( &snapshot_path, - "# Snapshot file\nexport CODEX_PERMISSION_PROFILE='stale-profile'\n", - ) - .expect("write snapshot"); + format!("# Snapshot file\nexport {key}='stale-value'\n"), + )?; let (session_shell, shell_snapshot) = shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs()); let command = vec![ "/bin/bash".to_string(), "-lc".to_string(), - "printenv CODEX_PERMISSION_PROFILE".to_string(), + format!("printenv {key}"), ]; let rewritten = maybe_wrap_shell_lc_with_snapshot( &command, @@ -654,12 +656,12 @@ fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() { ); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) - .env_remove(CODEX_PERMISSION_PROFILE_ENV_VAR) - .output() - .expect("run rewritten command"); + .env_remove(key) + .output()?; assert_eq!(output.status.code(), Some(1)); assert_eq!(output.stdout, b""); + Ok(()) } #[test] diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 4743e34f15..35a94ac959 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -22,6 +22,7 @@ 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_env; +use crate::exec_env::set_tool_call_id_env_var; use crate::exec_policy::ExecApprovalRequest; use crate::guardian::GuardianReviewContext; use crate::plugins::metrics::finish_and_track_measurements; @@ -1445,10 +1446,13 @@ impl UnifiedExecProcessManager { let shell_environment_policy = request.turn_environment.shell_environment_policy(); let local_policy_env = create_env(shell_environment_policy, /*thread_id*/ None); let mut env = local_policy_env.clone(); + #[cfg(windows)] + env.retain(|name, _| !name.eq_ignore_ascii_case(CODEX_THREAD_ID_ENV_VAR)); env.insert( CODEX_THREAD_ID_ENV_VAR.to_string(), context.session.thread_id.to_string(), ); + set_tool_call_id_env_var(&mut env, Some(&context.call_id)); inject_session_env(&mut env, context.session.session_id()); inject_apply_patch_env(&mut env); let active_permission_profile = request.turn_environment.active_permission_profile(); diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 92e0a4c3d6..5518bad8c0 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -641,9 +641,10 @@ async fn shell_snapshot_v2_guardian_uses_its_resolved_permissions_and_tools( ) -> Result<()> { skip_if_remote!(Ok(()), "profile fixture uses a host-local HOME directory"); let profile_home = tempfile::tempdir()?; + // Appending detects any successful reviewer write, even with the wrong thread ID. fs::write( profile_home.path().join(".bashrc"), - "printf capture > \"$HOME/$CODEX_THREAD_ID\"\n", + "printf '%s\\n' \"$CODEX_THREAD_ID\" >> \"$HOME/captures\"\n", ) .await?; let builder = @@ -654,6 +655,12 @@ async fn shell_snapshot_v2_guardian_uses_its_resolved_permissions_and_tools( .expect("set parent permissions"); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.approvals_reviewer = ApprovalsReviewer::AutoReview; + // Read the fixture's .bashrc instead of an inherited startup hook. + config + .permissions + .shell_environment_policy + .r#set + .insert("BASH_ENV".to_string(), String::new()); let rules = config.codex_home.join("rules"); std::fs::create_dir_all(&rules).expect("create rules directory"); std::fs::write( @@ -741,15 +748,10 @@ async fn shell_snapshot_v2_guardian_uses_its_resolved_permissions_and_tools( .expect("Guardian thread ID") .to_string(); assert_ne!(guardian_id, test.session_configured.thread_id.to_string()); - assert!( - profile_home - .path() - .join(test.session_configured.thread_id.to_string()) - .exists() - ); - assert!( - !profile_home.path().join(guardian_id).exists(), - "Guardian profile must not inherit writable owner permissions" + assert_eq!( + fs::read_to_string(profile_home.path().join("captures")).await?, + format!("{}\n", test.session_configured.thread_id), + "only the parent may write; Guardian must not inherit writable owner permissions" ); Ok(()) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index a6e5161e96..acdb81445f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -442,6 +442,80 @@ async fn exec_command_does_not_expose_configured_noise_auth_token() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_exports_originating_call_metadata_across_later_input() -> Result<()> { + skip_if_target_windows!(Ok(()), "uses POSIX shell and TTY fixtures"); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let harness = TestCodexHarness::with_auto_env_builder(test_codex()).await?; + let first_call_id = "exec-first-metadata"; + let second_call_id = "exec-second-metadata"; + let input_call_id = "exec-later-input"; + let first_args = json!({ + "cmd": "printf '%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" > first-metadata", + "yield_time_ms": 5_000, + }); + let second_args = json!({ + "cmd": "printf '%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" > second-before; IFS= read -r _; printf '%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" > second-after", + "yield_time_ms": 100, + "tty": true, + }); + let input_args = json!({ + "chars": "continue\n", + "session_id": 1001, + "yield_time_ms": 5_000, + }); + let mut responses = Vec::new(); + for (response_id, call_id, tool, args) in [ + ("resp-1", first_call_id, "exec_command", first_args), + ("resp-2", second_call_id, "exec_command", second_args), + ("resp-3", input_call_id, "write_stdin", input_args), + ] { + responses.push(sse(vec![ + ev_response_created(response_id), + ev_function_call(call_id, tool, &args.to_string()), + ev_completed(response_id), + ])); + } + responses.push(sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-4"), + ])); + let _response_mock = mount_sse_sequence(harness.server(), responses).await; + + harness.submit("record execution metadata").await?; + + let outputs = collect_tool_outputs(&harness.request_bodies().await)?; + let second_output = outputs + .get(second_call_id) + .context("missing second exec_command output")?; + assert_eq!(second_output.process_id.as_deref(), Some("1001")); + let input_output = outputs + .get(input_call_id) + .context("missing write_stdin output")?; + assert_eq!(input_output.exit_code, Some(0)); + assert!( + input_output.output.contains("continue"), + "write_stdin did not echo the later input: {:?}", + input_output.output + ); + + let thread_id = harness.test().session_configured.thread_id; + for (file, call_id) in [ + ("first-metadata", first_call_id), + ("second-before", second_call_id), + ("second-after", second_call_id), + ] { + assert_eq!( + harness.read_file_text(file).await?, + format!("{thread_id}|{call_id}"), + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_uses_installed_environment_shell_policy_with_explicit_overrides() -> Result<()> { diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 476a63539e..26614350c3 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -313,7 +313,7 @@ impl From for ShellInfo { } } -/// Optional tool attribution for executor telemetry, not authorization. +/// Optional tool attribution for executor telemetry and child environments, not authorization. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecMetadata { diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index fc4419a9a2..ee497de689 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -14,6 +14,26 @@ This crate owns the transport, protocol, and filesystem/process handlers. The top-level `codex` binary owns hidden helper dispatch for sandboxed filesystem operations and `codex-linux-sandbox`. +## Command metadata + +Launches expose `metadata.threadId` as `CODEX_THREAD_ID` and `metadata.toolCallId` +as `CODEX_TOOL_CALL_ID` in the initial child environment. `exec_command` supplies +the originating execution call ID, unchanged by later input. Code-mode uses the +nested execution call ID, not the outer cell ID. + +These reserved labels override inherited values, policy settings, overlays, and +cached snapshots. Present metadata clears omitted fields; absent metadata preserves +thread-only context and unsets the call ID. Snapshot capture and validation helpers +keep available thread context but omit the call ID; newly captured snapshots exclude +both ID exports. +Call IDs are copied unchanged, including empty values; NUL-containing IDs are +omitted. Platform process-environment size limits apply. + +Labels support correlation, not authorization. Shell startup, commands, and +descendants can change or remove them; consumers must handle missing or unusable +labels. These guarantees require an updated executor: older executors may ignore +metadata or restore stale snapshot values. + ## Transport The server speaks the exec-specific `codex-exec-server-protocol` message diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index f384d84d88..271f8aaaf0 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -19,6 +19,7 @@ use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; +use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; use codex_sandboxing::SandboxType; use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; @@ -751,9 +752,32 @@ fn child_env(params: &ExecParams) -> HashMap { }; env.remove(crate::CODEX_EXEC_SERVER_EXIT_ON_STDIN_CLOSE_ENV_VAR); env.retain(|name, _| !shell_environment::is_non_inheritable_env_var(name)); + apply_exec_metadata(&mut env, params.metadata.as_ref()); env } +pub(crate) fn apply_exec_metadata( + env: &mut HashMap, + metadata: Option<&crate::protocol::ExecMetadata>, +) { + if let Some(metadata) = metadata { + #[cfg(windows)] + env.retain(|name, _| !name.eq_ignore_ascii_case(CODEX_THREAD_ID_ENV_VAR)); + match metadata.thread_id { + Some(thread_id) => { + env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); + } + None => { + env.remove(CODEX_THREAD_ID_ENV_VAR); + } + } + } + shell_environment::set_tool_call_id_env_var( + env, + metadata.and_then(|metadata| metadata.tool_call_id.as_deref()), + ); +} + pub(crate) fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy { ShellEnvironmentPolicy { inherit: env_policy.inherit.clone(), @@ -1532,9 +1556,14 @@ mod tests { #[test] fn child_env_applies_policy_then_overlay() { + let thread_id = + codex_protocol::ThreadId::from_string("019d25f0-6728-7ce2-908f-c3c187323e5b") + .expect("thread id"); let mut params = test_exec_params(HashMap::from([ ("OVERLAY".to_string(), "overlay".to_string()), ("POLICY_SET".to_string(), "overlay-wins".to_string()), + ("CODEX_THREAD_ID".into(), "overlay-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "overlay-call".into()), ( "openai_identity_token_file".to_string(), "/run/identity-token".to_string(), @@ -1547,13 +1576,21 @@ mod tests { r#set: HashMap::from([ ("POLICY_SET".to_string(), "policy".to_string()), ("OpenAI_Federation_Rule_Id".to_string(), "rule".to_string()), + ("CODEX_THREAD_ID".into(), "policy-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "policy-call".into()), ]), include_only: Vec::new(), }); + params.metadata = Some(crate::protocol::ExecMetadata { + thread_id: Some(thread_id), + tool_call_id: Some("current-call".into()), + }); let mut expected = HashMap::from([ ("OVERLAY".to_string(), "overlay".to_string()), ("POLICY_SET".to_string(), "overlay-wins".to_string()), + ("CODEX_THREAD_ID".into(), thread_id.to_string()), + ("CODEX_TOOL_CALL_ID".into(), "current-call".into()), ]); if cfg!(target_os = "windows") { expected.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string()); @@ -1562,6 +1599,68 @@ mod tests { assert_eq!(child_env(¶ms), expected); } + #[test] + fn child_env_partial_metadata_removes_stale_counterpart() { + let thread_id = + codex_protocol::ThreadId::from_string("019d25f0-6728-7ce2-908f-c3c187323e5b") + .expect("thread id"); + for (thread_id, call_id) in [ + (Some(thread_id), None), + (None, Some("exec-call-only")), + (None, None), + ] { + let mut params = test_exec_params(HashMap::from([ + ("CODEX_THREAD_ID".into(), "stale-thread".into()), + ("Codex_Thread_Id".into(), "mixed-case-thread".into()), + ("codex_thread_id".into(), "lowercase-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "stale-call".into()), + ("OTHER".into(), "unchanged".into()), + ])); + params.metadata = Some(crate::protocol::ExecMetadata { + thread_id, + tool_call_id: call_id.map(str::to_string), + }); + + let mut expected = HashMap::from([("OTHER".into(), "unchanged".into())]); + #[cfg(not(windows))] + expected.insert("Codex_Thread_Id".into(), "mixed-case-thread".into()); + #[cfg(not(windows))] + expected.insert("codex_thread_id".into(), "lowercase-thread".into()); + if let Some(thread_id) = thread_id { + expected.insert("CODEX_THREAD_ID".into(), thread_id.to_string()); + } + if let Some(call_id) = call_id { + expected.insert("CODEX_TOOL_CALL_ID".into(), call_id.to_string()); + } + assert_eq!( + child_env(¶ms), + expected, + "metadata: {:?}", + params.metadata + ); + } + } + + #[test] + fn child_env_without_metadata_preserves_thread_only_context() { + let params = test_exec_params(HashMap::from([ + ("CODEX_THREAD_ID".into(), "prewarm-thread".into()), + ("Codex_Thread_Id".into(), "mixed-case-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "stale-call".into()), + ("Codex_Tool_Call_Id".into(), "mixed-case-call".into()), + ])); + + assert_eq!( + child_env(¶ms), + HashMap::from([ + ("CODEX_THREAD_ID".into(), "prewarm-thread".into()), + ("Codex_Thread_Id".into(), "mixed-case-thread".into()), + #[cfg(not(windows))] + ("Codex_Tool_Call_Id".into(), "mixed-case-call".into()), + ]), + ); + } + #[tokio::test] async fn exit_before_shutdown_records_success() { let (backend, metrics, exporter) = telemetry_backend(); diff --git a/codex-rs/exec-server/src/shell_snapshot.rs b/codex-rs/exec-server/src/shell_snapshot.rs index 6619bbb733..dfb02ff92a 100644 --- a/codex-rs/exec-server/src/shell_snapshot.rs +++ b/codex-rs/exec-server/src/shell_snapshot.rs @@ -16,6 +16,8 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_protocol::shell_environment; +use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; +use codex_protocol::shell_environment::CODEX_TOOL_CALL_ID_ENV_VAR; use codex_shell_command::shell_detect::ShellType; use codex_shell_command::shell_snapshot::CapturedSnapshot; use codex_shell_command::shell_snapshot::SnapshotCaptureOptions; @@ -29,6 +31,7 @@ use tokio::sync::OnceCell; use tokio::time::Instant; use crate::FileSystemSandboxContext; +use crate::local_process::apply_exec_metadata; use crate::local_process::shell_environment_policy; use crate::process_sandbox::PreparedExecRequest; use crate::protocol::ExecEnvPolicy; @@ -283,6 +286,7 @@ impl ShellSnapshotCache { .map(|(name, value)| (name.clone(), value.clone())), ); prepared.env.extend(request_overrides); + apply_exec_metadata(&mut prepared.env, params.metadata.as_ref()); prepared .env .retain(|name, _| !shell_environment::is_non_inheritable_env_var(name)); @@ -396,6 +400,7 @@ async fn capture_snapshot( .current_dir(prepared.cwd.as_path()) .env_clear() .envs(&prepared.env) + .env_remove(CODEX_TOOL_CALL_ID_ENV_VAR) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); @@ -558,6 +563,8 @@ fn parse_snapshot( } None => environment, }; + environment.remove(CODEX_THREAD_ID_ENV_VAR); + environment.remove(CODEX_TOOL_CALL_ID_ENV_VAR); environment.remove("PWD"); environment.remove("OLDPWD"); environment.retain(|name, _| !shell_environment::is_non_inheritable_env_var(name)); diff --git a/codex-rs/exec-server/src/shell_snapshot_tests.rs b/codex-rs/exec-server/src/shell_snapshot_tests.rs index adc0ceb141..82a34ff739 100644 --- a/codex-rs/exec-server/src/shell_snapshot_tests.rs +++ b/codex-rs/exec-server/src/shell_snapshot_tests.rs @@ -310,12 +310,21 @@ fn snapshot_filters_profile_exports_after_capture() { inherit: ShellEnvironmentPolicyInherit::All, ignore_default_excludes: false, exclude: vec!["PROFILE_DENIED".to_string()], - r#set: HashMap::from([("PROFILE_ALLOWED".to_string(), "override".to_string())]), - include_only: vec!["PROFILE_*".to_string()], + r#set: HashMap::from([ + ("PROFILE_ALLOWED".to_string(), "override".to_string()), + ("CODEX_THREAD_ID".to_string(), "policy-thread".to_string()), + ("CODEX_TOOL_CALL_ID".to_string(), "policy-call".to_string()), + ]), + // Metadata must survive policy filtering so the cache owns its removal. + include_only: vec![ + "PROFILE_*".to_string(), + "CODEX_THREAD_ID".to_string(), + "CODEX_TOOL_CALL_ID".to_string(), + ], }; let snapshot = parse_snapshot( ShellType::Bash, - b"profile \xff noise\n# Snapshot file\nfunction profile_helper() { :; }\n\0alias profile_alias='profile_helper'\n\0PROFILE_DENIED\0export PROFILE_DENIED=denied\n\0NON_UTF8\0export NON_UTF8='\xff'\n\0\0PROFILE_ALLOWED=profile\0PROFILE_DENIED=denied\0PROFILE_SECRET=secret\0PWD=/tmp\0NON_UTF8=\xff\0", + b"profile \xff noise\n# Snapshot file\nfunction profile_helper() { :; }\n\0alias profile_alias='profile_helper'\n\0PROFILE_DENIED\0export PROFILE_DENIED=denied\n\0NON_UTF8\0export NON_UTF8='\xff'\n\0\0PROFILE_ALLOWED=profile\0PROFILE_DENIED=denied\0PROFILE_SECRET=secret\0PWD=/tmp\0NON_UTF8=\xff\0CODEX_THREAD_ID=capture-thread\0CODEX_TOOL_CALL_ID=profile-call\0", Some(&policy), SnapshotReplay::Environment, ) @@ -380,7 +389,9 @@ fn snapshot_caches_only_unmanaged_proxy_state() { ]), ), ] { - let output = format!("# Snapshot file\n\0\0\0{exports}"); + let output = format!( + "# Snapshot file\n\0\0\0{exports}CODEX_THREAD_ID=capture-thread\0CODEX_TOOL_CALL_ID=profile-call\0" + ); let snapshot = parse_snapshot( ShellType::Bash, output.as_bytes(), @@ -392,6 +403,7 @@ fn snapshot_caches_only_unmanaged_proxy_state() { assert_eq!(snapshot.environment, expected); } } + use codex_shell_command::shell_detect::ShellType; #[test_case(SnapshotReplay::File, 1024 * 1024, 0, None; "file_accepts_large_state")] diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 8cf2a5d76f..956873ee56 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -14,6 +14,7 @@ use codex_exec_server::Environment; use codex_exec_server::ExecBackend; #[cfg(unix)] use codex_exec_server::ExecEnvPolicy; +use codex_exec_server::ExecMetadata; use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; @@ -373,6 +374,123 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory( Ok(()) } +#[cfg(unix)] +#[test_case(false, false, false; "local_pipe")] +#[test_case(false, true, false; "local_tty")] +#[test_case(true, false, false; "remote_pipe")] +#[test_case(true, true, false; "remote_tty")] +#[test_case(true, false, true; "remote_sandbox")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial_test::serial(remote_exec_server)] +async fn exec_metadata_overrides_policy_and_cached_snapshot( + use_remote: bool, + tty: bool, + use_sandbox: bool, +) -> Result<()> { + if use_sandbox + && let Some(warning) = codex_sandboxing::system_bwrap_warning( + &PermissionProfile::workspace_write(), + &std::env::current_dir()?, + ) + { + eprintln!("skipping sandbox test: {warning}"); + return Ok(()); + } + let context = create_process_context(use_remote).await?; + let home = TempDir::new()?; + let cwd = PathUri::from_host_native_path(home.path())?; + std::fs::write( + home.path().join(".bashrc"), + "printf '%s|%s\n' \"${CODEX_THREAD_ID-unset}\" \"${CODEX_TOOL_CALL_ID-unset}\" >> \"$HOME/capture-env\"\nexport CODEX_THREAD_ID=snapshot-thread\nexport CODEX_TOOL_CALL_ID=snapshot-call\n", + )?; + let policy = ExecEnvPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + ignore_default_excludes: true, + exclude: Vec::new(), + r#set: HashMap::from([ + ("HOME".into(), home.path().to_string_lossy().into_owned()), + ("PATH".into(), "/usr/bin:/bin".into()), + ("CODEX_THREAD_ID".into(), "policy-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "policy-call".into()), + ]), + include_only: Vec::new(), + }; + let thread_id = codex_protocol::ThreadId::from_string("019d25f0-6728-7ce2-908f-c3c187323e5b")?; + let later_thread_id = + codex_protocol::ThreadId::from_string("019d25f0-6728-7ce2-908f-c3c187323e5c")?; + + for (attempt, call_id, expected) in [ + (0, Some(""), ""), + (1, Some("call-λ-'\";$()"), "call-λ-'\";$()"), + (2, None, "unset"), + (3, Some("exec-invalid\0call"), "unset"), + ] { + let execution_thread_id = if attempt == 1 { + later_thread_id + } else { + thread_id + }; + let started = context + .backend + .start(ExecParams { + metadata: Some(ExecMetadata { + thread_id: Some(execution_thread_id), + tool_call_id: call_id.map(str::to_string), + }), + process_id: ProcessId::from(format!("metadata-snapshot-{attempt}")), + argv: vec![ + "/bin/bash".into(), + "-lc".into(), + "printf '%s|%s' \"$CODEX_THREAD_ID\" \"${CODEX_TOOL_CALL_ID-unset}\"" + .to_string(), + ], + cwd: cwd.clone(), + env_policy: Some(policy.clone()), + shell_snapshot: Some(ShellSnapshotRequest { + scope_id: "metadata-attachment".into(), + shell: ShellInfo { + name: "bash".into(), + path: "/bin/bash".into(), + }, + }), + env: HashMap::from([ + ("CODEX_THREAD_ID".into(), "overlay-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "overlay-call".into()), + ]), + tty, + pipe_stdin: false, + arg0: None, + sandbox: use_sandbox.then(|| { + FileSystemSandboxContext::from_permission_profile( + PermissionProfile::workspace_write(), + cwd.clone(), + ) + }), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }) + .await?; + let (stdout, stderr, status, closed) = + collect_process_output_from_events(started.process).await?; + assert_eq!( + (stdout, stderr, status, closed), + ( + format!("{execution_thread_id}|{expected}"), + String::new(), + Some(0), + true, + ) + ); + } + + assert_eq!( + std::fs::read_to_string(home.path().join("capture-env"))?, + "019d25f0-6728-7ce2-908f-c3c187323e5b|unset\n" + ); + Ok(()) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial_test::serial(remote_exec_server)] @@ -715,16 +833,43 @@ async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> { async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { let context = create_process_context(use_remote).await?; + let thread_id = codex_protocol::ThreadId::from_string("019d25f0-6728-7ce2-908f-c3c187323e5b")?; + let call_id = "call-λ-'\";$()"; + #[cfg(unix)] + let argv = vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s|%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" \"$CUSTOM_ENV_VAR\"" + .to_string(), + ]; + #[cfg(windows)] + let argv = vec![ + "powershell.exe".into(), + "-NoProfile".into(), + "-Command".into(), + "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [Console]::Write($env:CODEX_THREAD_ID + '|' + $env:CODEX_TOOL_CALL_ID + '|' + $env:CUSTOM_ENV_VAR)".into(), + ]; + let mut env = std::env::vars().collect::>(); + env.extend([ + ("CODEX_THREAD_ID".into(), "stale-thread".into()), + ("Codex_Thread_Id".into(), "mixed-case-thread".into()), + ("CODEX_TOOL_CALL_ID".into(), "stale-call".into()), + ("Codex_Tool_Call_Id".into(), "mixed-case-call".into()), + ("CUSTOM_ENV_VAR".into(), "caller-owned".into()), + ]); let session = context .backend .start(ExecParams { - metadata: Default::default(), + metadata: Some(ExecMetadata { + thread_id: Some(thread_id), + tool_call_id: Some(call_id.to_string()), + }), process_id: ProcessId::from("proc-1"), - argv: vec!["true".to_string()], + argv, cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, shell_snapshot: None, env_policy: /*env_policy*/ None, - env: Default::default(), + env, tty: false, pipe_stdin: false, arg0: None, @@ -736,6 +881,17 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); let wake_rx = session.process.subscribe_wake(); + let (stdout, stderr, status, closed) = + collect_process_output_from_events(Arc::clone(&session.process)).await?; + assert_eq!( + (stdout, stderr, status, closed), + ( + format!("{thread_id}|{call_id}|caller-owned"), + String::new(), + Some(0), + true, + ) + ); let (_, exit_code, closed) = collect_process_output_from_reads(session.process, wake_rx).await?; @@ -1776,7 +1932,6 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> #[test_case(false ; "local")] #[test_case(true ; "remote")] -#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] // Serialize tests that launch a real exec-server process through the full CLI. #[serial_test::serial(remote_exec_server)] diff --git a/codex-rs/protocol/src/shell_environment.rs b/codex-rs/protocol/src/shell_environment.rs index 427fe4969f..0c6c515c41 100644 --- a/codex-rs/protocol/src/shell_environment.rs +++ b/codex-rs/protocol/src/shell_environment.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; pub const CODEX_SESSION_ID_ENV_VAR: &str = "CODEX_SESSION_ID"; pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID"; +pub const CODEX_TOOL_CALL_ID_ENV_VAR: &str = "CODEX_TOOL_CALL_ID"; pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; pub const OPENAI_FEDERATION_RULE_ID_ENV_VAR: &str = "OPENAI_FEDERATION_RULE_ID"; pub const OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR: &str = "OPENAI_IDENTITY_TOKEN_FILE"; @@ -26,6 +27,19 @@ pub fn is_non_inheritable_env_var(name: &str) -> bool { .any(|restricted| restricted.eq_ignore_ascii_case(name)) } +pub fn set_tool_call_id_env_var(env: &mut HashMap, tool_call_id: Option<&str>) { + #[cfg(windows)] + env.retain(|name, _| !name.eq_ignore_ascii_case(CODEX_TOOL_CALL_ID_ENV_VAR)); + #[cfg(not(windows))] + env.remove(CODEX_TOOL_CALL_ID_ENV_VAR); + if let Some(tool_call_id) = tool_call_id.filter(|tool_call_id| !tool_call_id.contains('\0')) { + env.insert( + CODEX_TOOL_CALL_ID_ENV_VAR.to_string(), + tool_call_id.to_string(), + ); + } +} + /// Configures a child command to omit non-inheritable variables from the /// process environment and explicit command overrides. /// diff --git a/codex-rs/protocol/src/shell_environment_tests.rs b/codex-rs/protocol/src/shell_environment_tests.rs index c987f631db..8167a2afa1 100644 --- a/codex-rs/protocol/src/shell_environment_tests.rs +++ b/codex-rs/protocol/src/shell_environment_tests.rs @@ -9,6 +9,50 @@ const CHILD_MODE_ENV_VAR: &str = "CODEX_SHELL_ENVIRONMENT_SCRUBBER_TEST_MODE"; const TEST_NAME: &str = "shell_environment::tests::command_scrubber_removes_names_from_real_child_environment"; +#[test] +fn tool_call_id_replaces_platform_equivalent_names() { + let representable = format!("exec-雪-{}", "x".repeat(512)); + for (call_id, expected_call) in [ + (None, None), + (Some(""), Some("")), + (Some("invalid\0call"), None), + (Some("current-call"), Some("current-call")), + (Some(representable.as_str()), Some(representable.as_str())), + ] { + let mut env = HashMap::from([ + ("CODEX_TOOL_CALL_ID".to_string(), "stale-call".to_string()), + ( + "Codex_Tool_Call_Id".to_string(), + "mixed-case-call".to_string(), + ), + ( + "codex_tool_call_id".to_string(), + "lowercase-call".to_string(), + ), + ("OTHER".to_string(), "unchanged".to_string()), + ]); + + set_tool_call_id_env_var(&mut env, call_id); + + let mut expected = HashMap::from([("OTHER".to_string(), "unchanged".to_string())]); + #[cfg(not(windows))] + expected.extend([ + ( + "Codex_Tool_Call_Id".to_string(), + "mixed-case-call".to_string(), + ), + ( + "codex_tool_call_id".to_string(), + "lowercase-call".to_string(), + ), + ]); + if let Some(expected_call) = expected_call { + expected.insert("CODEX_TOOL_CALL_ID".to_string(), expected_call.to_string()); + } + assert_eq!(env, expected, "call_id: {call_id:?}"); + } +} + #[test] fn non_inheritable_environment_is_removed_after_policy_overrides() { let vars = [ diff --git a/codex-rs/shell-command/src/shell_snapshot_capture.rs b/codex-rs/shell-command/src/shell_snapshot_capture.rs index cbd1f235e9..9ffadc6074 100644 --- a/codex-rs/shell-command/src/shell_snapshot_capture.rs +++ b/codex-rs/shell-command/src/shell_snapshot_capture.rs @@ -7,6 +7,8 @@ use super::literals; use super::posix_env_path_expansion_function; use crate::shell_detect::ShellType; use crate::shell_startup_script; +use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; +use codex_protocol::shell_environment::CODEX_TOOL_CALL_ID_ENV_VAR; use std::borrow::Cow; const SNAPSHOT_COMMAND_HELPER: &str = r#"__codex_snapshot_command() { @@ -362,6 +364,13 @@ impl<'a> CapturedSnapshot<'a> { }); } } + // Each launch supplies execution IDs; omit their captured exports. + records.retain(|export| { + !matches!( + export.key, + CODEX_THREAD_ID_ENV_VAR | CODEX_TOOL_CALL_ID_ENV_VAR + ) + }); return Some(Self { startup_environment, shell_type, diff --git a/codex-rs/shell-command/src/shell_snapshot_tests.rs b/codex-rs/shell-command/src/shell_snapshot_tests.rs index 43a8952752..fc5c2259e8 100644 --- a/codex-rs/shell-command/src/shell_snapshot_tests.rs +++ b/codex-rs/shell-command/src/shell_snapshot_tests.rs @@ -81,10 +81,12 @@ fn snapshot_capture_selects_declarations_and_environment() -> Result<()> { ) .unwrap(); let output = Command::new(shell) - .args(["-c", &script]) + .args(["-c", &format!("readonly CODEX_THREAD_ID; {script}")]) .env_clear() .env("HOME", dir.path()) .env("PATH", "/usr/bin:/bin") + .env("CODEX_THREAD_ID", "capture-thread") + .env("CODEX_TOOL_CALL_ID", "profile-call") .env("APP_SETTING", "value") .output()?; assert!(output.status.success(), "{shell}: {output:?}"); @@ -103,6 +105,55 @@ fn snapshot_capture_selects_declarations_and_environment() -> Result<()> { (declarations, environment), "{shell}" ); + if !declarations || !environment { + continue; + } + + // Both replay paths must preserve current metadata from a full capture. + let replay_environment = HashMap::from([ + ("CODEX_THREAD_ID".to_string(), "capture-thread".to_string()), + ("CODEX_TOOL_CALL_ID".to_string(), "profile-call".to_string()), + ("APP_SETTING".to_string(), "value".to_string()), + ]); + let empty = HashMap::new(); + let brokered = prepare_snapshot_credentials( + &captured, + SnapshotCredentialEnvironment { + original: &replay_environment, + restored: &replay_environment, + allowed: &replay_environment, + ..empty_snapshot_credentials(&empty, &|_| true) + }, + |_| true, + ) + .expect("metadata is not a credential"); + for snapshot in [ + captured.render_script( + &codex_protocol::config_types::ShellEnvironmentPolicy::default(), + ), + brokered.script, + ] { + assert!(!snapshot.contains("CODEX_THREAD_ID"), "{shell}: {snapshot}"); + assert!( + !snapshot.contains("CODEX_TOOL_CALL_ID"), + "{shell}: {snapshot}" + ); + let path = dir.path().join("snapshot.sh"); + std::fs::write(&path, snapshot)?; + let replay = Command::new(shell) + .args(["-c", "set -e; . \"$1\"; printf '%s|%s|%s' \"$CODEX_THREAD_ID\" \"$CODEX_TOOL_CALL_ID\" \"$APP_SETTING\"", "replay"]) + .arg(&path) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("CODEX_THREAD_ID", "current-thread") + .env("CODEX_TOOL_CALL_ID", "current-call") + .output()?; + assert!(replay.status.success(), "{shell}: {replay:?}"); + assert_eq!( + replay.stdout, b"current-thread|current-call|value", + "{shell}" + ); + } } } Ok(())