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
This commit is contained in:
Leonardo Di Donato
2026-10-08 13:45:19 +00:00
committed by copyberry
parent 2fdf047c96
commit 9b738582b1
18 changed files with 610 additions and 34 deletions
+99
View File
@@ -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<String, String> {
};
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<String, String>,
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(&params), 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(&params),
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(&params),
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();
@@ -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));
@@ -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")]