Skip to content

Commit d73ec81

Browse files
authored
feat(tui): interactive terminal attach for tty exec sessions (#1055)
* feat(execution): add a stdin-only write for interactive processes * feat(tui): attach overlay for interactive terminal sessions * feat(tui): add /attach for interactive terminal sessions * feat(tools): point the model at tty sessions for interactive prompts * feat(tui): open the terminal automatically for tty exec sessions * feat(tools): tell the model to keep polling while the user types * feat(tui): give attached terminal sessions the full viewport * feat(tools): route sudo through an escalated tty session * fix(tui): route the auto-attach tick to its run * fix(tui): defer auto-attach while another view owns the screen * fix(tui): keep bracketed-paste framing for attached sessions * fix(tools): let tty sessions run interactive commands * fix(tui): capture the auto-attach baseline before the exec starts
1 parent 2479d94 commit d73ec81

19 files changed

Lines changed: 1781 additions & 16 deletions

‎internal/execution/process_manager.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,49 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
224224
return result, nil
225225
}
226226

227+
// WriteInput writes bytes to a retained interactive process's stdin without
228+
// collecting output. Unlike Continue it never drains the pending output
229+
// buffer, so a concurrent write_stdin poll still sees everything the process
230+
// emitted; callers that only need the rolling tail use Snapshot instead.
231+
func (manager *ProcessManager) WriteInput(id int, data []byte) error {
232+
process, ok := manager.get(id)
233+
if !ok {
234+
return ErrProcessNotFound
235+
}
236+
process.touch()
237+
if len(data) == 0 {
238+
return nil
239+
}
240+
if !process.tty || process.stdin == nil {
241+
return ErrProcessStdinDisabled
242+
}
243+
if _, err := process.stdin.Write(data); err != nil && !process.doneClosed() {
244+
return err
245+
}
246+
return nil
247+
}
248+
249+
// ResizeInput updates the PTY window size of a retained interactive process
250+
// so an attached terminal can fill its viewport. Non-positive dimensions are
251+
// a no-op; platforms without PTY support report the transport's error.
252+
func (manager *ProcessManager) ResizeInput(id int, cols, rows int) error {
253+
if cols <= 0 || rows <= 0 {
254+
return nil
255+
}
256+
process, ok := manager.get(id)
257+
if !ok {
258+
return ErrProcessNotFound
259+
}
260+
process.touch()
261+
if !process.tty || process.stdin == nil {
262+
return ErrProcessStdinDisabled
263+
}
264+
if err := resizePTY(process.stdin, cols, rows); err != nil && !process.doneClosed() {
265+
return err
266+
}
267+
return nil
268+
}
269+
227270
func clampInitialProcessWait(wait time.Duration) time.Duration {
228271
return min(wait, maxInteractiveYield)
229272
}

‎internal/execution/process_manager_test.go‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package execution
22

33
import (
44
"context"
5+
"errors"
56
"io"
67
"os"
78
"os/exec"
@@ -85,6 +86,126 @@ func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
8586
}
8687
}
8788

