diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 4bae8f6b3..96b7aeede 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "os/exec" + "strings" "sync" "github.com/creack/pty" @@ -95,6 +96,25 @@ func resolveCommand(kind, bin, shellEnv string) string { return resolveBin(bin) } +// appImageVars are set by the AppImage runtime and must not leak into the shell: +// ARGV0 (the .AppImage's invocation name) makes mise/asdf-style shims misread the +// shell as an invalid shim, and the rest are runtime internals a child never needs. +var appImageVars = map[string]bool{"ARGV0": true, "APPIMAGE": true, "APPDIR": true, "OWD": true} + +// childEnv strips AppImage runtime variables from env so spawned shells inherit a +// clean environment. Outside an AppImage none are present and env is returned as-is. +func childEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + key, _, _ := strings.Cut(kv, "=") + if appImageVars[key] { + continue + } + out = append(out, kv) + } + return out +} + // Start spawns the binary for session id under project projectID — the user's // shell when kind is "shell", otherwise the Claude Code binary resolved from the // project's settings (falling back to "claude" via $PATH) — attached to a new @@ -119,7 +139,7 @@ func (s *Service) Start(id, projectID, cwd, kind string, cols, rows int) error { cmd := exec.Command(resolveCommand(kind, s.bins.ClaudeBin(projectID), os.Getenv("SHELL"))) cmd.Dir = cwd - cmd.Env = append(os.Environ(), "TERM=xterm-256color") + cmd.Env = append(childEnv(os.Environ()), "TERM=xterm-256color") ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) if err != nil { diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 9fceda39d..5d07719cb 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -15,6 +15,30 @@ type stubBins struct{ bin string } func (s stubBins) ClaudeBin(string) string { return s.bin } +// TestChildEnvStripsAppImageVars proves the AppImage runtime variables that break +// mise/asdf shims are dropped while the real user environment is passed through. +func TestChildEnvStripsAppImageVars(t *testing.T) { + in := []string{ + "PATH=/usr/bin", + "ARGV0=lich.AppImage", + "APPIMAGE=/tmp/lich.AppImage", + "APPDIR=/tmp/.mount_lich", + "OWD=/home/user", + "HOME=/home/user", + } + got := strings.Join(childEnv(in), "\n") + for _, want := range []string{"PATH=/usr/bin", "HOME=/home/user"} { + if !strings.Contains(got, want) { + t.Errorf("childEnv dropped %q", want) + } + } + for _, gone := range []string{"ARGV0", "APPIMAGE", "APPDIR", "OWD"} { + if strings.Contains(got, gone+"=") { + t.Errorf("childEnv leaked AppImage var %q", gone) + } + } +} + // TestOperationsOnUnknownSessionAreNoops proves Write/Resize/Close on a session // that was never started return nil instead of panicking on a missing PTY. func TestOperationsOnUnknownSessionAreNoops(t *testing.T) {