diff --git a/README.md b/README.md index 616beb73..0fde1313 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,8 @@ the same sessions back on their conversations. A single one: `gate-inbox kill " + tmux.ShellQuote(dry) + "; " + tmux.ShellQuote(bin) + " stop --json" + if adopted { + db, err := sql.Open("sqlite", filepath.Join(home, "state.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec("UPDATE sessions SET tmux_socket = ?, tmux_pane_id = ? WHERE id = ?", socket, pane, target); err != nil { + t.Fatal(err) + } + } // respawn-pane puts the command inside the actual managed surface, so // this catches lifecycle writes accidentally left in the killed process. - tmuxCommand("respawn-pane", "-k", "-t", pane, "env GATE_INBOX_HOME="+tmux.ShellQuote(home)+" GATE_INBOX_SESSION_ID="+tmux.ShellQuote(target)+" sh -c "+tmux.ShellQuote(command)) + launchEnv := "env GATE_INBOX_HOME=" + tmux.ShellQuote(home) + " GATE_INBOX_SESSION_ID=" + tmux.ShellQuote(target) + if adopted { + launchEnv = "env -u GATE_INBOX_SESSION_ID GATE_INBOX_HOME=" + tmux.ShellQuote(home) + } + tmuxCommand("respawn-pane", "-k", "-t", pane, launchEnv+" sh -c "+tmux.ShellQuote(command)) deadline := time.Now().Add(15 * time.Second) var ends map[string]store.SessionEnd for time.Now().Before(deadline) { @@ -124,3 +143,59 @@ func testSelfStop(t *testing.T, withSentinel bool) { t.Fatalf("kill observer: %q, want %q", got, want) } } + +func TestStopRefusesSharedDaemonEvenWithoutThreadOrManagedEnvironment(t *testing.T) { + bin := buildFixture(t) + socket := tmuxtest.Socket(t, "sharedstop") + env := fixtureHome(t, "tmux_socket = \""+socket+"\"\n"+promptTool) + home := envValue(env, "GATE_INBOX_HOME") + seedSessions(t, filepath.Join(home, "state.db")) + env = withoutKey(withoutKey(env, "GATE_INBOX_SESSION_ID"), "CODEX_THREAD_ID") + tmuxCmd := func(args ...string) string { + t.Helper() + cmd := exec.Command("tmux", append([]string{"-L", socket}, args...)...) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("tmux: %v: %s", err, out) + } + return strings.TrimSpace(string(out)) + } + tmuxCmd("new-session", "-d", "-s", "sentinel", "sleep 60") + pane := tmuxCmd("display-message", "-p", "-t", "sentinel", "#{pane_id}") + state, err := store.Open(filepath.Join(home, "state.db")) + if err != nil { + t.Fatal(err) + } + defer state.Close() + row := store.Session{ID: "daem0001", Tool: "codex", Name: "adopted", Status: "working", CreatedAt: time.Now(), TmuxSocket: socket, TmuxPaneID: pane, AgentSessionID: "unverified-thread"} + if err := state.CreateSession(row); err != nil { + t.Fatal(err) + } + fake := filepath.Join(t.TempDir(), "codex") + build := exec.Command("go", "build", "-o", fake, "./testdata/stopcaller") + build.Env = tmuxtest.Environ() + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v: %s", err, out) + } + for _, thread := range []string{"", "unverified-thread", "another-thread"} { + output := filepath.Join(t.TempDir(), "result.json") + cmd := exec.Command(fake, "app-server", bin, output) + cmd.Env = append(env, "CODEX_THREAD_ID="+thread, "TMUX_PANE="+pane) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) + data := readEventually(t, output) + if !strings.Contains(data, "cannot verify a Gate Inbox caller pane") || !strings.Contains(data, "\"Failed\":true") { + t.Fatalf("unsafe daemon classification: %s", data) + } + if got := tmuxCmd("display-message", "-p", "-t", pane, "#{pane_id}"); got != pane { + t.Fatal("sentinel pane ended") + } + } + ends, err := state.SessionEnds() + if err != nil || len(ends) != 0 { + t.Fatalf("refusal wrote lifecycle: %v, %v", ends, err) + } +} diff --git a/app/testdata/stopcaller/main.go b/app/testdata/stopcaller/main.go new file mode 100644 index 00000000..30de0b8c --- /dev/null +++ b/app/testdata/stopcaller/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "time" +) + +func main() { + cmd := exec.Command(os.Args[2], "stop", "--if-managed", "--dry-run", "--json") + out, err := cmd.CombinedOutput() + result := struct { + Output string + Failed bool + }{string(out), err != nil} + data, _ := json.Marshal(result) + if err := os.WriteFile(os.Args[3], data, 0600); err != nil { + panic(err) + } + time.Sleep(time.Minute) +} diff --git a/compat/testdata/cli/usage.golden b/compat/testdata/cli/usage.golden index 7eb964ec..f00f8048 100644 --- a/compat/testdata/cli/usage.golden +++ b/compat/testdata/cli/usage.golden @@ -122,6 +122,8 @@ exit 0 usage: gate-inbox stop [--dry-run] [--json] -dry-run verify the calling session without ending it + -if-managed + report unmanaged callers without stopping them -json print the raw result as JSON instead of a sentence diff --git a/internal/cli/stop.go b/internal/cli/stop.go index ad4b19b2..75a8f830 100644 --- a/internal/cli/stop.go +++ b/internal/cli/stop.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "io" "os" "os/signal" @@ -12,12 +13,16 @@ import ( func runStop(out io.Writer, sessions sessionCommands, args []string, sessionID string) error { set := cmdline.NewFlagSet(usageStop) + ifManaged := set.Bool("if-managed", false, "report unmanaged callers without stopping them") dryRun := set.Bool("dry-run", false, "verify the calling session without ending it") asJSON := cmdline.JSONFlag(set) if _, err := parseCommand(out, set, args, 0, 0); err != nil { return err } result, err := sessions.Stop(sessionID, *dryRun) + if errors.Is(err, sessioncmd.ErrUnmanagedCaller) && *ifManaged { + return cmdline.Emit(out, *asJSON, sessioncmd.StopResult{DryRun: *dryRun}, "caller is unmanaged") + } if err != nil { return err } @@ -29,14 +34,14 @@ func runStop(out io.Writer, sessions sessionCommands, args []string, sessionID s } func finishStop(args []string, sessionID, configDir string) error { - set := cmdline.NewFlagSet("_finish-stop ") - operands, err := parseCommand(os.Stdout, set, args, 2, 2) + set := cmdline.NewFlagSet("_finish-stop ") + operands, err := parseCommand(os.Stdout, set, args, 3, 3) if err != nil { return err } // This runs as a run-shell -b job, which tmux SIGTERMs when ending the // last session empties the server and leaves its output pipe closed. signal.Ignore(syscall.SIGTERM, syscall.SIGHUP, syscall.SIGPIPE) - _, err = sessioncmd.NewSessions(configDir, sessioncmd.CLIVocabulary()).FinishStop(sessionID, operands[0], operands[1]) + _, err = sessioncmd.NewSessions(configDir, sessioncmd.CLIVocabulary()).FinishStop(sessionID, operands[0], operands[1], operands[2]) return err } diff --git a/internal/cli/stop_test.go b/internal/cli/stop_test.go index 75c662bf..2c164b79 100644 --- a/internal/cli/stop_test.go +++ b/internal/cli/stop_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "github.com/usestring/gate-inbox/internal/sessioncmd" "testing" ) @@ -24,3 +25,19 @@ func TestStopTakesOnlyTheCallerAndReportsDryRun(t *testing.T) { t.Fatalf("lost stop failure: %v", err) } } + +func TestStopIfManagedOnlyAcceptsExactUnmanagedResult(t *testing.T) { + fake := &fakeSessions{failWith: sessioncmd.ErrUnmanagedCaller} + out := &bytes.Buffer{} + if err := runStop(out, fake, []string{"--if-managed", "--dry-run", "--json"}, ""); err != nil { + t.Fatal(err) + } + var result sessioncmd.StopResult + if err := json.Unmarshal(out.Bytes(), &result); err != nil || result.Managed || !result.DryRun { + t.Fatalf("unmanaged: %s, %v", out, err) + } + fake.failWith = errors.New("cannot read caller process") + if err := runStop(out, fake, []string{"--if-managed", "--dry-run", "--json"}, ""); !errors.Is(err, fake.failWith) { + t.Fatalf("verification failure became unmanaged: %v", err) + } +} diff --git a/internal/sessioncmd/stop.go b/internal/sessioncmd/stop.go index 864027bd..68f7689e 100644 --- a/internal/sessioncmd/stop.go +++ b/internal/sessioncmd/stop.go @@ -2,35 +2,59 @@ package sessioncmd import ( "errors" + "fmt" "os" + "path/filepath" + "slices" + "strconv" "strings" "time" + "github.com/shirou/gopsutil/v4/process" "github.com/usestring/gate-inbox/extension" + "github.com/usestring/gate-inbox/internal/store" "github.com/usestring/gate-inbox/internal/tmux" ) +var ErrUnmanagedCaller = errors.New("caller is not a Gate Inbox session") + type StopResult struct { - Target Session `json:"session"` - DryRun bool `json:"dry_run"` + Managed bool `json:"managed"` + Target Session `json:"session"` + DryRun bool `json:"dry_run"` } // Stop leaves the lifecycle worker outside the pane it will end, so killing // the caller cannot interrupt the end record or the extensions' kill notices. func (s *Sessions) Stop(sessionID string, dryRun bool) (StopResult, error) { + if sessionID == "" { + if _, err := os.Stat(filepath.Join(s.configDir, "state.db")); errors.Is(err, os.ErrNotExist) { + return StopResult{}, ErrUnmanagedCaller + } else if err != nil { + return StopResult{}, err + } + } runtime, err := s.open() if err != nil { return StopResult{}, err } defer runtime.store.Close() - target, err := runtime.agent(sessionID) + target, err := runtime.stopCaller(sessionID) + if err != nil { + return StopResult{}, err + } + pin, err := runtime.paneProcess(target) + if err != nil { + return StopResult{}, err + } + owned, err := runtime.paneAncestor(target) if err != nil { return StopResult{}, err } - if target.TmuxPaneID != "" || runtime.driver.OwnSessionID() != target.ID { + if !owned { return StopResult{}, errors.New("stop must run inside its own managed session pane") } - result := StopResult{Target: runtime.sessionInfo(target, true, true), DryRun: dryRun} + result := StopResult{Managed: true, Target: runtime.sessionInfo(target, true, true), DryRun: dryRun} if dryRun { return result, nil } @@ -45,7 +69,11 @@ func (s *Sessions) Stop(sessionID string, dryRun bool) (StopResult, error) { command = append(command, name+"="+value) } } - command = append(command, executable, "_finish-stop", target.LaunchTime().Format(time.RFC3339Nano), os.Getenv("TMUX_PANE")) + pane, err := runtime.driver.PaneID(target.ID) + if err != nil { + return StopResult{}, err + } + command = append(command, executable, "_finish-stop", target.LaunchTime().Format(time.RFC3339Nano), pane, pin) for i, word := range command { command[i] = tmux.ShellQuote(word) } @@ -66,7 +94,7 @@ func (s *Sessions) Stop(sessionID string, dryRun bool) (StopResult, error) { // FinishStop pins the queued request to one launch and pane: an intervening // revive must not let the old worker end a new agent on the same board row. -func (s *Sessions) FinishStop(sessionID, launched, pane string) (Session, error) { +func (s *Sessions) FinishStop(sessionID, launched, pane, processPin string) (Session, error) { runtime, err := s.open() if err != nil { return Session{}, err @@ -76,9 +104,12 @@ func (s *Sessions) FinishStop(sessionID, launched, pane string) (Session, error) if err != nil { return Session{}, err } - if target.TmuxPaneID != "" || launched != target.LaunchTime().Format(time.RFC3339Nano) { + if launched != target.LaunchTime().Format(time.RFC3339Nano) { return Session{}, errors.New("stop request no longer matches this managed launch") } + if err := runtime.reach(target); err != nil { + return Session{}, err + } current, err := runtime.driver.PaneID(target.ID) if err != nil { return Session{}, err @@ -86,7 +117,11 @@ func (s *Sessions) FinishStop(sessionID, launched, pane string) (Session, error) if pane == "" || current != pane { return Session{}, errors.New("stop request no longer matches this session's pane") } - return s.stopSession(runtime, target, extension.KillByCLI, sessionID, func(id string) error { + currentPin, err := runtime.paneProcess(target) + if err != nil || currentPin != processPin { + return Session{}, errors.New("stop request no longer matches this pane process") + } + return s.stopSession(runtime, target, extension.KillByCLI, target.ID, func(id string) error { row, err := runtime.store.Get(id) if err != nil { return err @@ -94,6 +129,149 @@ func (s *Sessions) FinishStop(sessionID, launched, pane string) (Session, error) if launched != row.LaunchTime().Format(time.RFC3339Nano) { return errors.New("stop request no longer matches this managed launch") } - return runtime.driver.KillPane(id, pane) + if row.TmuxPaneID != target.TmuxPaneID || row.TmuxSocket != target.TmuxSocket { + return errors.New("stop request no longer matches this adopted target") + } + pid, _, _ := strings.Cut(processPin, ":") + if target.TmuxPaneID != "" { + return runtime.driver.KillAdoptedPane(id, pane, pid) + } + return runtime.driver.KillPane(id, pane, pid) }) } + +func (r *runtime) stopCaller(id string) (store.Session, error) { + if id != "" { + target, err := r.agent(id) + if err != nil { + return target, err + } + if err := r.reach(target); err != nil { + return target, err + } + return target, nil + } + rows, err := r.store.ListSessions(true) + if err != nil { + return store.Session{}, err + } + var matches []store.Session + unresolvedCodex := false + shared, err := sharedCodexAncestor() + if err != nil { + return store.Session{}, err + } + for _, row := range rows { + if r.cfg.Tools[row.Tool].Shell { + continue + } + if err := r.reach(row); err != nil { + return store.Session{}, err + } + if !row.Archived && row.Status != "dead" { + owned, err := r.paneAncestor(row) + if err != nil { + return store.Session{}, err + } + if owned { + matches = append(matches, row) + } + } + if row.Tool == "codex" && (row.AgentSessionID == os.Getenv("CODEX_THREAD_ID") && row.AgentSessionID != "" || row.TmuxPaneID != "" && shared) { + unresolvedCodex = true + } + } + if len(matches) > 1 { + return store.Session{}, errors.New("calling process belongs to multiple Gate Inbox rows") + } + if len(matches) == 1 { + return matches[0], nil + } + if unresolvedCodex { + return store.Session{}, errors.New("cannot verify a Gate Inbox caller pane from the shared Codex daemon; refusing shutdown") + } + return store.Session{}, ErrUnmanagedCaller +} + +func (r *runtime) paneProcess(target store.Session) (string, error) { + state, err := r.driver.PaneState(target.ID, "#{pane_pid}") + if err != nil { + return "", err + } + pid, err := strconv.ParseInt(strings.TrimSpace(state), 10, 32) + if err != nil || pid <= 0 { + return "", errors.New("pane process is unavailable") + } + proc, err := process.NewProcess(int32(pid)) + if err != nil { + return "", err + } + started, err := proc.CreateTime() + if err != nil || started <= 0 { + return "", errors.New("pane process start identity is unavailable") + } + return fmt.Sprintf("%d:%d", pid, started), nil +} + +func (r *runtime) paneAncestor(target store.Session) (bool, error) { + state, err := r.driver.PaneState(target.ID, "#{pane_pid}") + if err == nil && strings.TrimSpace(state) == "" { + return false, nil + } + if err != nil { + return false, err + } + number, err := strconv.Atoi(strings.TrimSpace(state)) + if err != nil || number <= 0 { + return false, errors.New("invalid pane process identity") + } + ancestors, err := callerAncestors() + if err != nil { + return false, err + } + return slices.Contains(ancestors, number), nil +} + +func sharedCodexAncestor() (bool, error) { + ancestors, err := callerAncestors() + if err != nil { + return false, err + } + for _, pid := range ancestors { + proc, err := process.NewProcess(int32(pid)) + if err != nil { + return false, err + } + argv, err := proc.CmdlineSlice() + if err != nil { + return false, err + } + if len(argv) < 2 || filepath.Base(argv[0]) != "codex" { + continue + } + if argv[1] == "app-server" || argv[1] == "exec-server" { + return true, nil + } + } + return false, nil +} + +func callerAncestors() ([]int, error) { + pid := int32(os.Getpid()) + var ancestors []int + for len(ancestors) < 32 && pid > 1 { + ancestors = append(ancestors, int(pid)) + proc, err := process.NewProcess(pid) + if err != nil { + return nil, err + } + pid, err = proc.Ppid() + if err != nil { + return nil, err + } + } + if pid > 1 { + return nil, errors.New("caller ancestry exceeds verification bound") + } + return ancestors, nil +} diff --git a/internal/sessioncmd/stop_test.go b/internal/sessioncmd/stop_test.go index 84018d35..6d8c59a2 100644 --- a/internal/sessioncmd/stop_test.go +++ b/internal/sessioncmd/stop_test.go @@ -22,16 +22,8 @@ func TestStopDryRunRequiresItsOwnLivePane(t *testing.T) { fields := strings.Fields(string(out)) t.Setenv("TMUX", fields[0]) t.Setenv("TMUX_PANE", fields[1]) - result, err := h.sessions.Stop(h.caller.ID, true) - if err != nil { - t.Fatal(err) - } - if !result.DryRun || !result.Target.Self || !h.driver.Exists(h.caller.ID) { - t.Fatalf("dry run: %+v", result) - } - ends, err := h.store.SessionEnds() - if err != nil || len(ends) != 0 { - t.Fatalf("dry run wrote ends: %v, %v", ends, err) + if _, err := h.sessions.Stop(h.caller.ID, true); err == nil { + t.Fatal("accepted pane environment without caller process ancestry") } t.Setenv("TMUX_PANE", "%9999999") if _, err := h.sessions.Stop(h.caller.ID, true); err == nil { @@ -50,16 +42,31 @@ func TestFinishStopPreservesHistoryAndPinsTheLaunch(t *testing.T) { if err != nil { t.Fatal(err) } + runtime, err := h.sessions.open() + if err != nil { + t.Fatal(err) + } + pin, err := runtime.paneProcess(row) + runtime.store.Close() + if err != nil { + t.Fatal(err) + } launch := row.LaunchTime().Format(time.RFC3339Nano) for _, bad := range [][2]string{{"old-launch", pane}, {launch, "%9999999"}, {launch, ""}} { - if _, err := h.sessions.FinishStop(row.ID, bad[0], bad[1]); err == nil { + if _, err := h.sessions.FinishStop(row.ID, bad[0], bad[1], pin); err == nil { t.Fatalf("accepted stale request: %v", bad) } if !h.driver.Exists(row.ID) { t.Fatal("stale request stopped session") } } - stopped, err := h.sessions.FinishStop(row.ID, launch, pane) + if _, err := h.sessions.FinishStop(row.ID, launch, pane, ""); err == nil { + t.Fatal("accepted empty pane process pin") + } + if _, err := h.sessions.FinishStop(row.ID, launch, pane, "1:old-start"); err == nil { + t.Fatal("accepted foreign pane process pin") + } + stopped, err := h.sessions.FinishStop(row.ID, launch, pane, pin) if err != nil { t.Fatal(err) } diff --git a/internal/tmux/killscope_test.go b/internal/tmux/killscope_test.go index b4cd7729..5ad3ec72 100644 --- a/internal/tmux/killscope_test.go +++ b/internal/tmux/killscope_test.go @@ -128,14 +128,14 @@ func TestKillPaneTakesOnlyTheOwnWindow(t *testing.T) { } stray := strayWindow(t, driver, id) - if err := driver.KillPane(id, stray); err == nil { + if err := driver.KillPane(id, stray, paneField(t, driver.socket, stray, "#{pane_pid}")); err == nil { t.Fatal("KillPane with a stray window's pane should refuse") } if !paneAlive(t, driver, own) || !paneAlive(t, driver, stray) { t.Fatal("a refused KillPane must end nothing") } - if err := driver.KillPane(id, own); err != nil { + if err := driver.KillPane(id, own, paneField(t, driver.socket, own, "#{pane_pid}")); err != nil { t.Fatalf("KillPane: %v", err) } if paneAlive(t, driver, own) { @@ -154,7 +154,7 @@ func TestKillPaneEndsASessionWithOnlyItsOwnWindow(t *testing.T) { if err != nil { t.Fatalf("PaneID: %v", err) } - if err := driver.KillPane(id, own); err != nil { + if err := driver.KillPane(id, own, paneField(t, driver.socket, own, "#{pane_pid}")); err != nil { t.Fatalf("KillPane: %v", err) } waitGone(t, driver, id) @@ -205,3 +205,46 @@ func TestKillFindsTheOwnWindowAfterARespawnAndWithoutTheRecord(t *testing.T) { }) } } + +func TestKillPanePreservesARespawnedProcess(t *testing.T) { + for _, withStray := range []bool{false, true} { + name := "only-window" + if withStray { + name = "with-stray-window" + } + t.Run(name, func(t *testing.T) { + driver := requireTmux(t) + id := createManaged(t, driver, "respawnpin", "cat") + own, err := driver.PaneID(id) + if err != nil { + t.Fatal(err) + } + stray := "" + if withStray { + stray = strayWindow(t, driver, id) + } + oldPID := paneField(t, driver.socket, own, "#{pane_pid}") + if out, err := tmuxOn(driver.socket, "respawn-pane", "-k", "-t", own, "cat").CombinedOutput(); err != nil { + t.Fatalf("respawn: %v: %s", err, out) + } + newPID := paneField(t, driver.socket, own, "#{pane_pid}") + if oldPID == newPID { + t.Fatal("fixture did not replace the pane process") + } + for _, pin := range []string{oldPID, "", "invalid"} { + if err := driver.KillPane(id, own, pin); err == nil { + t.Fatalf("accepted stale or invalid process pin %q", pin) + } + if !paneAlive(t, driver, own) || withStray && !paneAlive(t, driver, stray) { + t.Fatal("refusal ended a replacement or non-target pane") + } + } + if err := driver.KillPane(id, own, newPID); err != nil { + t.Fatal(err) + } + if paneAlive(t, driver, own) || withStray && !paneAlive(t, driver, stray) { + t.Fatal("verified shutdown did not preserve its scope") + } + }) + } +} diff --git a/internal/tmux/target_test.go b/internal/tmux/target_test.go index 0d37885b..d5a890ab 100644 --- a/internal/tmux/target_test.go +++ b/internal/tmux/target_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "runtime" + "slices" "strconv" "strings" "sync" @@ -824,3 +825,28 @@ func TestScanPanesCountsAServerThatIsNotRunningAsGone(t *testing.T) { t.Fatalf("a pane on a server that is not running is gone, got Gone = %v", scan.Gone) } } + +func TestKillAdoptedPaneRefusesChangedProcessIdentity(t *testing.T) { + driver := requireTmux(t) + socket, panes := foreignServer(t) + id := uniqueID("pinnedadopted") + adopt(t, driver, id, socket, panes[1]) + pid, err := driver.PaneState(id, "#{pane_pid}") + if err != nil { + t.Fatal(err) + } + for _, bad := range [][2]string{{panes[0], strings.TrimSpace(pid)}, {panes[1], "1"}, {panes[1], ""}} { + if err := driver.KillAdoptedPane(id, bad[0], bad[1]); err == nil { + t.Fatalf("accepted foreign identity %v", bad) + } + if got := livePanes(t, socket); !slices.Contains(got, panes[0]) || !slices.Contains(got, panes[1]) { + t.Fatalf("refusal ended a pane: %v", got) + } + } + if err := driver.KillAdoptedPane(id, panes[1], strings.TrimSpace(pid)); err != nil { + t.Fatal(err) + } + if got := livePanes(t, socket); !slices.Contains(got, panes[0]) || slices.Contains(got, panes[1]) { + t.Fatalf("non-target sentinel: %v", got) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 44676252..c44ef2de 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1513,6 +1513,25 @@ func (d *Driver) KillAdopted(id string) error { return err } +func (d *Driver) KillAdoptedPane(id, pane, processID string) error { + target, adopted := d.AdoptedTarget(id) + if !adopted || target.Name != pane { + return fmt.Errorf("%s: adopted pane identity changed", id) + } + pid, err := strconv.Atoi(processID) + if err != nil || pid <= 0 { + return fmt.Errorf("%s: invalid pane process identity", id) + } + _, err = d.runAt(id, "if-shell", "-F", "-t", pane, "#{==:#{pane_pid},"+processID+"}", "kill-pane -t "+pane) + if err != nil { + return err + } + if d.Exists(id) { + return fmt.Errorf("%s: adopted pane process changed before shutdown", id) + } + return nil +} + func (d *Driver) Exists(id string) bool { target, adopted := d.AdoptedTarget(id) if !adopted { @@ -1645,9 +1664,14 @@ func (d *Driver) PaneID(id string) (string, error) { // KillPane is Kill for a caller that validated one pane: the check and the // kill run as one tmux command, so a session revived under id after pane -// was read survives. The pane has to be in the session's own window as well, +// was read survives, as does a process respawned in the same pane. +// The pane has to be in the session's own window as well, // so a pane read off a window somebody else opened in it ends nothing. -func (d *Driver) KillPane(id, pane string) error { +func (d *Driver) KillPane(id, pane, processID string) error { + pid, err := strconv.Atoi(processID) + if err != nil || pid <= 0 { + return fmt.Errorf("%s: invalid pane process identity", id) + } if err := d.refuseAdopted(id, "kill"); err != nil { return err } @@ -1664,11 +1688,11 @@ func (d *Driver) KillPane(id, pane string) error { return fmt.Errorf("%s: pane %s is not in this session's own window", id, pane) } if err := d.endOwnWindow(id, home, pane, - "#{&&:#{==:#{session_name},"+sessionName(id)+"},#{==:#{window_id},"+home+"}}"); err != nil { + "#{&&:#{==:#{pane_pid},"+strconv.Itoa(pid)+"},#{&&:#{==:#{session_name},"+sessionName(id)+"},#{==:#{window_id},"+home+"}}}"); err != nil { return err } if d.Exists(id) { - return fmt.Errorf("%s: pane %s no longer belongs to this session's own window", id, pane) + return fmt.Errorf("%s: pane %s process or window changed before shutdown", id, pane) } os.Remove(d.launchScriptPath(id)) return nil