89+
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
90+
manager := NewProcessManager(ProcessManagerOptions{})
91+
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
92+
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
93+
}
94+
}
95+
96+
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
97+
if runtime.GOOS == "windows" {
98+
t.Skip("test command uses a POSIX shell")
99+
}
100+
root := t.TempDir()
101+
manager := NewProcessManager(ProcessManagerOptions{})
102+
command := exec.Command("/bin/sh", "-c", "sleep 30")
103+
started, err := manager.Start(context.Background(), ProcessStart{
104+
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
105+
}, time.Millisecond)
106+
if err != nil {
107+
t.Fatalf("Start: %v", err)
108+
}
109+
defer manager.Stop(started.ProcessID)
110+
if started.TTY {
111+
t.Fatal("pipe process reported a TTY")
112+
}
113+
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
114+
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
115+
}
116+
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
117+
t.Fatalf("WriteInput empty data = %v, want nil", err)
118+
}
119+
}
120+
121+
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
122+
if runtime.GOOS != "linux" {
123+
t.Skip("PTY sessions are only available on Linux")
124+
}
125+
root := t.TempDir()
126+
manager := NewProcessManager(ProcessManagerOptions{})
127+
command := exec.CommandContext(context.Background(), "cat")
128+
started, err := manager.Start(context.Background(), ProcessStart{
129+
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
130+
CommandText: "cat", TTY: true,
131+
}, 50*time.Millisecond)
132+
if err != nil {
133+
t.Skipf("PTY transport unavailable: %v", err)
134+
}
135+
if !started.TTY {
136+
t.Skip("PTY transport fell back to pipes")
137+
}
138+
defer manager.Stop(started.ProcessID)
139+
140+
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
141+
t.Fatalf("WriteInput: %v", err)
142+
}
143+
// The PTY echoes input back into the output stream, so the rolling recent
144+
// tail shows what was typed without anything being drained.
145+
deadline := time.Now().Add(5 * time.Second)
146+
for {
147+
snapshot, ok := manager.Snapshot(started.ProcessID)
148+
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
149+
break
150+
}
151+
if time.Now().After(deadline) {
152+
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
153+
}
154+
time.Sleep(10 * time.Millisecond)
155+
}
156+
// WriteInput must not have consumed the pending buffer: a write_stdin-style
157+
// Continue still collects the echoed bytes.
158+
continued, err := manager.Continue(context.Background(), ProcessContinue{
159+
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
160+
})
161+
if err != nil {
162+
t.Fatalf("Continue: %v", err)
163+
}
164+
if !strings.Contains(continued.Output, "hello") {
165+
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
166+
}
167+
}
168+
169+
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
170+
manager := NewProcessManager(ProcessManagerOptions{})
171+
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
172+
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
173+
}
174+
}
175+
176+
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
177+
if runtime.GOOS != "linux" {
178+
t.Skip("PTY sessions are only available on Linux")
179+
}
180+
root := t.TempDir()
181+
manager := NewProcessManager(ProcessManagerOptions{})
182+
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
183+
started, err := manager.Start(context.Background(), ProcessStart{
184+
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
185+
CommandText: "stty size", TTY: true,
186+
}, time.Millisecond)
187+
if err != nil {
188+
t.Skipf("PTY transport unavailable: %v", err)
189+
}
190+
if !started.TTY {
191+
t.Skip("PTY transport fell back to pipes")
192+
}
193+
defer manager.Stop(started.ProcessID)
194+
195+
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
196+
t.Fatalf("ResizeInput: %v", err)
197+
}
198+
continued, err := manager.Continue(context.Background(), ProcessContinue{
199+
ProcessID: started.ProcessID, Wait: 2 * time.Second,
200+
})
201+
if err != nil {
202+
t.Fatalf("Continue: %v", err)
203+
}
204+
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
205+
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
206+
}
207+
}
208+
88209
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
89210
reaped := make(chan struct{})
90211
close(reaped)

‎internal/execution/pty_fallback.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ import (
1111
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
1212
return nil, nil, errors.New("pty transport is unavailable on this platform")
1313
}
14+
15+
func resizePTY(_ io.Writer, _, _ int) error {
16+
return errors.New("pty transport is unavailable on this platform")
17+
}

