From d3cdd8add567e8cc402192694b0e34fe3091fbfe Mon Sep 17 00:00:00 2001 From: Hugo Tavares Date: Tue, 28 Jul 2026 13:21:27 +0000 Subject: [PATCH 1/2] Support interactive SSH on Windows Amp-Thread-ID: https://ampcode.com/threads/T-019fa8dc-05dc-7350-8158-705849107b0d Co-authored-by: Amp --- .github/workflows/checks.yml | 13 ++++++ pkg/sshd/pty_windows.go | 8 ++-- pkg/sshd/pty_windows_test.go | 89 ++++++++++++++++++++++++++++++++++++ pkg/sshd/sshd.go | 1 + 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 pkg/sshd/pty_windows_test.go diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cdd556f..10cf315 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -42,3 +42,16 @@ jobs: duration: 30m authorized-users: hugosantos,n-g,htr slack-announce-channel: "#ci" + + windows-ssh: + name: Windows SSH + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: 'stable' + - name: Test Windows SSH + run: go test ./pkg/sshd diff --git a/pkg/sshd/pty_windows.go b/pkg/sshd/pty_windows.go index cedcea8..35d0224 100644 --- a/pkg/sshd/pty_windows.go +++ b/pkg/sshd/pty_windows.go @@ -3,13 +3,15 @@ package sshd import ( - "errors" "io" "os/exec" "github.com/gliderlabs/ssh" ) -func handlePty(session io.ReadWriter, ptyReq ssh.Pty, winCh <-chan ssh.Window, cmd *exec.Cmd) error { - return errors.New("pty not supported in windows") +func handlePty(session io.ReadWriter, _ ssh.Pty, _ <-chan ssh.Window, cmd *exec.Cmd) error { + cmd.Stdin = session + cmd.Stdout = session + cmd.Stderr = session + return cmd.Start() } diff --git a/pkg/sshd/pty_windows_test.go b/pkg/sshd/pty_windows_test.go new file mode 100644 index 0000000..577c79a --- /dev/null +++ b/pkg/sshd/pty_windows_test.go @@ -0,0 +1,89 @@ +//go:build windows + +package sshd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "net" + "os" + "strings" + "testing" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +func TestWindowsPTYSession(t *testing.T) { + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + clientSigner, err := gossh.NewSignerFromKey(clientKey) + if err != nil { + t.Fatal(err) + } + + server, err := MakeServer(context.Background(), SSHServerOpts{ + AllowedUsers: []string{"runner"}, + AuthorizedKeys: map[string]string{ + string(gossh.MarshalAuthorizedKey(clientSigner.PublicKey())): "test", + }, + Env: os.Environ(), + Shell: []string{os.Getenv("COMSPEC")}, + }) + if err != nil { + t.Fatal(err) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + defer server.Server.Close() + + go func() { + _ = server.Server.Serve(listener) + }() + + client, err := gossh.Dial("tcp", listener.Addr().String(), &gossh.ClientConfig{ + User: "runner", + Auth: []gossh.AuthMethod{gossh.PublicKeys(clientSigner)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + t.Fatal(err) + } + defer session.Close() + + if err := session.RequestPty("xterm", 24, 80, gossh.TerminalModes{}); err != nil { + t.Fatal(err) + } + + const marker = "breakpoint-windows-pty-ok" + var output bytes.Buffer + session.Stdin = strings.NewReader("echo " + marker + "\r\nexit\r\n") + session.Stdout = &output + session.Stderr = &output + + if err := session.Shell(); err != nil { + t.Fatal(err) + } + if err := session.Wait(); err != nil { + t.Fatalf("Windows shell failed: %v\noutput:\n%s", err, output.String()) + } + if !strings.Contains(output.String(), marker) { + t.Fatalf("Windows shell output did not contain %q:\n%s", marker, output.String()) + } +} diff --git a/pkg/sshd/sshd.go b/pkg/sshd/sshd.go index 0ed9f9c..62f851f 100644 --- a/pkg/sshd/sshd.go +++ b/pkg/sshd/sshd.go @@ -106,6 +106,7 @@ func MakeServer(ctx context.Context, opts SSHServerOpts) (*SSHServer, error) { return } } else { + cmd.Stdin = session cmd.Stdout = session cmd.Stderr = session if err := cmd.Start(); err != nil { From 60a163971cda0466428102f3a58d0e666f4fdad9 Mon Sep 17 00:00:00 2001 From: Hugo Tavares Date: Tue, 28 Jul 2026 13:50:56 +0000 Subject: [PATCH 2/2] Avoid concurrent writes in Windows SSH test Amp-Thread-ID: https://ampcode.com/threads/T-019fa8dc-05dc-7350-8158-705849107b0d Co-authored-by: Amp --- pkg/sshd/pty_windows_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/sshd/pty_windows_test.go b/pkg/sshd/pty_windows_test.go index 577c79a..e027dd6 100644 --- a/pkg/sshd/pty_windows_test.go +++ b/pkg/sshd/pty_windows_test.go @@ -72,18 +72,18 @@ func TestWindowsPTYSession(t *testing.T) { } const marker = "breakpoint-windows-pty-ok" - var output bytes.Buffer + var stdout, stderr bytes.Buffer session.Stdin = strings.NewReader("echo " + marker + "\r\nexit\r\n") - session.Stdout = &output - session.Stderr = &output + session.Stdout = &stdout + session.Stderr = &stderr if err := session.Shell(); err != nil { t.Fatal(err) } if err := session.Wait(); err != nil { - t.Fatalf("Windows shell failed: %v\noutput:\n%s", err, output.String()) + t.Fatalf("Windows shell failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) } - if !strings.Contains(output.String(), marker) { - t.Fatalf("Windows shell output did not contain %q:\n%s", marker, output.String()) + if !strings.Contains(stdout.String(), marker) { + t.Fatalf("Windows shell stdout did not contain %q:\n%s\nstderr:\n%s", marker, stdout.String(), stderr.String()) } }