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, + ))) + ); + } }