‎internal/execution/pty_linux.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ func openPTY() (*os.File, *os.File, error) {
4848
_ = master.Close()
4949
return nil, nil, err
5050
}
51+
// A fresh PTY defaults to a 0x0 window; seed a sane size so the process
52+
// never sees zero dimensions before an attached client reports its own.
53+
_ = unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{Row: 24, Col: 80})
5154
pts, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
5255
if err != nil {
5356
_ = master.Close()
@@ -80,3 +83,16 @@ func hardenPTYProcess(command *exec.Cmd) {
8083
return nil
8184
}
8285
}
86+
87+
// resizePTY reports a new window size to the PTY behind an interactive
88+
// process's stdin writer, so full-screen attached terminals drive the
89+
// session's own line wrapping.
90+
func resizePTY(w io.Writer, cols, rows int) error {
91+
file, ok := w.(interface{ Fd() uintptr })
92+
if !ok {
93+
return errors.New("pty transport is unavailable on this platform")
94+
}
95+
return unix.IoctlSetWinsize(int(file.Fd()), unix.TIOCSWINSZ, &unix.Winsize{
96+
Row: uint16(rows), Col: uint16(cols),
97+
})
98+
}

‎internal/tools/exec_command.go‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ type ExecSessionSnapshot = execution.ProcessSnapshot
4747

4848
type ExecSessionController interface {
4949
ExecSessions() []ExecSessionSnapshot
50+
ExecSession(id int) (ExecSessionSnapshot, bool)
5051
StopExecSession(id int) bool
5152
StopAllExecSessions() []int
53+
WriteExecSessionInput(id int, data []byte) error
54+
ResizeExecSession(id int, cols, rows int) error
5255
}
5356

5457
type execCommandTool struct {
@@ -95,7 +98,7 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
9598
},
9699
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
97100
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
98-
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
101+
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes. Use true for commands that may prompt for input (sudo, ssh, interactive installers): the user can then type into the session directly. Setuid tools such as sudo also need sandbox_permissions \"require_escalated\", since the sandbox blocks privilege escalation.", Default: false},
99102
},
100103
Required: []string{"cmd"},
101104
AdditionalProperties: false,
@@ -131,6 +134,10 @@ func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
131134
return tool.manager.List()
132135
}
133136

137+
func (tool execCommandTool) ExecSession(id int) (ExecSessionSnapshot, bool) {
138+
return tool.manager.Snapshot(id)
139+
}
140+
134141
func (tool execCommandTool) StopExecSession(id int) bool {
135142
return tool.manager.Stop(id)
136143
}
@@ -139,6 +146,14 @@ func (tool execCommandTool) StopAllExecSessions() []int {
139146
return tool.manager.StopAll()
140147
}
141148

149+
func (tool execCommandTool) WriteExecSessionInput(id int, data []byte) error {
150+
return tool.manager.WriteInput(id, data)
151+
}
152+
153+
func (tool execCommandTool) ResizeExecSession(id int, cols, rows int) error {
154+
return tool.manager.ResizeInput(id, cols, rows)
155+
}
156+
142157
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
143158
commandText, err := execCommandArg(args)
144159
if err != nil {
@@ -172,8 +187,14 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
172187
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
173188
return shellIssueBlockResult(*issue)
174189
}
175-
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
176-
return interactiveBlockResult(interactive)
190+
// tty:true exists exactly to run prompting commands, so the
191+
// non-interactive guard steps aside and the PTY path gets the session.
192+
if !ttyRequested {
193+
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
194+
result := interactiveBlockResult(interactive)
195+
result.Output += "\nRerun with tty:true if the user should interact with it."
196+
return result
197+
}
177198
}
178199
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
179200
if err != nil {
@@ -453,7 +474,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
453474
if input.exited && input.exitCode != 0 && !input.interrupted {
454475
status = StatusError
455476
}
456-
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
477+
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted, input.tty)
457478
if status == StatusError && input.exited && !input.interrupted {
458479
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
459480
meta["shell_issue"] = issue.Kind
@@ -575,7 +596,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
575596
return summaries
576597
}
577598

