Skip to content

feat(cli): forward stdin and Ctrl-C in ax ssh - #413

Open
anzal1 wants to merge 1 commit into
google:mainfrom
anzal1:feat/ssh-interactive
Open

anzal1 wants to merge 1 commit into
google:mainfrom
anzal1:feat/ssh-interactive

Conversation

@anzal1

@anzal1 anzal1 commented Sep 25, 2026

Copy link
Copy Markdown

Part of #374

ax ssh <task> with no command starts /bin/sh in the sandbox, but guest.Client.Exec never opened the process's stdin. The shell read EOF right away and exited, so the command returned without printing anything.

The guest protocol in agent-substrate/env already covers this: StartProcessRequest.stdin opens a stdin pipe, WriteProcessInput streams into it, and SignalProcess sends a signal to the process group. This PR wires the client up to those calls.

Changes

  • guest.ExecOptions has two new fields:
    • Stdin io.Reader is streamed to the process through WriteProcessInput. Remote stdin is closed when the reader hits EOF. A nil Stdin keeps the old behaviour, where the process gets an empty stdin.
    • Signals <-chan os.Signal sends each SIGHUP, SIGINT, SIGQUIT or SIGTERM it receives to the process group through SignalProcess.
  • ax ssh passes os.Stdin and forwards SIGINT. Ctrl-C now interrupts the remote command instead of killing ax and leaving the remote process running.
  • When stdin is a terminal, the default shell runs as /bin/sh -i. With -i it prints a prompt, and when Ctrl-C reaches the process group it stops the foreground command but not the shell.

So ax ssh task123 gives you a working shell: you get a prompt, commands run, Ctrl-C interrupts, Ctrl-D or exit leaves, and the exit status is passed back. Piping also works now, for example cat script.py | ax ssh task123 -- python3 -.

What's still missing

The guest protocol has no PTY. Input is line-buffered by the local terminal, and the remote side has no tty. That means full-screen programs like vim, top and less, job control, and window resizing don't work yet. Supporting them needs a change in agent-substrate/env: a PTY option on StartProcessRequest and a way to send resizes. I can open an issue there if that sounds right. Once it exists, ax needs to put the local terminal into raw mode and send SIGWINCH sizes, which is a small follow-up.

Testing

internal/guest/client_test.go is new. It runs the real guest server from agent-substrate/env on a local port and covers:

  • stdin bytes reach the process and its output comes back
  • line-by-line interactive input over a pipe, where each line is sent only after the reply to the previous one arrives, and closing stdin gives the process EOF
  • the exit code is passed through
  • with no Stdin, the process still gets an empty stdin
  • a forwarded SIGINT ends the process with 130

Four of these fail on main. The interactive test on main prints just bye, which is the bug in this issue: the loop sees EOF straight away.

I also checked /bin/sh -i by hand inside Linux containers: dash (the default python:3.12-slim task image), bash on Ubuntu 22.04, and busybox ash on Alpine. In each one, Ctrl-C during sleep 30 stopped the sleep within a second, and the shell kept accepting commands.

go test -race ./..., go vet ./... and make build pass, and go mod tidy leaves go.mod and go.sum unchanged.

Notes

  • ax ssh now forwards stdin for one-off commands as well, which matches what ssh does. So a script that runs ax ssh inside a while read loop will have the loop's input read by the remote command. If you'd rather only forward stdin for the interactive shell, or add an -n flag like ssh has, I'm happy to change it.
  • Bash has no tty here, so it echoes some readline redraw sequences. That goes away once there's a PTY.
  • The guest kills processes after its one hour default timeout, and interactive sessions are no exception.

ax ssh <task> with no command started /bin/sh remotely, but Exec never
opened the process's stdin, so the shell read EOF and exited straight
away.

guest.ExecOptions now takes Stdin, which is streamed to the process via
WriteProcessInput and closed on EOF, and Signals, which are delivered
with SignalProcess. ax ssh passes os.Stdin and forwards SIGINT, so Ctrl-C
interrupts the remote command. When stdin is a terminal the default
shell runs as /bin/sh -i so it shows a prompt and survives Ctrl-C.

The guest protocol has no PTY support yet, so full-screen programs and
job control still don't work.

Part of google#374
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant