join@: fix onboarding freeze — read raw PTY input instead of ReadString('\n')

ssh join@host requests a PTY by default, so the client's terminal goes into
raw mode: it sends keystrokes as they're typed (Enter is '\r', not '\n') and
does no local echo. The onboarding prompts (username, email, code) read with
bufio.ReadString('\n'), which never sees a '\n' and blocks forever — the user
sees a dead prompt at "Email:". The earlier "require a PTY" change made this
guaranteed rather than intermittent.

Add a readLine helper that reads byte-by-byte, treats '\r' or '\n' as the
line terminator, handles backspace, and echoes printable bytes back so the
user can see what they type. Route the three onboarding prompts through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 11:30:52 +00:00
parent ac4b0873d9
commit f44f0c9ae8

View file

@ -28,6 +28,7 @@ import (
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -313,6 +314,41 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()} return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
} }
// readLine reads one line of interactive input from an SSH session that is
// running under a client-allocated PTY. That detail is the whole reason this
// helper exists: when the client requests a PTY (which `ssh join@host` does by
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
// terminator, handle backspace, and echo printable bytes back ourselves.
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
var b []byte
for {
c, err := in.ReadByte()
if err != nil {
return "", err
}
switch c {
case '\r', '\n':
wish.Print(s, "\r\n")
return string(b), nil
case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
return "", io.EOF
case 0x7f, '\b': // DEL / backspace: erase last char on screen too
if len(b) > 0 {
b = b[:len(b)-1]
wish.Print(s, "\b \b")
}
default:
if c >= 0x20 { // printable byte; ignore other control codes
b = append(b, c)
wish.Print(s, string(c))
}
}
}
}
// handleJoin runs onboarding interactively in one SSH session: register the // handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the // visitor's key, confirm their email with a code we email them, then offer the
// $10 lifetime Premium membership (CoinPay). It then disconnects. // $10 lifetime Premium membership (CoinPay). It then disconnects.
@ -391,7 +427,7 @@ func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (sto
for tries := 0; tries < 5; tries++ { for tries := 0; tries < 5; tries++ {
wish.Print(s, "\n Username ["+def+"]: ") wish.Print(s, "\n Username ["+def+"]: ")
line, err := in.ReadString('\n') line, err := readLine(s, in)
if err != nil { if err != nil {
return store.User{}, err return store.User{}, err
} }
@ -426,7 +462,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
var email string var email string
for tries := 0; tries < 3; tries++ { for tries := 0; tries < 3; tries++ {
wish.Print(s, "\n Email: ") wish.Print(s, "\n Email: ")
line, err := in.ReadString('\n') line, err := readLine(s, in)
if err != nil { if err != nil {
return false return false
} }
@ -465,7 +501,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
for tries := 0; tries < 3; tries++ { for tries := 0; tries < 3; tries++ {
wish.Print(s, " Enter the code: ") wish.Print(s, " Enter the code: ")
line, err := in.ReadString('\n') line, err := readLine(s, in)
if err != nil { if err != nil {
return false return false
} }