From 7481b2d01ceadd9324b8389161eaf0400a13d379 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:05:48 -0600 Subject: [PATCH] Terminal: show the shell's error output, so a missing shell is explained The shell's stderr was discarded, so a shell that could not start looked like an immediate disconnect. It now goes to the terminal like other output, followed by the exit code. An end-to-end test covers a machine with no shell. Builds against Bamboo v0.1.10, which says why when there is no shell to run. Co-Authored-By: Claude Sonnet 5.5 --- e2e/terminal_relay_test.go | 27 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- terminal.go | 13 +++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/e2e/terminal_relay_test.go b/e2e/terminal_relay_test.go index 2a11cf4..300be7d 100644 --- a/e2e/terminal_relay_test.go +++ b/e2e/terminal_relay_test.go @@ -137,6 +137,33 @@ func TestWebTerminalWithoutAnyOpenPort(t *testing.T) { t.Errorf("the test opened a port after all: %+v", maps.Mappings) } + // a machine with no shell (the published container image has none) says why, instead of the + // terminal just disconnecting: the shell's error output reaches the browser, then the exit code + if sshd.Supported { + t.Setenv("SHELL", "/nonexistent/shell") + ws := dial() + defer ws.Close() + var out bytes.Buffer + var exit termMsg + for exit.Type != "exit" { + ws.SetReadDeadline(time.Now().Add(15 * time.Second)) + var f []byte + if err := websocket.Message.Receive(ws, &f); err != nil { + t.Fatalf("waiting for the shell to end (got %q): %v", out.String(), err) + } + if len(f) > 0 && f[0] == '{' { + if m := parseTermMsg(f); m.Type == "exit" { + exit = m + } + continue + } + out.Write(f) + } + if exit.Code != 127 || !strings.Contains(out.String(), "no shell to run") { + t.Errorf("a missing shell: exit %d, output %q", exit.Code, out.String()) + } + } + // the SSH server still decides who gets in other := newSSHSigner(t) if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil { diff --git a/go.mod b/go.mod index afc3bd9..9562ee4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.veillax.com/Veillax/hammerhead go 1.27.3 require ( - git.veillax.com/Veillax/bamboo v0.1.9 + git.veillax.com/Veillax/bamboo v0.1.10 git.veillax.com/Veillax/blacktip v0.1.10 git.veillax.com/Veillax/reef v0.1.12 github.com/emersion/go-msgauth v0.7.0 diff --git a/go.sum b/go.sum index e680409..7e690c3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.veillax.com/Veillax/bamboo v0.1.9 h1:D+DWIFRxy2MZ9Oa7fRrdeM7xNUBZPwjfO5CRMgc9+q4= -git.veillax.com/Veillax/bamboo v0.1.9/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k= +git.veillax.com/Veillax/bamboo v0.1.10 h1:R21+L54cS87VXmOS4eAsfRwp6vL+ORLqJUBaRHBIlJE= +git.veillax.com/Veillax/bamboo v0.1.10/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k= git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE= git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o= git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s= diff --git a/terminal.go b/terminal.go index ccbe989..2f804bf 100644 --- a/terminal.go +++ b/terminal.go @@ -360,6 +360,9 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols, problem(err.Error()) return } + // What the shell writes to its error output (including why it could not start) goes to the + // terminal like everything else, instead of being thrown away. + sess.Stderr = termWriter(func(p []byte) error { return send(termFrame{data: append([]byte(nil), p...)}) }) if err := sess.Shell(); err != nil { problem(fmt.Sprintf("Couldn't start a shell: %v", err)) return @@ -566,3 +569,13 @@ func clipText(s string, n int) string { } return s } + +// termWriter sends what is written to it to the browser as terminal output. +type termWriter func(p []byte) error + +func (w termWriter) Write(p []byte) (int, error) { + if err := w(p); err != nil { + return 0, err + } + return len(p), nil +}