From 33fa9524261f8c7a9f7b72463188579297b00e61 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 20 Apr 2026 16:39:17 -0700 Subject: [PATCH] fix: fix stale proxy env restoration after shell snapshots (#17271) ## Summary This fixes a stale-environment path in shell snapshot restoration. A sandboxed command can source a shell snapshot that was captured while an older proxy process was running. If that proxy has died and come back on a different port, the snapshot can otherwise put old proxy values back into the command environment, which is how tools like `pip` end up talking to a dead proxy. The wrapper now captures the live process environment before sourcing the snapshot and then restores or clears every proxy env var from the proxy crate's canonical list. That makes proxy state after shell snapshot restoration match the current command environment, rather than whatever proxy values happened to be present in the snapshot. On macOS, the Codex-generated `GIT_SSH_COMMAND` is refreshed when the SOCKS listener changes, while custom SSH wrappers are still left alone. --------- Co-authored-by: Codex --- codex-rs/core/src/tools/runtimes/mod.rs | 76 ++++- codex-rs/core/src/tools/runtimes/mod_tests.rs | 288 ++++++++++++++++++ .../core/tests/suite/realtime_conversation.rs | 5 + codex-rs/network-proxy/src/lib.rs | 6 + codex-rs/network-proxy/src/proxy.rs | 179 ++++++++++- 5 files changed, 537 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 466a01eff..2246fa18f 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -8,6 +8,12 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::path_utils; use crate::shell::Shell; use crate::tools::sandboxing::ToolError; +#[cfg(target_os = "macos")] +use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; +use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; +use codex_network_proxy::PROXY_ENV_KEYS; +#[cfg(target_os = "macos")] +use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxCommand; use codex_utils_absolute_path::AbsolutePathBuf; @@ -102,6 +108,9 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone()); } let (override_captures, override_exports) = build_override_exports(&override_env); + let (proxy_captures, proxy_exports) = build_proxy_env_exports(); + let override_captures = join_shell_blocks([override_captures, proxy_captures]); + let override_exports = join_shell_blocks([override_exports, proxy_exports]); let rewritten_script = if override_exports.is_empty() { format!( "if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}" @@ -118,10 +127,59 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( fn build_override_exports(explicit_env_overrides: &HashMap) -> (String, String) { let mut keys = explicit_env_overrides .keys() + .map(String::as_str) .filter(|key| is_valid_shell_variable_name(key)) .collect::>(); keys.sort_unstable(); + build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys) +} + +fn build_proxy_env_exports() -> (String, String) { + let mut keys = PROXY_ENV_KEYS + .iter() + .copied() + .filter(|key| is_valid_shell_variable_name(key)) + .collect::>(); + keys.sort_unstable(); + keys.dedup(); + + let (captures, restores) = + build_override_exports_for_keys("__CODEX_SNAPSHOT_PROXY_OVERRIDE", &keys); + let key = PROXY_ACTIVE_ENV_KEY; + let proxy_blocks = ( + format!("{captures}\n__CODEX_SNAPSHOT_PROXY_ENV_SET=\"${{{key}+x}}\""), + format!( + "if [ -n \"$__CODEX_SNAPSHOT_PROXY_ENV_SET\" ] || [ -n \"${{{key}+x}}\" ]; then\n{restores}\nfi" + ), + ); + let git_blocks = build_codex_proxy_git_ssh_command_exports(); + ( + join_shell_blocks([proxy_blocks.0, git_blocks.0]), + join_shell_blocks([proxy_blocks.1, git_blocks.1]), + ) +} + +#[cfg(target_os = "macos")] +fn build_codex_proxy_git_ssh_command_exports() -> (String, String) { + let key = PROXY_GIT_SSH_COMMAND_ENV_KEY; + let marker_pattern = format!("{}\\ *", CODEX_PROXY_GIT_SSH_COMMAND_MARKER.trim_end()); + ( + format!( + "__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_SET=\"${{{key}+x}}\"\n__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND=\"${{{key}-}}\"\ncase \"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND\" in\n {marker_pattern}) __CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_LIVE_MARKED=1 ;;\n *) __CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_LIVE_MARKED= ;;\nesac" + ), + format!( + "case \"${{{key}-}}\" in\n {marker_pattern}) __CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_AFTER_MARKED=1 ;;\n *) __CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_AFTER_MARKED= ;;\nesac\nif [ -n \"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_LIVE_MARKED\" ]; then\n if [ -z \"${{{key}+x}}\" ] || [ -n \"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_AFTER_MARKED\" ]; then\n export {key}=\"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND\"\n fi\nelif [ -n \"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_AFTER_MARKED\" ]; then\n if [ -n \"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND_SET\" ]; then\n export {key}=\"$__CODEX_SNAPSHOT_PROXY_GIT_SSH_COMMAND\"\n else\n unset {key}\n fi\nfi" + ), + ) +} + +#[cfg(not(target_os = "macos"))] +fn build_codex_proxy_git_ssh_command_exports() -> (String, String) { + (String::new(), String::new()) +} + +fn build_override_exports_for_keys(variable_prefix: &str, keys: &[&str]) -> (String, String) { if keys.is_empty() { return (String::new(), String::new()); } @@ -130,9 +188,9 @@ fn build_override_exports(explicit_env_overrides: &HashMap) -> ( .iter() .enumerate() .map(|(idx, key)| { - format!( - "__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}=\"${{{key}+x}}\"\n__CODEX_SNAPSHOT_OVERRIDE_{idx}=\"${{{key}-}}\"" - ) + let set_var = format!("{variable_prefix}_SET_{idx}"); + let value_var = format!("{variable_prefix}_{idx}"); + format!("{set_var}=\"${{{key}+x}}\"\n{value_var}=\"${{{key}-}}\"") }) .collect::>() .join("\n"); @@ -140,8 +198,10 @@ fn build_override_exports(explicit_env_overrides: &HashMap) -> ( .iter() .enumerate() .map(|(idx, key)| { + let set_var = format!("{variable_prefix}_SET_{idx}"); + let value_var = format!("{variable_prefix}_{idx}"); format!( - "if [ -n \"${{__CODEX_SNAPSHOT_OVERRIDE_SET_{idx}}}\" ]; then export {key}=\"${{__CODEX_SNAPSHOT_OVERRIDE_{idx}}}\"; else unset {key}; fi" + "if [ -n \"${{{set_var}}}\" ]; then export {key}=\"${{{value_var}}}\"; else unset {key}; fi" ) }) .collect::>() @@ -150,6 +210,14 @@ fn build_override_exports(explicit_env_overrides: &HashMap) -> ( (captures, restores) } +fn join_shell_blocks(blocks: impl IntoIterator) -> String { + blocks + .into_iter() + .filter(|block| !block.is_empty()) + .collect::>() + .join("\n") +} + fn is_valid_shell_variable_name(name: &str) -> bool { let mut chars = name.chars(); let Some(first) = chars.next() else { diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index aee70d233..0c9acd413 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -1,6 +1,11 @@ use super::*; use crate::shell::ShellType; use crate::shell_snapshot::ShellSnapshot; +#[cfg(target_os = "macos")] +use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; +use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; +#[cfg(target_os = "macos")] +use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -325,6 +330,289 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() { assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread"); } +#[test] +fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\n\ + export PIP_PROXY='http://127.0.0.1:8080'\n\ + export HTTP_PROXY='http://127.0.0.1:8080'\n\ + export http_proxy='http://127.0.0.1:8080'\n\ + export GIT_SSH_COMMAND='ssh -o ProxyCommand=stale'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s\\n%s\\n%s\\n%s' \"$PIP_PROXY\" \"$HTTP_PROXY\" \"$http_proxy\" \"$GIT_SSH_COMMAND\"" + .to_string(), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(PROXY_ACTIVE_ENV_KEY, "1") + .env("PIP_PROXY", "http://127.0.0.1:4321") + .env("HTTP_PROXY", "http://127.0.0.1:4321") + .env("http_proxy", "http://127.0.0.1:4321") + .env("GIT_SSH_COMMAND", "ssh -o ProxyCommand=fresh") + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "http://127.0.0.1:4321\n\ + http://127.0.0.1:4321\n\ + http://127.0.0.1:4321\n\ + ssh -o ProxyCommand=stale" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + let stale_command = format!( + "{CODEX_PROXY_GIT_SSH_COMMAND_MARKER}ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'" + ); + let fresh_command = format!( + "{CODEX_PROXY_GIT_SSH_COMMAND_MARKER}ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:48081 %h %p'" + ); + std::fs::write( + &snapshot_path, + format!( + "# Snapshot file\nexport {PROXY_GIT_SSH_COMMAND_ENV_KEY}='{}'\n", + shell_single_quote(&stale_command) + ), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!("printf '%s' \"${PROXY_GIT_SSH_COMMAND_ENV_KEY}\""), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(PROXY_GIT_SSH_COMMAND_ENV_KEY, &fresh_command) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), fresh_command); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_restores_custom_git_ssh_command() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + let stale_command = format!( + "{CODEX_PROXY_GIT_SSH_COMMAND_MARKER}ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'" + ); + let custom_command = "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'"; + std::fs::write( + &snapshot_path, + format!( + "# Snapshot file\nexport {PROXY_GIT_SSH_COMMAND_ENV_KEY}='{}'\n", + shell_single_quote(&stale_command) + ), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!("printf '%s' \"${PROXY_GIT_SSH_COMMAND_ENV_KEY}\""), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(PROXY_GIT_SSH_COMMAND_ENV_KEY, custom_command) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), custom_command); +} + +#[cfg(target_os = "macos")] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_clears_stale_codex_git_ssh_command_without_live_command() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + let stale_command = format!( + "{CODEX_PROXY_GIT_SSH_COMMAND_MARKER}ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'" + ); + std::fs::write( + &snapshot_path, + format!( + "# Snapshot file\nexport {PROXY_GIT_SSH_COMMAND_ENV_KEY}='{}'\n", + shell_single_quote(&stale_command) + ), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!( + "if [ \"${{{PROXY_GIT_SSH_COMMAND_ENV_KEY}+x}}\" = x ]; then printf 'set'; else printf 'unset'; fi" + ), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_remove(PROXY_GIT_SSH_COMMAND_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport HTTP_PROXY='http://user.proxy:8080'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$HTTP_PROXY\"".to_string(), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "http://user.proxy:8080" + ); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_restores_live_env_when_snapshot_proxy_active() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + format!( + "# Snapshot file\n\ + export {PROXY_ACTIVE_ENV_KEY}='1'\n\ + export PIP_PROXY='http://127.0.0.1:8080'\n\ + export HTTP_PROXY='http://127.0.0.1:8080'\n" + ), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!( + "if [ \"${{PIP_PROXY+x}}\" = x ]; then printf 'pip:%s\\n' \"$PIP_PROXY\"; else printf 'pip:unset\\n'; fi; \ + printf 'http:%s\\n' \"$HTTP_PROXY\"; \ + if [ \"${{{PROXY_ACTIVE_ENV_KEY}+x}}\" = x ]; then printf 'active:%s' \"${PROXY_ACTIVE_ENV_KEY}\"; else printf 'active:unset'; fi" + ), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::from([( + "HTTP_PROXY".to_string(), + "http://user.proxy:8080".to_string(), + )]), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("HTTP_PROXY", "http://user.proxy:8080") + .env_remove("PIP_PROXY") + .env_remove(PROXY_ACTIVE_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "pip:unset\nhttp:http://user.proxy:8080\nactive:unset" + ); +} + #[test] fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() { let dir = tempdir().expect("create temp dir"); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 4fb07f69b..dcf5e1f95 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -177,6 +177,11 @@ fn run_realtime_conversation_test_in_subprocess( .arg("--exact") .arg(test_name) .env(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR, "1"); + // The child talks to a loopback websocket server; parent proxy settings can + // route that connection away from the test server in Bazel environments. + for &key in codex_network_proxy::PROXY_ENV_KEYS { + command.env_remove(key); + } match openai_api_key { Some(openai_api_key) => { command.env(OPENAI_API_KEY_ENV_VAR, openai_api_key); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 4fb313805..760b09e21 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -33,11 +33,17 @@ pub use policy::normalize_host; pub use proxy::ALL_PROXY_ENV_KEYS; pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY; pub use proxy::Args; +#[cfg(target_os = "macos")] +pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; pub use proxy::NetworkProxyHandle; +pub use proxy::PROXY_ACTIVE_ENV_KEY; +pub use proxy::PROXY_ENV_KEYS; +#[cfg(target_os = "macos")] +pub use proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub use proxy::PROXY_URL_ENV_KEYS; pub use proxy::has_proxy_url_env_vars; pub use proxy::proxy_url_env_value; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 97ea6735f..426e14ddd 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -363,7 +363,50 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ ]; pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; +pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; +const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; +#[cfg(any(target_os = "macos", test))] +const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; +pub const PROXY_ENV_KEYS: &[&str] = &[ + PROXY_ACTIVE_ENV_KEY, + ALLOW_LOCAL_BINDING_ENV_KEY, + ELECTRON_GET_USE_PROXY_ENV_KEY, + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "npm_config_http_proxy", + "npm_config_https_proxy", + "npm_config_proxy", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", + "WS_PROXY", + "WSS_PROXY", + "ws_proxy", + "wss_proxy", + "NO_PROXY", + "no_proxy", + "npm_config_noproxy", + "NPM_CONFIG_NOPROXY", + "YARN_NO_PROXY", + "BUNDLE_NO_PROXY", + "ALL_PROXY", + "all_proxy", + "FTP_PROXY", + "ftp_proxy", +]; + +#[cfg(target_os = "macos")] +pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY; const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"]; const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"]; @@ -385,6 +428,14 @@ pub const DEFAULT_NO_PROXY_VALUE: &str = concat!( "192.168.0.0/16" ); +#[cfg(target_os = "macos")] +pub const CODEX_PROXY_GIT_SSH_COMMAND_MARKER: &str = "CODEX_PROXY_GIT_SSH_COMMAND=1 "; +#[cfg(target_os = "macos")] +const CODEX_PROXY_GIT_SSH_COMMAND_PREFIX: &str = + "CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x "; +#[cfg(target_os = "macos")] +const CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX: &str = " %h %p'"; + pub fn proxy_url_env_value<'a>( env: &'a HashMap, canonical_key: &str, @@ -408,6 +459,17 @@ fn set_env_keys(env: &mut HashMap, keys: &[&str], value: &str) { } } +#[cfg(target_os = "macos")] +fn codex_proxy_git_ssh_command(socks_addr: SocketAddr) -> String { + format!("{CODEX_PROXY_GIT_SSH_COMMAND_PREFIX}{socks_addr}{CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX}") +} + +#[cfg(target_os = "macos")] +fn is_codex_proxy_git_ssh_command(command: &str) -> bool { + command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_PREFIX) + && command.ends_with(CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX) +} + fn apply_proxy_env_overrides( env: &mut HashMap, http_addr: SocketAddr, @@ -417,6 +479,7 @@ fn apply_proxy_env_overrides( ) { let http_proxy_url = format!("http://{http_addr}"); let socks_proxy_url = format!("socks5h://{socks_addr}"); + env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string()); env.insert( ALLOW_LOCAL_BINDING_ENV_KEY.to_string(), if allow_local_binding { @@ -459,7 +522,10 @@ fn apply_proxy_env_overrides( // locally instead of letting the proxy resolve them. set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE); - env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string()); + env.insert( + ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(), + "true".to_string(), + ); // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if // those vars contain SOCKS URLs. We only switch ALL_PROXY here. @@ -475,9 +541,17 @@ fn apply_proxy_env_overrides( #[cfg(target_os = "macos")] if socks_enabled { // Preserve existing SSH wrappers (for example: Secretive/Teleport setups) - // and only provide a SOCKS ProxyCommand fallback when one is not present. - env.entry("GIT_SSH_COMMAND".to_string()) - .or_insert_with(|| format!("ssh -o ProxyCommand='nc -X 5 -x {socks_addr} %h %p'")); + // but refresh a previously injected Codex fallback so it cannot point + // at a stale proxy port after the proxy is restarted. + match env.get(GIT_SSH_COMMAND_ENV_KEY) { + Some(command) if !is_codex_proxy_git_ssh_command(command) => {} + _ => { + env.insert( + GIT_SSH_COMMAND_ENV_KEY.to_string(), + codex_proxy_git_ssh_command(socks_addr), + ); + } + } } } @@ -936,15 +1010,43 @@ mod tests { assert!(no_proxy.contains("172.16.0.0/12")); assert!(no_proxy.contains("192.168.0.0/16")); assert!(no_proxy.contains("169.254.0.0/16")); + assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string())); assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string())); - assert_eq!(env.get("ELECTRON_GET_USE_PROXY"), Some(&"true".to_string())); + assert_eq!( + env.get(ELECTRON_GET_USE_PROXY_ENV_KEY), + Some(&"true".to_string()) + ); #[cfg(target_os = "macos")] assert_eq!( - env.get("GIT_SSH_COMMAND"), - Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string()) + env.get(GIT_SSH_COMMAND_ENV_KEY), + Some( + &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'" + .to_string() + ) ); #[cfg(not(target_os = "macos"))] - assert_eq!(env.get("GIT_SSH_COMMAND"), None); + assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None); + } + + #[test] + fn apply_proxy_env_overrides_sets_only_expected_env_keys() { + let mut env = HashMap::new(); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + ); + + for key in env.keys() { + let is_managed_git_ssh_key = + cfg!(target_os = "macos") && key == GIT_SSH_COMMAND_ENV_KEY; + assert!( + PROXY_ENV_KEYS.contains(&key.as_str()) || is_managed_git_ssh_key, + "proxy env writer set unexpected key: {key}" + ); + } } #[test] @@ -998,11 +1100,14 @@ mod tests { ); #[cfg(target_os = "macos")] assert_eq!( - env.get("GIT_SSH_COMMAND"), - Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string()) + env.get(GIT_SSH_COMMAND_ENV_KEY), + Some( + &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'" + .to_string() + ) ); #[cfg(not(target_os = "macos"))] - assert_eq!(env.get("GIT_SSH_COMMAND"), None); + assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None); } #[cfg(target_os = "macos")] @@ -1010,7 +1115,7 @@ mod tests { fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() { let mut env = HashMap::new(); env.insert( - "GIT_SSH_COMMAND".to_string(), + GIT_SSH_COMMAND_ENV_KEY.to_string(), "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(), ); apply_proxy_env_overrides( @@ -1022,8 +1127,56 @@ mod tests { ); assert_eq!( - env.get("GIT_SSH_COMMAND"), + env.get(GIT_SSH_COMMAND_ENV_KEY), Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string()) ); } + + #[cfg(target_os = "macos")] + #[test] + fn apply_proxy_env_overrides_preserves_unmarked_git_ssh_command_with_proxy_shape() { + let mut env = HashMap::new(); + env.insert( + GIT_SSH_COMMAND_ENV_KEY.to_string(), + "ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + ); + + assert_eq!( + env.get(GIT_SSH_COMMAND_ENV_KEY), + Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string()) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn apply_proxy_env_overrides_refreshes_previous_codex_proxy_git_ssh_command() { + let mut env = HashMap::new(); + env.insert( + GIT_SSH_COMMAND_ENV_KEY.to_string(), + codex_proxy_git_ssh_command(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081)), + ); + + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + ); + + assert_eq!( + env.get(GIT_SSH_COMMAND_ENV_KEY), + Some(&codex_proxy_git_ssh_command(SocketAddr::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 48081, + ))) + ); + } }