From 9c070417203bac8469e2d3cac413f739c4e5c4a6 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 12 Jul 2026 19:03:19 -0300 Subject: [PATCH] fix: strip AppImage runtime vars from PTY shell env The AppImage runtime injects ARGV0 (the .AppImage's invocation name), APPIMAGE, APPDIR and OWD into the process environment. Start passed the full os.Environ() to the spawned PTY shell, so mise/asdf activation hooks read ARGV0 and mistook the shell for an invalid tool shim, erroring on every terminal open. childEnv filters only those four runtime variables; the rest of the user's login environment (PATH, SHELL, HOME, LANG, ...) is preserved. Applied at the shared spawn point, so both shell and Claude sessions are covered. Outside an AppImage the vars are absent and childEnv is a no-op. --- internal/terminal/terminal.go | 22 +++++++++++++++++++++- internal/terminal/terminal_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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) {