578-
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
599+
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
579600
output = strings.TrimRight(output, "\r\n")
580601
parts := []string{}
581602
if output != "" {
@@ -593,12 +614,18 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
593614
parts = append(parts, "interrupted: true")
594615
}
595616
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
617+
if exitCode != 0 && strings.Contains(output, "no new privileges") {
618+
parts = append(parts, `Hint: this command needs sandbox_permissions "require_escalated" (the sandbox blocks setuid); retry with it and tty:true if it prompts.`)
619+
}
596620
} else {
597621
if output == "" {
598622
parts = append(parts, "Command is still running.")
599623
}
600624
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
601625
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
626+
if tty {
627+
parts = append(parts, fmt.Sprintf("This session has a terminal and the user can already type into it (it opened in their TUI; /attach %d reopens it). If it is waiting on a password or confirmation, say so in one line, then keep polling with write_stdin (empty chars, yield_time_ms 60000) until it exits. Do not end your turn while it is running, and never ask for the password in chat.", sessionID))
628+
}
602629
}
603630
return strings.Join(parts, "\n")
604631
}

‎internal/tools/exec_command_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,3 +894,56 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
894894
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
895895
}
896896
}
897+
898+
func TestFormatExecCommandOutputTTYAttachHint(t *testing.T) {
899+
running := formatExecCommandOutput("", 1007, false, 0, false, true)
900+
if !strings.Contains(running, "/attach 1007 reopens it") || !strings.Contains(running, "Do not end your turn while it is running") {
901+
t.Fatalf("tty running session should point at /attach and keep-poll guidance: %q", running)
902+
}
903+
exited := formatExecCommandOutput("", 1007, true, 0, false, true)
904+
if strings.Contains(exited, "/attach") {
905+
t.Fatalf("exited session should not mention /attach: %q", exited)
906+
}
907+
pipes := formatExecCommandOutput("", 1007, false, 0, false, false)
908+
if strings.Contains(pipes, "/attach") {
909+
t.Fatalf("non-tty session should not mention /attach: %q", pipes)
910+
}
911+
}
912+
913+
func TestExecCommandInteractiveBlockSuggestsTTY(t *testing.T) {
914+
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
915+
result := tool.Run(context.Background(), map[string]any{"cmd": "ssh host.example.com"})
916+
if result.Status != StatusError || result.Meta["safety_block"] != "interactive_command" {
917+
t.Fatalf("expected interactive safety block, got meta=%#v output=%q", result.Meta, result.Output)
918+
}
919+
if !strings.Contains(result.Output, "tty:true") {
920+
t.Fatalf("block output should suggest tty:true: %q", result.Output)
921+
}
922+
}
923+
924+
func TestExecCommandTTYSkipsInteractiveBlock(t *testing.T) {
925+
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
926+
// ssh to a bogus host fails fast on its own; the point is that the
927+
// interactive guard must not fire ahead of the tty path.
928+
result := tool.Run(context.Background(), map[string]any{
929+
"cmd": "ssh host.example.com", "tty": true, "yield_time_ms": 250,
930+
})
931+
if result.Meta["safety_block"] == "interactive_command" {
932+
t.Fatalf("tty:true should not hit the interactive block: %#v", result.Meta)
933+
}
934+
}
935+
936+
func TestFormatExecCommandOutputNoNewPrivilegesHint(t *testing.T) {
937+
blocked := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 1, false, true)
938+
if !strings.Contains(blocked, `sandbox_permissions "require_escalated"`) {
939+
t.Fatalf("no_new_privs failure should hint at require_escalated: %q", blocked)
940+
}
941+
normal := formatExecCommandOutput("some other error", 1007, true, 1, false, true)
942+
if strings.Contains(normal, "require_escalated") {
943+
t.Fatalf("unrelated failure should not hint at require_escalated: %q", normal)
944+
}
945+
blockedOK := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 0, false, true)
946+
if strings.Contains(blockedOK, "require_escalated") {
947+
t.Fatalf("zero-exit output should not hint at require_escalated: %q", blockedOK)
948+
}
949+
}

‎internal/tui/attachment_preview.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (m model) attachmentThumbnailVisible(width int) bool {
2424
}
2525
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
2626
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
27-
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
27+
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
2828
return false
2929
}
3030
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {

0 commit comments

Comments
 (0)