feat(shell): run bash on a real PTY via creack/pty

The shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran
without a tty. Programs that check isatty() (python, htop, less,
vim-style REPLs) would drop echo, ignore line-editing, and leak literal
0x1b bytes into the program — Python's REPL reported 'invalid
non-printable character U+001B' on input.

Switch to github.com/creack/pty:StartWithSize with an 80x24 default
and TERM=xterm-256color in cmd.Env. The PTY file replaces both the
stdin and stdout pipes; sess.Stdin is the pty *os.File and the
output capture goroutine reads from it directly. waitExit no longer
needs to close sess.Stdin — the capture goroutine's defer handles it
when the process dies and the pty returns EOF.

- go.mod / go.sum: add creack/pty v1.1.24.
- internal/shell/manager.go: rewrite Start to use pty.StartWithSize;
  captureOutput reads from the pty; waitExit simplified; race-clean
  exited map to avoid the cmd.ProcessState data race.
- internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal
  (echo with echo + output interleaved proves PTY echo is on) and
  TestShellPTYTermEnv (TERM=xterm-256color propagates).

E2E against the real bash: 'python3 -q' returns the >>> prompt,
print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak.

Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
This commit is contained in:
tao.chen
2026-07-06 14:05:47 +08:00
parent 789f62669c
commit 5ed618494d
4 changed files with 152 additions and 53 deletions
+86
View File
@@ -0,0 +1,86 @@
package shell
import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"
)
func startTestShell(t *testing.T) (*LocalManager, io.WriteCloser, Subscription) {
t.Helper()
mgr := NewManager("bash", []string{"-i"})
root := t.TempDir()
if err := mgr.Start("test-ws", root); err != nil {
t.Fatalf("Start failed: %v", err)
}
stdin, err := mgr.Stdin("test-ws")
if err != nil {
t.Fatalf("Stdin failed: %v", err)
}
sub, err := mgr.Subscribe("test-ws")
if err != nil {
t.Fatalf("Subscribe failed: %v", err)
}
t.Cleanup(func() {
sub.Close()
_ = mgr.Stop("test-ws")
})
return mgr, stdin, sub
}
func waitForMarker(t *testing.T, sub Subscription, stdin io.Writer, input, marker string) string {
t.Helper()
if _, err := stdin.Write([]byte(input)); err != nil {
t.Fatalf("failed to write to stdin: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var out bytes.Buffer
for {
select {
case chunk, ok := <-sub.Output():
if !ok {
t.Fatalf("subscription closed before seeing %q; output so far:\n%s", marker, out.String())
}
out.Write(chunk)
if strings.Contains(out.String(), marker) {
return out.String()
}
case <-ctx.Done():
t.Fatalf("timed out waiting for %q; output so far:\n%s", marker, out.String())
}
}
}
func TestShellPTYIsRealTerminal(t *testing.T) {
mgr, stdin, sub := startTestShell(t)
_ = mgr
out := waitForMarker(t, sub, stdin, "echo HELLO_PTY_TEST\n", "HELLO_PTY_TEST")
if !strings.Contains(out, "echo HELLO_PTY_TEST") {
t.Errorf("expected command echo in PTY output, got:\n%s", out)
}
if !strings.Contains(out, "HELLO_PTY_TEST") {
t.Errorf("expected command output in PTY output, got:\n%s", out)
}
}
func TestShellPTYTermEnv(t *testing.T) {
_, stdin, sub := startTestShell(t)
out := waitForMarker(t, sub, stdin, "echo \"$TERM\"\n", "xterm-256color")
if !strings.Contains(out, "xterm-256color") {
t.Errorf("expected TERM=xterm-256color in output, got:\n%s", out)
}
}