From 866af3d9cb6fce3e040c39b6560b0dfe1d6a4762 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:16:35 +0800 Subject: [PATCH] fix(agents): Give child processes the login shell's environment An app started from Finder or the Dock took only PATH from the login shell, so variables exported in rc files never reached agents, language servers, run configurations or debug targets. Claude Code configured for Bedrock through CLAUDE_CODE_USE_BEDROCK / AWS_REGION in ~/.zshrc fell back to the Anthropic API and failed with 403. The shell's environment is read once at startup (in the background when PATH comes from the cache) and kept in memory only, since it may hold secrets. Spawns add the variables this process lacks before their own. --- crates/ide-core/src/process.rs | 33 ++++++++++++++++++++ crates/ide-dap/src/session.rs | 7 ++++- crates/ide-jsonrpc/src/lib.rs | 1 + crates/ide-run/src/process.rs | 6 ++++ src-tauri/src/shell_env.rs | 56 ++++++++++++++++++++++++++++------ 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index 78182623..3ccb3853 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -1,6 +1,8 @@ //! Child-process helpers shared by every crate that spawns tools. +use std::ffi::{OsStr, OsString}; use std::process::Command; +use std::sync::OnceLock; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags. Prefer [`no_window`] otherwise. @@ -18,3 +20,34 @@ pub fn no_window(cmd: &mut Command) -> &mut Command { } cmd } + +static SHELL_ENV: OnceLock> = OnceLock::new(); + +/// Records the login shell's environment, read once at startup off the main thread. +pub fn set_shell_env(vars: Vec<(OsString, OsString)>) { + let _ = SHELL_ENV.set(vars); +} + +/// The login shell's variables this process lacks: an app started from Finder/Dock gets only +/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...). +/// Apply before a call site's own variables so those win. Empty until the shell has been read. +pub fn shell_env() -> impl Iterator { + SHELL_ENV + .get() + .into_iter() + .flatten() + .filter(|(k, _)| std::env::var_os(k).is_none()) + .map(|(k, v)| (k.as_os_str(), v.as_os_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_env_adds_only_what_the_process_lacks() { + set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); + let vars: Vec<_> = shell_env().collect(); + assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]); + } +} diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 27ac375b..6219f4bf 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -422,7 +422,12 @@ pub(crate) fn run_in_terminal(s: &Arc, args: &Value) -> Result IdeResult { fn apply_env(cmd: &mut Command, env: &LaunchEnv) { if env.clear { cmd.env_clear(); + } else { + cmd.envs(ide_core::process::shell_env()); } cmd.envs(&env.vars); } diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs index 7be13170..d00d2a67 100644 --- a/src-tauri/src/shell_env.rs +++ b/src-tauri/src/shell_env.rs @@ -5,6 +5,13 @@ //! Spawning an interactive login shell costs ~1 s with typical rc files, so the //! value is cached: a warm start uses the cached PATH immediately and refreshes //! the cache in the background for the next launch. +//! +//! The rest of the shell's environment (API keys, provider switches, SDK homes) +//! goes to child processes through `ide_core::process::shell_env`. It stays in +//! memory, never in the cache: it may hold secrets. + +#[cfg(unix)] +use std::ffi::{OsStr, OsString}; #[cfg(unix)] /// Returns a refresh to run once the environment is final (it spawns a @@ -22,7 +29,9 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option path, None => { // Slow shell rc files must not delay the first start indefinitely. - let fresh = read_login_path(Duration::from_secs(3))?; + let env = read_login_env(Duration::from_secs(3))?; + let fresh = login_path(&env)?; + ide_core::process::set_shell_env(env); if let Some(cache) = &cache { write_cache(cache, &fresh); } @@ -34,9 +43,11 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option Option { +fn login_path(env: &[(OsString, OsString)]) -> Option { + env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.to_str()).filter(|p| !p.is_empty()).map(str::to_owned) +} + +/// Variables the shell sets for itself rather than the user's configuration. +#[cfg(unix)] +const SHELL_OWN: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_"]; + +/// The login shell's environment; PATH included, the shell's own bookkeeping left out. +#[cfg(unix)] +fn read_login_env(timeout: std::time::Duration) -> Option> { use std::io::Read; + use std::os::unix::ffi::OsStrExt; use std::process::{Command, Stdio}; use std::time::Instant; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); - const MARK: &str = "__OPENIDEA_PATH__"; + const MARK: &str = "__OPENIDEA_ENV__"; let mut child = Command::new(&shell) - .args(["-ilc", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")]) + .args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + // Read while waiting: a large environment would fill the pipe and stall the shell. + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut out = Vec::new(); + stdout.read_to_end(&mut out).map(|_| out) + }); let deadline = Instant::now() + timeout; loop { match child.try_wait() { @@ -71,14 +99,24 @@ fn read_login_path(timeout: std::time::Duration) -> Option { Ok(None) if Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)), _ => { let _ = child.kill(); - tracing::warn!("timed out reading PATH from {shell}"); + tracing::warn!("timed out reading the environment from {shell}"); return None; } } } - let mut out = String::new(); - child.stdout.take()?.read_to_string(&mut out).ok()?; - out.split(MARK).nth(1).filter(|p| !p.is_empty()).map(str::to_owned) + let out = reader.join().ok()?.ok()?; + let mark = MARK.as_bytes(); + let start = out.windows(mark.len()).position(|w| w == mark)? + mark.len(); + let len = out[start..].windows(mark.len()).position(|w| w == mark)?; + let vars = out[start..start + len] + .split(|&b| b == 0) + .filter_map(|entry| { + let eq = entry.iter().position(|&b| b == b'=')?; + let (k, v) = (OsStr::from_bytes(&entry[..eq]), OsStr::from_bytes(&entry[eq + 1..])); + (!k.is_empty() && !SHELL_OWN.iter().any(|s| k == *s)).then(|| (k.to_owned(), v.to_owned())) + }) + .collect(); + Some(vars) } #[cfg(not(unix))]