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.
This commit is contained in:
l0ng-ai
2026-10-27 17:16:47 +08:00
parent 145f168525
commit 866af3d9cb
5 changed files with 93 additions and 10 deletions
+33
View File
@@ -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<Vec<(OsString, OsString)>> = 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<Item = (&'static OsStr, &'static OsStr)> {
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"))]);
}
}
+6 -1
View File
@@ -422,7 +422,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
let mut cmd = tokio::process::Command::new(program);
ide_core::process::no_window(cmd.as_std_mut());
cmd.args(rest).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
+1
View File
@@ -312,6 +312,7 @@ impl Peer {
ide_core::process::no_window(cmd.as_std_mut());
cmd.args(args)
.current_dir(cwd)
.envs(ide_core::process::shell_env())
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
+6
View File
@@ -406,6 +406,10 @@ impl ProcessManager {
cmd.cwd(cwd);
if env.clear {
cmd.env_clear();
} else {
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
@@ -610,6 +614,8 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
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);
}
+47 -9
View File
@@ -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<impl FnOnce() + use<
Some(path) => 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<impl FnOnce() + use<
let cache = cache.filter(|_| refresh)?;
Some(move || {
std::thread::spawn(move || {
if let Some(fresh) = read_login_path(Duration::from_secs(10)) {
let Some(env) = read_login_env(Duration::from_secs(10)) else { return };
if let Some(fresh) = login_path(&env) {
write_cache(&cache, &fresh);
}
ide_core::process::set_shell_env(env);
});
})
}
@@ -50,20 +61,37 @@ fn write_cache(cache: &std::path::Path, path: &str) {
}
#[cfg(unix)]
fn read_login_path(timeout: std::time::Duration) -> Option<String> {
fn login_path(env: &[(OsString, OsString)]) -> Option<String> {
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<Vec<(OsString, OsString)>> {
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<String> {
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))]