From f44f0c9ae80c198c4453008d78ca174fecd9b28c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 11:30:52 +0000 Subject: [PATCH] =?UTF-8?q?join@:=20fix=20onboarding=20freeze=20=E2=80=94?= =?UTF-8?q?=20read=20raw=20PTY=20input=20instead=20of=20ReadString('\n')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/agentbbs/main.go | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index ee86427..1362f57 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -28,6 +28,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "net" "net/http" "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()} } +// 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 // visitor's key, confirm their email with a code we email them, then offer the // $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++ { wish.Print(s, "\n Username ["+def+"]: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { return store.User{}, err } @@ -426,7 +462,7 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U var email string for tries := 0; tries < 3; tries++ { wish.Print(s, "\n Email: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { 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++ { wish.Print(s, " Enter the code: ") - line, err := in.ReadString('\n') + line, err := readLine(s, in) if err != nil { return false }