Skip to content

Commit 2e0d034

Browse files
fix(session): resolve user shell env for sidecar spawning
Desktop-launched apps inherit a minimal environment from the display manager, missing PATH entries (nvm/fnm/volta), LD_LIBRARY_PATH, and API keys. This caused node/codex sidecars to fail with library mismatches (e.g. nghttp2) or not be found at all. Add shell_env module that runs the user's login shell once to capture the real environment, caches it, and applies it to child processes.
1 parent 9bea098 commit 2e0d034

4 files changed

Lines changed: 140 additions & 10 deletions

File tree

crates/session/src/claude.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use tokio::process::{Child, Command};
88
use tokio::sync::mpsc;
99
use tracing::debug;
1010

11+
use crate::shell_env;
1112
use crate::types::AgentEvent;
1213

1314
/// Path to the agent sidecar script, relative to the workspace root.
@@ -136,15 +137,25 @@ impl ClaudeSession {
136137

137138
debug!("Spawning agent sidecar: node {}", sidecar_path.display());
138139

139-
let mut child = Command::new("node")
140-
.arg(&sidecar_path)
140+
// Resolve `node` using the user's real shell PATH so that desktop-launched
141+
// apps (which may have a minimal environment) find the correct binary and
142+
// its matching shared libraries (e.g. libnghttp2).
143+
let node_bin = shell_env::which("node")
144+
.unwrap_or_else(|| std::path::PathBuf::from("node"));
145+
146+
let mut cmd = Command::new(&node_bin);
147+
cmd.arg(&sidecar_path)
141148
.current_dir(cwd)
142149
.stdin(std::process::Stdio::piped())
143150
.stdout(std::process::Stdio::piped())
144151
.stderr(std::process::Stdio::piped())
145-
.kill_on_drop(true)
146-
.spawn()
147-
.context("Failed to spawn node agent sidecar")?;
152+
.kill_on_drop(true);
153+
shell_env::apply(&mut cmd);
154+
155+
let mut child = cmd.spawn().context(format!(
156+
"Failed to spawn node agent sidecar (node={})",
157+
node_bin.display()
158+
))?;
148159

149160
let stdout = child.stdout.take().context("Failed to capture stdout")?;
150161
let stdin = child.stdin.take().context("Failed to capture stdin")?;

crates/session/src/codex.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use tokio::sync::{mpsc, oneshot};
1111
use tracing::{debug, error, warn};
1212

1313
use crate::protocol::{self, JsonRpcMessage, JsonRpcResponse};
14+
use crate::shell_env;
1415
use crate::types::AgentEvent;
1516

1617
/// Message sent to the writer task.
@@ -47,15 +48,24 @@ impl CodexSession {
4748
cwd: &Path,
4849
model: &str,
4950
) -> Result<(Self, mpsc::UnboundedReceiver<AgentEvent>)> {
50-
let mut child = Command::new("codex")
51-
.arg("app-server")
51+
// Resolve `codex` using the user's real shell PATH so that desktop-launched
52+
// apps find the correct binary with matching shared libraries.
53+
let codex_bin = shell_env::which("codex")
54+
.unwrap_or_else(|| std::path::PathBuf::from("codex"));
55+
56+
let mut cmd = Command::new(&codex_bin);
57+
cmd.arg("app-server")
5258
.current_dir(cwd)
5359
.stdin(std::process::Stdio::piped())
5460
.stdout(std::process::Stdio::piped())
5561
.stderr(std::process::Stdio::piped())
56-
.kill_on_drop(true)
57-
.spawn()
58-
.context("Failed to spawn codex app-server")?;
62+
.kill_on_drop(true);
63+
shell_env::apply(&mut cmd);
64+
65+
let mut child = cmd.spawn().context(format!(
66+
"Failed to spawn codex app-server (codex={})",
67+
codex_bin.display()
68+
))?;
5969

6070
let stdout = child.stdout.take().context("Failed to capture codex stdout")?;
6171
let stderr = child.stderr.take().context("Failed to capture codex stderr")?;

crates/session/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod claude;
55
pub mod codex;
66
pub mod manager;
77
pub mod protocol;
8+
pub mod shell_env;
89
pub mod types;
910

1011
/// Unique identifier for a session.

crates/session/src/shell_env.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//! Resolve the user's interactive shell environment.
2+
//!
3+
//! Desktop-launched apps (Tauri, Electron, etc.) often inherit a minimal
4+
//! environment that lacks PATH entries, LD_LIBRARY_PATH, nvm/fnm shims, etc.
5+
//! This module runs a one-shot shell to capture the real environment and
6+
//! caches it for the lifetime of the process.
7+
8+
use std::collections::HashMap;
9+
use std::sync::OnceLock;
10+
11+
use tracing::{debug, warn};
12+
13+
/// Cached shell environment, resolved once per process.
14+
static SHELL_ENV: OnceLock<HashMap<String, String>> = OnceLock::new();
15+
16+
/// Get the resolved shell environment. The first call resolves it (blocking);
17+
/// subsequent calls return the cached result instantly.
18+
pub fn get() -> &'static HashMap<String, String> {
19+
SHELL_ENV.get_or_init(resolve_shell_env)
20+
}
21+
22+
/// Apply the resolved shell environment to a [`tokio::process::Command`].
23+
///
24+
/// This merges the resolved env on top of the command's existing environment
25+
/// rather than replacing it wholesale, so Tauri-specific vars are preserved.
26+
pub fn apply(cmd: &mut tokio::process::Command) {
27+
for (key, value) in get() {
28+
cmd.env(key, value);
29+
}
30+
}
31+
32+
/// Resolve the user's login-shell environment by running:
33+
/// $SHELL -l -i -c 'env -0' (preferred, NUL-separated)
34+
/// $SHELL -l -c 'env' (fallback)
35+
///
36+
/// On failure, falls back to the current process environment.
37+
fn resolve_shell_env() -> HashMap<String, String> {
38+
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
39+
debug!("Resolving shell environment using: {shell}");
40+
41+
// Try NUL-separated first (handles values with newlines).
42+
if let Some(env) = run_shell_env(&shell, &["-l", "-i", "-c", "env -0"], true) {
43+
debug!("Resolved {} env vars via `env -0`", env.len());
44+
return env;
45+
}
46+
47+
// Fallback: newline-separated.
48+
if let Some(env) = run_shell_env(&shell, &["-l", "-c", "env"], false) {
49+
debug!("Resolved {} env vars via `env` (newline-delimited)", env.len());
50+
return env;
51+
}
52+
53+
warn!("Could not resolve shell environment; falling back to process env");
54+
std::env::vars().collect()
55+
}
56+
57+
fn run_shell_env(
58+
shell: &str,
59+
args: &[&str],
60+
nul_separated: bool,
61+
) -> Option<HashMap<String, String>> {
62+
let output = std::process::Command::new(shell)
63+
.args(args)
64+
.stdin(std::process::Stdio::null())
65+
.stdout(std::process::Stdio::piped())
66+
.stderr(std::process::Stdio::null())
67+
// Prevent rc files from prompting for input
68+
.env("TERM", "dumb")
69+
.output()
70+
.ok()?;
71+
72+
if !output.status.success() {
73+
return None;
74+
}
75+
76+
let stdout = String::from_utf8_lossy(&output.stdout);
77+
let mut env = HashMap::new();
78+
79+
let entries: Box<dyn Iterator<Item = &str>> = if nul_separated {
80+
Box::new(stdout.split('\0'))
81+
} else {
82+
Box::new(stdout.lines())
83+
};
84+
85+
for entry in entries {
86+
if let Some((key, value)) = entry.split_once('=') {
87+
if !key.is_empty() && !key.contains(|c: char| c.is_whitespace()) {
88+
env.insert(key.to_string(), value.to_string());
89+
}
90+
}
91+
}
92+
93+
if env.is_empty() { None } else { Some(env) }
94+
}
95+
96+
/// Resolve the full path to a command using the resolved shell PATH.
97+
/// Returns None if the command cannot be found.
98+
pub fn which(cmd: &str) -> Option<std::path::PathBuf> {
99+
let env = get();
100+
let path_var = env.get("PATH")?;
101+
for dir in std::env::split_paths(path_var) {
102+
let candidate = dir.join(cmd);
103+
if candidate.is_file() {
104+
return Some(candidate);
105+
}
106+
}
107+
None
108+
}

0 commit comments

Comments
 (0)