@@ -4,10 +4,13 @@ use process_wrap::tokio::CommandWrap;
44use process_wrap:: tokio:: ProcessGroup ;
55#[ cfg( windows) ]
66use process_wrap:: tokio:: { CommandWrapper , JobObject , KillOnDrop } ;
7+ use std:: collections:: HashMap ;
78#[ cfg( unix) ]
89use std:: os:: unix:: process:: ExitStatusExt ;
10+ use std:: path:: Path ;
11+ use std:: process:: Stdio ;
912use std:: sync:: Arc ;
10- use std:: { process :: Stdio , time:: Duration } ;
13+ use std:: time:: { Duration , Instant } ;
1114use tauri:: { AppHandle , Manager , path:: BaseDirectory } ;
1215use tauri_specta:: Event ;
1316use tokio:: {
@@ -39,6 +42,7 @@ impl CommandWrapper for WinCreationFlags {
3942
4043const CLI_INSTALL_DIR : & str = ".opencode/bin" ;
4144const CLI_BINARY_NAME : & str = "opencode" ;
45+ const SHELL_ENV_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
4246
4347#[ derive( serde:: Deserialize , Debug ) ]
4448pub struct ServerConfig {
@@ -232,6 +236,133 @@ fn shell_escape(input: &str) -> String {
232236 escaped
233237}
234238
239+ fn parse_shell_env ( stdout : & [ u8 ] ) -> HashMap < String , String > {
240+ String :: from_utf8_lossy ( stdout)
241+ . split ( '\0' )
242+ . filter_map ( |line| {
243+ if line. is_empty ( ) {
244+ return None ;
245+ }
246+
247+ let ( key, value) = line. split_once ( '=' ) ?;
248+ if key. is_empty ( ) {
249+ return None ;
250+ }
251+
252+ Some ( ( key. to_string ( ) , value. to_string ( ) ) )
253+ } )
254+ . collect ( )
255+ }
256+
257+ fn command_output_with_timeout (
258+ mut cmd : std:: process:: Command ,
259+ timeout : Duration ,
260+ ) -> std:: io:: Result < Option < std:: process:: Output > > {
261+ let mut child = cmd. spawn ( ) ?;
262+ let start = Instant :: now ( ) ;
263+
264+ loop {
265+ if child. try_wait ( ) ?. is_some ( ) {
266+ return child. wait_with_output ( ) . map ( Some ) ;
267+ }
268+
269+ if start. elapsed ( ) >= timeout {
270+ let _ = child. kill ( ) ;
271+ let _ = child. wait ( ) ;
272+ return Ok ( None ) ;
273+ }
274+
275+ std:: thread:: sleep ( Duration :: from_millis ( 25 ) ) ;
276+ }
277+ }
278+
279+ enum ShellEnvProbe {
280+ Loaded ( HashMap < String , String > ) ,
281+ Timeout ,
282+ Unavailable ,
283+ }
284+
285+ fn probe_shell_env ( shell : & str , mode : & str ) -> ShellEnvProbe {
286+ let mut cmd = std:: process:: Command :: new ( shell) ;
287+ cmd. args ( [ mode, "-c" , "env -0" ] ) ;
288+ cmd. stdin ( Stdio :: null ( ) ) ;
289+ cmd. stdout ( Stdio :: piped ( ) ) ;
290+ cmd. stderr ( Stdio :: null ( ) ) ;
291+ let output = match command_output_with_timeout ( cmd, SHELL_ENV_TIMEOUT ) {
292+ Ok ( Some ( output) ) => output,
293+ Ok ( None ) => return ShellEnvProbe :: Timeout ,
294+ Err ( error) => {
295+ tracing:: debug!( shell, mode, ?error, "Shell env probe failed" ) ;
296+ return ShellEnvProbe :: Unavailable ;
297+ }
298+ } ;
299+ if !output. status . success ( ) {
300+ tracing:: debug!( shell, mode, "Shell env probe exited with non-zero status" ) ;
301+ return ShellEnvProbe :: Unavailable ;
302+ }
303+ let env = parse_shell_env ( & output. stdout ) ;
304+ if env. is_empty ( ) {
305+ tracing:: debug!( shell, mode, "Shell env probe returned empty env" ) ;
306+ return ShellEnvProbe :: Unavailable ;
307+ }
308+
309+ ShellEnvProbe :: Loaded ( env)
310+ }
311+
312+ fn is_nushell ( shell : & str ) -> bool {
313+ let shell_name = Path :: new ( shell)
314+ . file_name ( )
315+ . and_then ( |name| name. to_str ( ) )
316+ . unwrap_or ( shell)
317+ . to_ascii_lowercase ( ) ;
318+ shell_name == "nu" || shell_name == "nu.exe" || shell. to_ascii_lowercase ( ) . ends_with ( "\\ nu.exe" )
319+ }
320+ fn load_shell_env ( shell : & str ) -> Option < HashMap < String , String > > {
321+ if is_nushell ( shell) {
322+ tracing:: debug!( shell, "Skipping shell env probe for nushell" ) ;
323+ return None ;
324+ }
325+
326+ match probe_shell_env ( shell, "-il" ) {
327+ ShellEnvProbe :: Loaded ( env) => {
328+ tracing:: info!(
329+ shell,
330+ env_count = env. len( ) ,
331+ "Loaded shell environment with -il"
332+ ) ;
333+ return Some ( env) ;
334+ }
335+ ShellEnvProbe :: Timeout => {
336+ tracing:: warn!( shell, "Interactive shell env probe timed out" ) ;
337+ return None ;
338+ }
339+ ShellEnvProbe :: Unavailable => { }
340+ }
341+
342+ if let ShellEnvProbe :: Loaded ( env) = probe_shell_env ( shell, "-l" ) {
343+ tracing:: info!(
344+ shell,
345+ env_count = env. len( ) ,
346+ "Loaded shell environment with -l"
347+ ) ;
348+ return Some ( env) ;
349+ }
350+ tracing:: warn!( shell, "Falling back to app environment" ) ;
351+ None
352+ }
353+
354+ fn merge_shell_env (
355+ shell_env : Option < HashMap < String , String > > ,
356+ envs : Vec < ( String , String ) > ,
357+ ) -> Vec < ( String , String ) > {
358+ let mut merged = shell_env. unwrap_or_default ( ) ;
359+ for ( key, value) in envs {
360+ merged. insert ( key, value) ;
361+ }
362+
363+ merged. into_iter ( ) . collect ( )
364+ }
365+
235366pub fn spawn_command (
236367 app : & tauri:: AppHandle ,
237368 args : & str ,
@@ -312,6 +443,7 @@ pub fn spawn_command(
312443 } else {
313444 let sidecar = get_sidecar_path ( app) ;
314445 let shell = get_user_shell ( ) ;
446+ let envs = merge_shell_env ( load_shell_env ( & shell) , envs) ;
315447
316448 let line = if shell. ends_with ( "/nu" ) {
317449 format ! ( "^\" {}\" {}" , sidecar. display( ) , args)
@@ -556,3 +688,54 @@ async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
556688 }
557689 }
558690}
691+
692+ #[ cfg( test) ]
693+ mod tests {
694+ use super :: * ;
695+ use std:: collections:: HashMap ;
696+
697+ #[ test]
698+ fn parse_shell_env_supports_null_delimited_pairs ( ) {
699+ let env = parse_shell_env ( b"PATH=/usr/bin:/bin\0 FOO=bar=baz\0 \0 " ) ;
700+
701+ assert_eq ! ( env. get( "PATH" ) , Some ( & "/usr/bin:/bin" . to_string( ) ) ) ;
702+ assert_eq ! ( env. get( "FOO" ) , Some ( & "bar=baz" . to_string( ) ) ) ;
703+ }
704+
705+ #[ test]
706+ fn parse_shell_env_ignores_invalid_entries ( ) {
707+ let env = parse_shell_env ( b"INVALID\0 =empty\0 OK=1\0 " ) ;
708+
709+ assert_eq ! ( env. len( ) , 1 ) ;
710+ assert_eq ! ( env. get( "OK" ) , Some ( & "1" . to_string( ) ) ) ;
711+ }
712+
713+ #[ test]
714+ fn merge_shell_env_keeps_explicit_overrides ( ) {
715+ let mut shell_env = HashMap :: new ( ) ;
716+ shell_env. insert ( "PATH" . to_string ( ) , "/shell/path" . to_string ( ) ) ;
717+ shell_env. insert ( "HOME" . to_string ( ) , "/tmp/home" . to_string ( ) ) ;
718+
719+ let merged = merge_shell_env (
720+ Some ( shell_env) ,
721+ vec ! [
722+ ( "PATH" . to_string( ) , "/desktop/path" . to_string( ) ) ,
723+ ( "OPENCODE_CLIENT" . to_string( ) , "desktop" . to_string( ) ) ,
724+ ] ,
725+ )
726+ . into_iter ( )
727+ . collect :: < HashMap < _ , _ > > ( ) ;
728+
729+ assert_eq ! ( merged. get( "PATH" ) , Some ( & "/desktop/path" . to_string( ) ) ) ;
730+ assert_eq ! ( merged. get( "HOME" ) , Some ( & "/tmp/home" . to_string( ) ) ) ;
731+ assert_eq ! ( merged. get( "OPENCODE_CLIENT" ) , Some ( & "desktop" . to_string( ) ) ) ;
732+ }
733+
734+ #[ test]
735+ fn is_nushell_handles_path_and_binary_name ( ) {
736+ assert ! ( is_nushell( "nu" ) ) ;
737+ assert ! ( is_nushell( "/opt/homebrew/bin/nu" ) ) ;
738+ assert ! ( is_nushell( "C:\\ Program Files\\ nu.exe" ) ) ;
739+ assert ! ( !is_nushell( "/bin/zsh" ) ) ;
740+ }
741+ }
0 commit comments