diff --git a/internal/proc/path_other.go b/internal/proc/path_other.go index ac801bb1..cf442a72 100644 --- a/internal/proc/path_other.go +++ b/internal/proc/path_other.go @@ -171,11 +171,22 @@ func shellEnv() (string, map[string]string) { } // askShell runs sh as a terminal opens it and reads what shellEnv says. +// +// The shell is a probe (ProbeContext): it leads a session of its own, with +// no controlling terminal. An interactive zsh in magpie's session took the +// terminal it was started from — it puts itself in the foreground, as a +// shell does for its jobs — and while it ran, magpie's own process group +// was in the background there: Ctrl-C no longer reached `magpie web`, and +// anything in that group touching the terminal (the bash asked beside it, +// whose profile runs in magpie's group, an agent's CLI) had the kernel stop +// the whole group, magpie with it (DD on Discord: magpie web → zsh: +// suspended (tty input)). A timeout also killed only the shell, leaving +// what its profile started holding the terminal; a probe's whole group goes. func askShell(sh string) (string, map[string]string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // interactive too, since many put PATH in .zshrc/.bashrc - cmd := CommandContext(ctx, sh, "-ilc", shellProbe(sh)) + cmd := ProbeContext(ctx, sh, "-ilc", shellProbe(sh)) cmd.Stdin = nil out, _ := cmd.Output() return parseShellEnv(string(out), shellMark) diff --git a/internal/proc/path_other_test.go b/internal/proc/path_other_test.go index d4943043..d1f40dbf 100644 --- a/internal/proc/path_other_test.go +++ b/internal/proc/path_other_test.go @@ -6,7 +6,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" + "syscall" "testing" "time" @@ -142,3 +144,35 @@ func TestShellEnvNushell(t *testing.T) { t.Fatalf("CLAUDE_CONFIG_DIR = %q, want none", got) } } + +// The login shell is asked outside magpie's process group and session (DD +// on Discord: magpie web → zsh: suspended (tty input)). An interactive zsh +// in magpie's session puts itself in the terminal's foreground, which left +// magpie's group in the background there: Ctrl-C no longer reached it, and +// anything in the group that touched the terminal had the kernel stop all +// of it. The fake shell answers with its own process group and session; a +// real zsh, where there is one, still gives its PATH when asked that way. +func TestAskShellOwnSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + sh := filepath.Join(home, "sh") + testenv.Program(t, sh, "#!/bin/sh\nprintf '"+shellMark+"%s %s"+shellMark+"' \"$(ps -o pgid= -p $$ | tr -d ' ')\" \"$(ps -o sess= -p $$ | tr -d ' ')\"\n") + out, _ := askShell(sh) + f := strings.Fields(out) + if len(f) != 2 { + t.Fatalf("the fake shell's answer: %q", out) + } + if mine := strconv.Itoa(syscall.Getpgrp()); f[0] == mine { + t.Fatalf("the login shell ran in magpie's process group %s: a shell there takes the terminal from magpie", mine) + } + if sid, err := syscall.Getsid(0); err == nil && f[1] == strconv.Itoa(sid) && f[1] != "0" { + t.Fatalf("the login shell ran in magpie's session %s: it can take magpie's terminal", f[1]) + } + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("zsh isn't installed") + } + if p := ShellPath(zsh); len(p) == 0 { + t.Fatalf("zsh asked in a session of its own gave no PATH") + } +}