Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ the same sessions back on their conversations. A single one: `gate-inbox kill <i
from `gate-inbox sessions`. From inside a managed agent session, `gate-inbox stop` ends that
session and preserves its row and conversation for an explicit revive. It records an intentional
stop, so startup recovery does not offer it as a lost session. `gate-inbox stop --dry-run`
verifies the current pane without ending it. Adopted panes must still be stopped by id from
verifies the current pane without ending it. Adopted callers with verified pane process ancestry can also stop themselves.
Shared-daemon callers whose pane cannot be verified refuse shutdown. Other adopted panes can be stopped by id from
another session or the board.

**Carry on in the plain CLI.** In a focused session, `alt+y` copies the agent's own conversation
Expand Down
83 changes: 79 additions & 4 deletions app/stop_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app

import (
"database/sql"
"encoding/json"
"fmt"
"os"
Expand All @@ -16,16 +17,20 @@ import (
)

func TestStopFromInsidePaneCompletesLifecycleOutsideIt(t *testing.T) {
testSelfStop(t, true)
testSelfStop(t, true, false)
}

// The last session's death empties the server, and tmux SIGTERMs a
// run-shell -b job on the way out, so the worker must outlive that.
func TestStopOfTheServersLastSessionCompletesLifecycle(t *testing.T) {
testSelfStop(t, false)
testSelfStop(t, false, false)
}

func testSelfStop(t *testing.T, withSentinel bool) {
func TestStopDiscoversAdoptedCallerWithoutLaunchEnvironment(t *testing.T) {
testSelfStop(t, true, true)
}

func testSelfStop(t *testing.T, withSentinel, adopted bool) {
bin := buildFixture(t)
socket := tmuxtest.Socket(t, "selfstop")
env := fixtureHome(t, "tmux_socket = \""+socket+"\"\n"+promptTool)
Expand Down Expand Up @@ -78,9 +83,23 @@ func testSelfStop(t *testing.T, withSentinel bool) {
dry := filepath.Join(home, "dry.json")
pane := tmuxCommand("display-message", "-p", "-t", "gi_"+target, "#{pane_id}")
command := tmux.ShellQuote(bin) + " stop --dry-run --json > " + 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) {
Expand Down Expand Up @@ -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)
}
}
22 changes: 22 additions & 0 deletions app/testdata/stopcaller/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions compat/testdata/cli/usage.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions internal/cli/stop.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"errors"
"io"
"os"
"os/signal"
Expand All @@ -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
}
Expand All @@ -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 <launch-time> <pane-id>")
operands, err := parseCommand(os.Stdout, set, args, 2, 2)
set := cmdline.NewFlagSet("_finish-stop <launch-time> <pane-id> <process-pin>")
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
}
17 changes: 17 additions & 0 deletions internal/cli/stop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"github.com/usestring/gate-inbox/internal/sessioncmd"
"testing"
)

Expand All @@ -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)
}
}
Loading