gomote: add ssh <cmd> and scp

Fixes golang/go#21140.

Change-Id: I88cf60b2b4615b1f96a3624964c89a60046be4d5
Reviewed-on: https://go-review.googlesource.com/c/build/+/808700
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: David Chase <drchase@google.com>
diff --git a/cmd/buildlet/ssh.go b/cmd/buildlet/ssh.go
index f757210..67bb31f 100644
--- a/cmd/buildlet/ssh.go
+++ b/cmd/buildlet/ssh.go
@@ -7,6 +7,7 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"log"
@@ -19,8 +20,9 @@
 
 func startSSHServerSwarming() {
 	buildletSSHServer = &ssh.Server{
-		Addr:    "localhost:" + sshPort(),
-		Handler: sshHandler,
+		Addr:              "localhost:" + sshPort(),
+		Handler:           sshHandler,
+		SubsystemHandlers: sshSubsystems,
 		PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
 			allowed, _, _, _, err := ssh.ParseAuthorizedKey(buldletAuthKeys)
 			if err != nil {
@@ -42,10 +44,20 @@
 	})
 }
 
+// shellCommand returns a command that runs rawCmd using the shell,
+// or the shell itself reading commands from standard input
+// if rawCmd is empty.
+func shellCommand(ctx context.Context, rawCmd string) *exec.Cmd {
+	if rawCmd == "" {
+		return exec.CommandContext(ctx, shell())
+	}
+	return exec.CommandContext(ctx, shell(), "-c", rawCmd)
+}
+
 func sshHandler(s ssh.Session) {
 	ptyReq, winCh, isPty := s.Pty()
 	if !isPty {
-		fmt.Fprint(s, "scp is not supported\n")
+		sshHandlerDirect(s)
 		return
 	}
 	var cmd *exec.Cmd
diff --git a/cmd/buildlet/ssh_direct.go b/cmd/buildlet/ssh_direct.go
new file mode 100644
index 0000000..3dbc085
--- /dev/null
+++ b/cmd/buildlet/ssh_direct.go
@@ -0,0 +1,90 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build !plan9
+
+package main
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os/exec"
+
+	"github.com/gliderlabs/ssh"
+	"github.com/pkg/sftp"
+)
+
+// sshSubsystems are the subsystem handlers for the buildlet's SSH server.
+var sshSubsystems = map[string]ssh.SubsystemHandler{
+	"sftp": sshHandlerSFTP,
+}
+
+// sshHandlerSFTP serves the sftp subsystem, which scp and sftp use to
+// copy files to and from the buildlet (go.dev/issue/21140). Relative
+// paths name files in the buildlet's work directory.
+func sshHandlerSFTP(s ssh.Session) {
+	srv, err := sftp.NewServer(s, sftp.WithServerWorkingDirectory(*workDir))
+	if err != nil {
+		log.Printf("starting sftp server: %s", err)
+		fmt.Fprintf(s.Stderr(), "starting sftp server: %s\n", err)
+		s.Exit(255)
+		return
+	}
+	defer srv.Close()
+	if err := srv.Serve(); err != nil && !errors.Is(err, io.EOF) {
+		log.Printf("sftp server: %s", err)
+		fmt.Fprintf(s.Stderr(), "sftp server: %s\n", err)
+		s.Exit(255)
+		return
+	}
+	s.Exit(0)
+}
+
+// sshHandlerDirect handles a session that did not request a pty: an
+// exec request ("gomote ssh instance cmd..."), a shell reading
+// commands from piped standard input, or the legacy scp protocol
+// (go.dev/issue/21140). It connects the command to the session's own
+// streams instead of a pty, so data passes through byte for byte, and
+// it propagates the command's exit status.
+func sshHandlerDirect(s ssh.Session) {
+	fail := func(format string, args ...any) {
+		fmt.Fprintf(s.Stderr(), format, args...)
+		s.Exit(255)
+	}
+	cmd := shellCommand(s.Context(), s.RawCommand())
+	cmd.Dir = *workDir
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		fail("%v\n", err)
+		return
+	}
+	cmd.Stdout = s
+	cmd.Stderr = s.Stderr()
+	if err := cmd.Start(); err != nil {
+		log.Printf("unable to start shell: %s", err)
+		fail("unable to start shell %q: %s\n", shell(), err)
+		return
+	}
+	// Copy session input on the side: the copy blocks until the client
+	// sends EOF or disconnects, which must not keep Wait from returning
+	// once the command exits.
+	go func() {
+		io.Copy(stdin, s)
+		stdin.Close()
+	}()
+	err = cmd.Wait()
+	code := 0
+	var exitErr *exec.ExitError
+	if errors.As(err, &exitErr) {
+		if code = exitErr.ExitCode(); code < 0 {
+			code = 255
+		}
+	} else if err != nil {
+		fail("running shell %q: %s\n", shell(), err)
+		return
+	}
+	s.Exit(code)
+}
diff --git a/cmd/buildlet/ssh_direct_test.go b/cmd/buildlet/ssh_direct_test.go
new file mode 100644
index 0000000..f33ad75
--- /dev/null
+++ b/cmd/buildlet/ssh_direct_test.go
@@ -0,0 +1,195 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build !plan9 && !windows
+
+package main
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	gssh "github.com/gliderlabs/ssh"
+	"github.com/pkg/sftp"
+	"golang.org/x/crypto/ssh"
+)
+
+// testSSHClient starts an SSH server running sshHandler, like the one
+// the buildlet runs on swarming bots, and returns a client connected
+// to it.
+func testSSHClient(t *testing.T) *ssh.Client {
+	t.Helper()
+	// Use a known shell: the tests below use POSIX shell syntax,
+	// and shell() consults $SHELL on some systems.
+	t.Setenv("SHELL", "/bin/sh")
+
+	oldWorkDir := *workDir
+	*workDir = t.TempDir()
+	t.Cleanup(func() { *workDir = oldWorkDir })
+
+	ln, err := net.Listen("tcp", "localhost:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := &gssh.Server{Handler: sshHandler, SubsystemHandlers: sshSubsystems}
+	go srv.Serve(ln)
+	t.Cleanup(func() { srv.Close() })
+
+	c, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
+		User:            "test",
+		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { c.Close() })
+	return c
+}
+
+// TestSSHHandlerExec tests an exec request without a pty,
+// as in "gomote ssh instance cmd...".
+func TestSSHHandlerExec(t *testing.T) {
+	c := testSSHClient(t)
+	sess, err := c.NewSession()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sess.Close()
+	var stdout, stderr bytes.Buffer
+	sess.Stdout = &stdout
+	sess.Stderr = &stderr
+	err = sess.Run("echo out; echo err >&2; exit 7")
+	var exitErr *ssh.ExitError
+	if !errors.As(err, &exitErr) {
+		t.Fatalf("Run = %v, want exit status 7", err)
+	}
+	if code := exitErr.ExitStatus(); code != 7 {
+		t.Errorf("exit status = %d, want 7", code)
+	}
+	if got := stdout.String(); got != "out\n" {
+		t.Errorf("stdout = %q, want %q", got, "out\n")
+	}
+	if got := stderr.String(); got != "err\n" {
+		t.Errorf("stderr = %q, want %q", got, "err\n")
+	}
+}
+
+// TestSSHHandlerShell tests a shell request without a pty,
+// which reads commands from standard input.
+func TestSSHHandlerShell(t *testing.T) {
+	c := testSSHClient(t)
+	sess, err := c.NewSession()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sess.Close()
+	var stdout bytes.Buffer
+	sess.Stdin = strings.NewReader("echo hello\nexit 3\n")
+	sess.Stdout = &stdout
+	if err := sess.Shell(); err != nil {
+		t.Fatal(err)
+	}
+	err = sess.Wait()
+	var exitErr *ssh.ExitError
+	if !errors.As(err, &exitErr) {
+		t.Fatalf("Wait = %v, want exit status 3", err)
+	}
+	if code := exitErr.ExitStatus(); code != 3 {
+		t.Errorf("exit status = %d, want 3", code)
+	}
+	if got := stdout.String(); got != "hello\n" {
+		t.Errorf("stdout = %q, want %q", got, "hello\n")
+	}
+}
+
+// TestSSHHandlerSFTP tests the sftp subsystem that scp uses,
+// including that relative paths name files in the work directory.
+func TestSSHHandlerSFTP(t *testing.T) {
+	c := testSSHClient(t)
+	client, err := sftp.NewClient(c)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer client.Close()
+
+	f, err := client.Create("hello.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := f.Write([]byte("hello\n")); err != nil {
+		t.Fatal(err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatal(err)
+	}
+	data, err := os.ReadFile(filepath.Join(*workDir, "hello.txt"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "hello\n" {
+		t.Errorf("hello.txt = %q, want %q", data, "hello\n")
+	}
+
+	f, err = client.Open("hello.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	data, err = io.ReadAll(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "hello\n" {
+		t.Errorf("read back hello.txt = %q, want %q", data, "hello\n")
+	}
+}
+
+// TestSSHHandlerStdinOpen tests that a command that ignores its
+// standard input finishes even though the client is still holding
+// standard input open, as an interactive ssh client does.
+func TestSSHHandlerStdinOpen(t *testing.T) {
+	c := testSSHClient(t)
+	sess, err := c.NewSession()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sess.Close()
+	pr, pw := io.Pipe()
+	defer pw.Close()
+	sess.Stdin = pr
+	stdout, err := sess.StdoutPipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := sess.Start("echo done"); err != nil {
+		t.Fatal(err)
+	}
+	// If the handler waits for standard input to close, the session
+	// channel never closes and the read below never returns; closing
+	// the connection makes the test fail instead of time out.
+	var stuck atomic.Bool
+	timer := time.AfterFunc(30*time.Second, func() {
+		stuck.Store(true)
+		c.Close()
+	})
+	defer timer.Stop()
+	out, err := io.ReadAll(stdout)
+	if stuck.Load() {
+		t.Fatal("session did not finish while standard input was open")
+	}
+	if err != nil {
+		t.Fatalf("reading stdout: %v", err)
+	}
+	if got := string(out); got != "done\n" {
+		t.Errorf("stdout = %q, want %q", got, "done\n")
+	}
+}
diff --git a/cmd/buildlet/ssh_windows.go b/cmd/buildlet/ssh_windows.go
index 55abf42..ccaced4 100644
--- a/cmd/buildlet/ssh_windows.go
+++ b/cmd/buildlet/ssh_windows.go
@@ -5,9 +5,12 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"log"
+	"os/exec"
+	"syscall"
 
 	"github.com/UserExistsError/conpty"
 	"github.com/gliderlabs/ssh"
@@ -15,8 +18,9 @@
 
 func startSSHServerSwarming() {
 	buildletSSHServer = &ssh.Server{
-		Addr:    "localhost:" + sshPort(),
-		Handler: sshHandler,
+		Addr:              "localhost:" + sshPort(),
+		Handler:           sshHandler,
+		SubsystemHandlers: sshSubsystems,
 		PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
 			allowed, _, _, _, err := ssh.ParseAuthorizedKey(buldletAuthKeys)
 			if err != nil {
@@ -38,10 +42,26 @@
 	})
 }
 
+// shellCommand returns a command that runs rawCmd using the shell,
+// or the shell itself reading commands from standard input
+// if rawCmd is empty.
+func shellCommand(ctx context.Context, rawCmd string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, shell())
+	if rawCmd != "" {
+		// cmd.exe does not parse its command line using the standard
+		// Windows quoting rules that os/exec applies to Args, so set
+		// the command line directly and pass rawCmd through verbatim.
+		cmd.SysProcAttr = &syscall.SysProcAttr{
+			CmdLine: `"` + shell() + `" /c ` + rawCmd,
+		}
+	}
+	return cmd
+}
+
 func sshHandler(s ssh.Session) {
 	ptyReq, winCh, isPty := s.Pty()
 	if !isPty {
-		fmt.Fprint(s, "scp is not supported\n")
+		sshHandlerDirect(s)
 		return
 	}
 	f, err := conpty.Start(shell(), conpty.ConPtyDimensions(ptyReq.Window.Width, ptyReq.Window.Height), conpty.ConPtyWorkDir(*workDir))
diff --git a/cmd/gomote/gomote.go b/cmd/gomote/gomote.go
index e3d8469..ec6c567 100644
--- a/cmd/gomote/gomote.go
+++ b/cmd/gomote/gomote.go
@@ -37,6 +37,7 @@
 	  rdp        RDP (Remote Desktop Protocol) to a Windows buildlet
 	  repro      reproduce a build by LUCI build ID
 	  run        run a command on a buildlet
+	  scp        copy files to or from a buildlet
 	  ssh        ssh to a buildlet
 
 To list all the builder types available, run "create" with no arguments:
@@ -209,6 +210,7 @@
 	registerCommand("rdp", "Unimplimented: RDP (Remote Desktop Protocol) to a Windows buildlet", rdp)
 	registerCommand("rm", "delete files or directories", rm)
 	registerCommand("run", "run a command on a buildlet", run)
+	registerCommand("scp", "copy files to or from a buildlet", scp)
 	registerCommand("ssh", "ssh to a buildlet", ssh)
 }
 
diff --git a/cmd/gomote/ssh.go b/cmd/gomote/ssh.go
index 51a1681..d3dea53 100644
--- a/cmd/gomote/ssh.go
+++ b/cmd/gomote/ssh.go
@@ -13,23 +13,31 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"slices"
 	"strings"
 
 	"golang.org/x/build/internal/gomote/protos"
 )
 
+// sshServer is the ssh proxy through which ssh and scp
+// reach buildlets.
+const sshServer = "gomotessh.golang.org"
+
 func ssh(args []string) error {
 	fs := flag.NewFlagSet("ssh", flag.ContinueOnError)
 	fs.Usage = func() {
-		usageLogger.Print("ssh usage: gomote ssh <instance>")
+		usageLogger.Print("ssh usage: gomote ssh [-n] <instance> [cmd...]")
 		fs.PrintDefaults()
 		os.Exit(1)
 	}
+	printOnly := fs.Bool("n", false, "print the ssh command line but do not run it")
 	fs.Parse(args)
 
 	var name string
-	if fs.NArg() == 1 {
+	var remoteCmd []string
+	if fs.NArg() >= 1 {
 		name = fs.Arg(0)
+		remoteCmd = fs.Args()[1:]
 	} else if activeGroup != nil {
 		if len(activeGroup.Instances) != 1 {
 			return fmt.Errorf("command only supports groups with exactly one member")
@@ -39,17 +47,98 @@
 		fs.Usage()
 	}
 
-	sshKeyDir, err := sshConfigDirectory()
+	priKey, certPath, err := sshCertificate(name)
 	if err != nil {
 		return err
 	}
+	return sshConnect(name, priKey, certPath, remoteCmd, *printOnly)
+}
+
+// scp copies files to or from buildlets.
+// Arguments of the form instance:path name files on that instance;
+// all other arguments, including scp flags, pass through to scp.
+func scp(args []string) error {
+	instances, rewritten := scpArgs(args)
+	if len(instances) == 0 {
+		usageLogger.Print("scp usage: gomote scp [scp-args] [<instance>:]file... [<instance>:]file")
+		os.Exit(1)
+	}
+	scpPath, err := exec.LookPath("scp")
+	if err != nil {
+		return fmt.Errorf("path to scp not found: %w", err)
+	}
+	cli := []string{"-P", "2222"}
+	var priKey string
+	for _, inst := range instances {
+		pk, certPath, err := sshCertificate(inst)
+		if err != nil {
+			return err
+		}
+		priKey = pk
+		cli = append(cli, "-o", "CertificateFile="+certPath)
+	}
+	cli = append(cli, "-i", priKey)
+	cli = append(cli, rewritten...)
+	fmt.Printf("$ %s\n", shellJoin(append([]string{scpPath}, cli...)))
+	cmd := exec.Command(scpPath, cli...)
+	cmd.Stdout = os.Stdout
+	cmd.Stdin = os.Stdin
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("unable to scp: %w", err)
+	}
+	return nil
+}
+
+// scpArgs rewrites the arguments for an scp command, replacing
+// arguments of the form instance:path with instance@sshServer:path
+// and returning the instances mentioned along with the rewritten
+// argument list.
+func scpArgs(args []string) (instances, rewritten []string) {
+	for _, arg := range args {
+		inst, ok := scpInstance(arg)
+		if !ok {
+			rewritten = append(rewritten, arg)
+			continue
+		}
+		if !slices.Contains(instances, inst) {
+			instances = append(instances, inst)
+		}
+		rewritten = append(rewritten, inst+"@"+sshServer+strings.TrimPrefix(arg, inst))
+	}
+	return instances, rewritten
+}
+
+// scpInstance reports the instance name if arg has the remote form
+// instance:path, using scp's own rule: an argument is remote if it
+// contains a colon before any slash. Flags and arguments that
+// already name a user with @ are left alone.
+func scpInstance(arg string) (string, bool) {
+	if strings.HasPrefix(arg, "-") {
+		return "", false
+	}
+	i := strings.IndexAny(arg, ":/@")
+	if i <= 0 || arg[i] != ':' {
+		return "", false
+	}
+	return arg[:i], true
+}
+
+// sshCertificate signs the local SSH public key for the named
+// instance, returning the paths of the local private key and the
+// signed certificate.
+func sshCertificate(name string) (priKey, certPath string, err error) {
+	sshKeyDir, err := sshConfigDirectory()
+	if err != nil {
+		return "", "", err
+	}
 	pubKey, priKey, err := localKeyPair(sshKeyDir)
 	if err != nil {
-		return err
+		return "", "", err
 	}
 	pubKeyBytes, err := os.ReadFile(pubKey)
 	if err != nil {
-		return err
+		return "", "", err
 	}
 	ctx := context.Background()
 	client := gomoteServerClient(ctx)
@@ -58,13 +147,13 @@
 		PublicSshKey: []byte(pubKeyBytes),
 	})
 	if err != nil {
-		return fmt.Errorf("unable to retrieve SSH certificate: %w", err)
+		return "", "", fmt.Errorf("unable to retrieve SSH certificate: %w", err)
 	}
-	certPath, err := writeCertificateToDisk(resp.GetSignedPublicSshKey())
+	certPath, err = writeCertificateToDisk(resp.GetSignedPublicSshKey())
 	if err != nil {
-		return err
+		return "", "", err
 	}
-	return sshConnect(name, priKey, certPath)
+	return priKey, certPath, nil
 }
 
 func sshConfigDirectory() (string, error) {
@@ -118,14 +207,18 @@
 	return tf.Name(), tf.Close()
 }
 
-func sshConnect(name string, priKey, certPath string) error {
+func sshConnect(name string, priKey, certPath string, remoteCmd []string, printOnly bool) error {
 	ssh, err := exec.LookPath("ssh")
 	if err != nil {
 		return fmt.Errorf("path to ssh not found: %w", err)
 	}
-	sshServer := "gomotessh.golang.org"
 	cli := []string{"-o", fmt.Sprintf("CertificateFile=%s", certPath), "-i", priKey, "-p", "2222", name + "@" + sshServer}
-	fmt.Printf("$ %s %s\n", ssh, strings.Join(cli, " "))
+	cli = append(cli, remoteCmd...)
+	if printOnly {
+		fmt.Println(shellJoin(append([]string{ssh}, cli...)))
+		return nil
+	}
+	fmt.Printf("$ %s\n", shellJoin(append([]string{ssh}, cli...)))
 	cmd := exec.Command(ssh, cli...)
 	cmd.Stdout = os.Stdout
 	cmd.Stdin = os.Stdin
@@ -136,6 +229,23 @@
 	return nil
 }
 
+// shellQuote quotes s as needed for use in a shell command line.
+func shellQuote(s string) string {
+	if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$&|;<>()*?[]#~`!{}") {
+		return s
+	}
+	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
+// shellJoin joins args into a shell command line, quoting as needed.
+func shellJoin(args []string) string {
+	quoted := make([]string, len(args))
+	for i, arg := range args {
+		quoted[i] = shellQuote(arg)
+	}
+	return strings.Join(quoted, " ")
+}
+
 func fileExists(path string) bool {
 	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
 		return false
diff --git a/cmd/gomote/ssh_test.go b/cmd/gomote/ssh_test.go
new file mode 100644
index 0000000..c77b2ca
--- /dev/null
+++ b/cmd/gomote/ssh_test.go
@@ -0,0 +1,63 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestScpArgs(t *testing.T) {
+	tests := []struct {
+		args          []string
+		wantInstances []string
+		wantRewritten []string
+	}{
+		{
+			[]string{"-r", "user-linux-amd64-0:foo", "."},
+			[]string{"user-linux-amd64-0"},
+			[]string{"-r", "user-linux-amd64-0@" + sshServer + ":foo", "."},
+		},
+		{
+			[]string{"local", "user-linux-amd64-0:", "user-linux-amd64-0:x/y"},
+			[]string{"user-linux-amd64-0"},
+			[]string{"local", "user-linux-amd64-0@" + sshServer + ":", "user-linux-amd64-0@" + sshServer + ":x/y"},
+		},
+		{
+			[]string{"a-1:x", "b-2:y"},
+			[]string{"a-1", "b-2"},
+			[]string{"a-1@" + sshServer + ":x", "b-2@" + sshServer + ":y"},
+		},
+		{
+			// Local paths, flags, and already-qualified names pass through.
+			[]string{"-P", "2222", "./a:b", "/c:d", "u@h:x", ":x", "plain"},
+			nil,
+			[]string{"-P", "2222", "./a:b", "/c:d", "u@h:x", ":x", "plain"},
+		},
+	}
+	for _, tt := range tests {
+		instances, rewritten := scpArgs(tt.args)
+		if !reflect.DeepEqual(instances, tt.wantInstances) || !reflect.DeepEqual(rewritten, tt.wantRewritten) {
+			t.Errorf("scpArgs(%q) = %q, %q, want %q, %q", tt.args, instances, rewritten, tt.wantInstances, tt.wantRewritten)
+		}
+	}
+}
+
+func TestShellJoin(t *testing.T) {
+	tests := []struct {
+		args []string
+		want string
+	}{
+		{[]string{"ssh", "-p", "2222", "inst@host"}, "ssh -p 2222 inst@host"},
+		{[]string{"-i", "/a/Application Support/key"}, "-i '/a/Application Support/key'"},
+		{[]string{"echo", "don't"}, `echo 'don'\''t'`},
+		{[]string{""}, "''"},
+	}
+	for _, tt := range tests {
+		if got := shellJoin(tt.args); got != tt.want {
+			t.Errorf("shellJoin(%q) = %s, want %s", tt.args, got, tt.want)
+		}
+	}
+}
diff --git a/go.mod b/go.mod
index 0a84c5a..7778ad1 100644
--- a/go.mod
+++ b/go.mod
@@ -45,6 +45,7 @@
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
 	github.com/mailjet/mailjet-apiv3-go/v4 v4.0.7
 	github.com/mattn/go-sqlite3 v1.14.14
+	github.com/pkg/sftp v1.13.11
 	github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e
 	github.com/sendgrid/sendgrid-go v3.11.1+incompatible
 	github.com/shurcooL/githubv4 v0.0.0-20231126234147-1cffa1f02456
@@ -146,6 +147,7 @@
 	github.com/kevinburke/ssh_config v1.6.0 // indirect
 	github.com/klauspost/compress v1.16.7 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/kr/fs v0.1.0 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mostynb/zstdpool-syncpool v0.0.12 // indirect
diff --git a/go.sum b/go.sum
index 9ad1f11..607363a 100644
--- a/go.sum
+++ b/go.sum
@@ -620,6 +620,8 @@
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -713,6 +715,8 @@
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
+github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
 github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
 github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
diff --git a/internal/coordinator/remote/ssh.go b/internal/coordinator/remote/ssh.go
index f23b993..9ef6d62 100644
--- a/internal/coordinator/remote/ssh.go
+++ b/internal/coordinator/remote/ssh.go
@@ -31,6 +31,7 @@
 
 	"github.com/creack/pty"
 	gssh "github.com/gliderlabs/ssh"
+	"golang.org/x/build/buildlet"
 	"golang.org/x/build/dashboard"
 	"golang.org/x/build/internal/envutil"
 	"golang.org/x/crypto/ssh"
@@ -138,6 +139,9 @@
 		},
 	}
 	s.server.Handler = s.HandleIncomingSSHPostAuth
+	s.server.SubsystemHandlers = map[string]gssh.SubsystemHandler{
+		"sftp": func(sess gssh.Session) { s.handleDirect(sess) },
+	}
 	for _, opt := range opts {
 		opt(s)
 	}
@@ -166,7 +170,7 @@
 	inst := s.User()
 	ptyReq, winCh, isPty := s.Pty()
 	if !isPty {
-		fmt.Fprintf(s, "scp etc not yet supported; https://golang.org/issue/21140\n")
+		ss.handleDirect(s)
 		return
 	}
 	rs, err := ss.sessionPool.Session(inst)
@@ -215,47 +219,13 @@
 		return
 	}
 	if useLocalSSHProxy {
-		sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey)
-		log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err)
+		port, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser)
 		if err != nil {
-			fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err)
+			fmt.Fprintf(s, "%v\n", err)
 			return
 		}
-		defer sshConn.Close()
-
-		// Now listen on some localhost port that we'll proxy to sshConn.
-		// The openssh ssh command line tool will connect to this IP.
-		ln, err := net.Listen("tcp", "localhost:0")
-		if err != nil {
-			fmt.Fprintf(s, "local listen error: %v\n", err)
-			return
-		}
-		localProxyPort = ln.Addr().(*net.TCPAddr).Port
-		log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort)
-		var lnCloseOnce sync.Once
-		lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) }
-		defer lnClose()
-
-		// Accept at most one connection from localProxyPort and proxy
-		// it to sshConn.
-		go func() {
-			c, err := ln.Accept()
-			lnClose()
-			if err != nil {
-				return
-			}
-			defer c.Close()
-			errc := make(chan error, 1)
-			go func() {
-				_, err := io.Copy(c, sshConn)
-				errc <- err
-			}()
-			go func() {
-				_, err := io.Copy(sshConn, c)
-				errc <- err
-			}()
-			err = <-errc
-		}()
+		defer cleanup()
+		localProxyPort = port
 	}
 	workDir, err := bc.WorkDir(ctx)
 	if err != nil {
@@ -275,12 +245,7 @@
 	var cmd *exec.Cmd
 	switch bconf.GOOS() {
 	default:
-		cmd = exec.Command("ssh",
-			"-p", strconv.Itoa(localProxyPort),
-			"-o", "UserKnownHostsFile=/dev/null",
-			"-o", "StrictHostKeyChecking=no",
-			"-i", ss.privateHostKeyFile,
-			sshUser+"@localhost")
+		cmd = exec.Command("ssh", buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, "", "")...)
 	case "plan9":
 		fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n")
 		if ipErr != nil {
@@ -317,7 +282,7 @@
 	inst := s.User()
 	ptyReq, winCh, isPty := s.Pty()
 	if !isPty {
-		fmt.Fprintf(s, "scp etc not yet supported; https://go.dev/issue/21140\n")
+		ss.handleDirect(s)
 		return
 	}
 	rs, err := ss.sessionPool.Session(inst)
@@ -331,8 +296,11 @@
 		log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err)
 	}
 
-	sshUser := "swarming"
-	isPlan9 := strings.Contains(rs.HostType, "plan9")
+	sshUser, isPlan9, err := ss.sessionSSHUser(rs)
+	if err != nil {
+		fmt.Fprintf(s, "%v\n", err)
+		return
+	}
 	useLocalSSHProxy := !isPlan9
 	if sshUser == "" && useLocalSSHProxy {
 		fmt.Fprintf(s, "instance %q host type %q does not have SSH configured\n", inst, rs.HostType)
@@ -351,47 +319,13 @@
 		return
 	}
 	if useLocalSSHProxy {
-		sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey)
-		log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err)
+		port, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser)
 		if err != nil {
-			fmt.Fprintf(s, "failed to connect to ssh on %s: %v\n", inst, err)
+			fmt.Fprintf(s, "%v\n", err)
 			return
 		}
-		defer sshConn.Close()
-
-		// Now listen on some localhost port that we'll proxy to sshConn.
-		// The openssh ssh command line tool will connect to this IP.
-		ln, err := net.Listen("tcp", "localhost:0")
-		if err != nil {
-			fmt.Fprintf(s, "local listen error: %v\n", err)
-			return
-		}
-		localProxyPort = ln.Addr().(*net.TCPAddr).Port
-		log.Printf("ssh local proxy port for %s: %v", inst, localProxyPort)
-		var lnCloseOnce sync.Once
-		lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) }
-		defer lnClose()
-
-		// Accept at most one connection from localProxyPort and proxy
-		// it to sshConn.
-		go func() {
-			c, err := ln.Accept()
-			lnClose()
-			if err != nil {
-				return
-			}
-			defer c.Close()
-			errc := make(chan error, 1)
-			go func() {
-				_, err := io.Copy(c, sshConn)
-				errc <- err
-			}()
-			go func() {
-				_, err := io.Copy(sshConn, c)
-				errc <- err
-			}()
-			err = <-errc
-		}()
+		defer cleanup()
+		localProxyPort = port
 	}
 	workDir, err := bc.WorkDir(ctx)
 	if err != nil {
@@ -407,12 +341,7 @@
 	fmt.Fprint(s, "# Happy debugging.\n")
 
 	log.Printf("ssh to %s: starting ssh -p %d for %s@localhost", inst, localProxyPort, sshUser)
-	cmd := exec.Command("ssh",
-		"-p", strconv.Itoa(localProxyPort),
-		"-o", "UserKnownHostsFile=/dev/null",
-		"-o", "StrictHostKeyChecking=no",
-		"-i", ss.privateHostKeyFile,
-		sshUser+"@localhost")
+	cmd := exec.Command("ssh", buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, "", "")...)
 	if isPlan9 {
 		fmt.Fprintf(s, "# Plan9 user/pass: glenda/glenda123\n")
 		if ipErr != nil {
@@ -441,6 +370,172 @@
 	cmd.Wait()
 }
 
+// handleDirect handles a session that did not request a pty: an exec
+// request ("gomote ssh instance cmd..."), a shell session reading
+// commands from piped standard input, or the sftp subsystem that scp
+// and sftp use (go.dev/issue/21140). It connects the ssh client to
+// the session's own streams instead of a pty, so data passes through
+// byte for byte, and it propagates the remote exit status.
+//
+// Unlike the interactive handlers, it prints no banners: standard
+// output belongs to whatever protocol the command is running,
+// and diagnostics go to the session's standard error.
+func (ss *SSHServer) handleDirect(s gssh.Session) {
+	fail := func(format string, args ...any) {
+		fmt.Fprintf(s.Stderr(), format, args...)
+		s.Exit(255)
+	}
+	inst := s.User()
+	rs, err := ss.sessionPool.Session(inst)
+	if err != nil {
+		fail("unknown instance %q\n", inst)
+		return
+	}
+	sshUser, isPlan9, err := ss.sessionSSHUser(rs)
+	if err != nil {
+		fail("%v\n", err)
+		return
+	}
+	if isPlan9 {
+		fail("non-interactive sessions are not supported on plan9\n")
+		return
+	}
+	if sshUser == "" {
+		fail("instance %q host type %q does not have SSH configured\n", inst, rs.HostType)
+		return
+	}
+
+	ctx, cancel := context.WithCancel(s.Context())
+	defer cancel()
+	if err := ss.sessionPool.KeepAlive(ctx, inst); err != nil {
+		log.Printf("ssh: KeepAlive on session=%s failed: %s", inst, err)
+	}
+
+	bc, err := ss.sessionPool.BuildletClient(inst)
+	if err != nil {
+		fail("failed to connect to ssh on %s: %v\n", inst, err)
+		return
+	}
+	localProxyPort, cleanup, err := ss.proxyBuildletSSH(bc, inst, sshUser)
+	if err != nil {
+		fail("%v\n", err)
+		return
+	}
+	defer cleanup()
+
+	log.Printf("ssh to %s: starting direct ssh -p %d for %s@localhost (subsystem %q, command %q)",
+		inst, localProxyPort, sshUser, s.Subsystem(), s.RawCommand())
+	cmd := exec.CommandContext(ctx, "ssh",
+		buildletSSHArgs(localProxyPort, ss.privateHostKeyFile, sshUser, s.Subsystem(), s.RawCommand())...)
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		fail("%v\n", err)
+		return
+	}
+	cmd.Stdout = s
+	cmd.Stderr = s.Stderr()
+	if err := cmd.Start(); err != nil {
+		fail("running ssh client to %s: %v\n", inst, err)
+		return
+	}
+	// Copy session input on the side: the copy blocks until the client
+	// sends EOF or disconnects, which must not keep Wait from returning
+	// once the command exits.
+	go func() {
+		io.Copy(stdin, s)
+		stdin.Close()
+	}()
+	err = cmd.Wait()
+	code := 0
+	var exitErr *exec.ExitError
+	if errors.As(err, &exitErr) {
+		if code = exitErr.ExitCode(); code < 0 {
+			code = 255
+		}
+	} else if err != nil {
+		fail("running ssh client to %s: %v\n", inst, err)
+		return
+	}
+	s.Exit(code)
+}
+
+// sessionSSHUser returns the user for ssh connections to rs's buildlet
+// and whether the buildlet is a plan9 system, which the ssh command
+// cannot reach.
+func (ss *SSHServer) sessionSSHUser(rs *Session) (sshUser string, isPlan9 bool, err error) {
+	return "swarming", strings.Contains(rs.HostType, "plan9"), nil
+}
+
+// proxyBuildletSSH connects to the SSH server on inst's buildlet as
+// sshUser and starts a localhost listener that proxies a single
+// connection to it, for the ssh command line tool to connect to.
+// It returns the listener's port and a cleanup function that closes
+// the listener and the buildlet connection.
+func (ss *SSHServer) proxyBuildletSSH(bc buildlet.Client, inst, sshUser string) (port int, cleanup func(), err error) {
+	sshConn, err := bc.ConnectSSH(sshUser, ss.gomotePublicKey)
+	log.Printf("buildlet(%q).ConnectSSH = %T, %v", inst, sshConn, err)
+	if err != nil {
+		return 0, nil, fmt.Errorf("failed to connect to ssh on %s: %v", inst, err)
+	}
+	ln, err := net.Listen("tcp", "localhost:0")
+	if err != nil {
+		sshConn.Close()
+		return 0, nil, fmt.Errorf("local listen error: %v", err)
+	}
+	port = ln.Addr().(*net.TCPAddr).Port
+	log.Printf("ssh local proxy port for %s: %v", inst, port)
+	var lnCloseOnce sync.Once
+	lnClose := func() { lnCloseOnce.Do(func() { ln.Close() }) }
+
+	// Accept at most one connection and proxy it to sshConn.
+	go func() {
+		c, err := ln.Accept()
+		lnClose()
+		if err != nil {
+			return
+		}
+		defer c.Close()
+		errc := make(chan error, 1)
+		go func() {
+			_, err := io.Copy(c, sshConn)
+			errc <- err
+		}()
+		go func() {
+			_, err := io.Copy(sshConn, c)
+			errc <- err
+		}()
+		<-errc
+	}()
+	return port, func() { lnClose(); sshConn.Close() }, nil
+}
+
+// buildletSSHArgs returns the ssh client arguments for a session to
+// sshUser@localhost:port through the local buildlet proxy: an sftp
+// subsystem request if subsystem is set, an exec of rawCmd if that is
+// set, and otherwise a shell (interactive on the pty paths, reading
+// standard input on the direct path).
+func buildletSSHArgs(port int, keyFile, sshUser, subsystem, rawCmd string) []string {
+	args := []string{
+		"-p", strconv.Itoa(port),
+		"-o", "UserKnownHostsFile=/dev/null",
+		"-o", "StrictHostKeyChecking=no",
+		// Suppress the "Permanently added ... known hosts" warning,
+		// which would otherwise arrive on the stderr of every session.
+		"-o", "LogLevel=ERROR",
+		"-i", keyFile,
+	}
+	if subsystem != "" {
+		return append(args, "-s", sshUser+"@localhost", subsystem)
+	}
+	args = append(args, sshUser+"@localhost")
+	if rawCmd != "" {
+		// One argument: ssh passes it to the remote shell verbatim,
+		// preserving the client's own quoting.
+		args = append(args, rawCmd)
+	}
+	return args
+}
+
 // setupRemoteSSHEnv sets up environment variables on the remote system.
 // This makes the new SSH session easier to use for Go testing.
 func (ss *SSHServer) setupRemoteSSHEnv(bconf *dashboard.BuildConfig, workDir string, f io.Writer) {
diff --git a/internal/coordinator/remote/ssh_test.go b/internal/coordinator/remote/ssh_test.go
index 1d77f0a..19397ab 100644
--- a/internal/coordinator/remote/ssh_test.go
+++ b/internal/coordinator/remote/ssh_test.go
@@ -7,8 +7,21 @@
 package remote
 
 import (
+	"bytes"
 	"context"
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"errors"
 	"fmt"
+	"io"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
 	"testing"
 	"time"
 
@@ -324,3 +337,355 @@
 	// devCertAlternateClientPublic is a public SSH to be used for development.
 	devCertAlternateClientPublic = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM6PwraVsJK/4uiM1ytR/RcfW+qSe4RkGQBx6IEe424R test_discard@golang.org`
 )
+
+func TestBuildletSSHArgs(t *testing.T) {
+	common := []string{"-p", "1234", "-o", "UserKnownHostsFile=/dev/null", "-o", "StrictHostKeyChecking=no", "-o", "LogLevel=ERROR", "-i", "/key"}
+	tests := []struct {
+		subsystem string
+		rawCmd    string
+		want      []string
+	}{
+		{"", "", append(common, "swarming@localhost")},
+		{"", "hostname -f", append(common, "swarming@localhost", "hostname -f")},
+		{"sftp", "", append(common, "-s", "swarming@localhost", "sftp")},
+	}
+	for _, tt := range tests {
+		got := buildletSSHArgs(1234, "/key", "swarming", tt.subsystem, tt.rawCmd)
+		if diff := cmp.Diff(tt.want, got); diff != "" {
+			t.Errorf("buildletSSHArgs(subsystem=%q, cmd=%q) mismatch (-want +got):\n%s", tt.subsystem, tt.rawCmd, diff)
+		}
+	}
+}
+
+// TestSSHHandleDirect exercises the direct (no pty) proxy path end to
+// end: a real ssh or scp client connects to a real SSHServer, which
+// authenticates the client's certificate and proxies the session to a
+// fake buildlet sshd (an in-process x/crypto/ssh server standing in
+// for the sshd on a swarming bot).
+func TestSSHHandleDirect(t *testing.T) {
+	// The proxy runs on Linux, and this test needs an ssh client that
+	// can connect to a local server and run commands through it. The
+	// macOS builders cannot even exec /usr/bin/scp, so limit the test
+	// to the system the proxy is deployed on.
+	if runtime.GOOS != "linux" {
+		t.Skipf("skipping on %s: the ssh proxy runs on linux", runtime.GOOS)
+	}
+	requireCommand(t, "ssh", "-V")
+
+	// The gomote key pair: the proxy's host key, its identity when
+	// connecting to the buildlet sshd, and the key that sshd authorizes.
+	gomotePriv, gomotePub, err := SSHKeyPair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	authorizedGomoteKey, _, _, _, err := ssh.ParseAuthorizedKey(gomotePub)
+	if err != nil {
+		t.Fatal(err)
+	}
+	caPriv, _, err := SSHKeyPair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	caSigner, err := ssh.ParsePrivateKey(caPriv)
+	if err != nil {
+		t.Fatal(err)
+	}
+	buildletHostPriv, _, err := SSHKeyPair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	buildletSigner, err := ssh.ParsePrivateKey(buildletHostPriv)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// A session pool holding one fake buildlet.
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	sp := NewSessionPool(ctx)
+	defer sp.Close()
+	fc := &directFakeClient{
+		t:          t,
+		hostSigner: buildletSigner,
+		authorized: authorizedGomoteKey,
+	}
+	const ownerID = "accounts.google.com:tester"
+	sess := sp.AddSession(ownerID, "user", "gotip-linux-amd64", "host-linux-amd64", "task123", fc)
+
+	// The proxy under test.
+	ss, err := NewSSHServer("localhost:0", gomotePriv, gomotePub, caPriv, sp, EnableLUCIOption())
+	if err != nil {
+		t.Fatal(err)
+	}
+	ln, err := net.Listen("tcp", "localhost:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	go ss.serve(ln)
+	defer ss.Close()
+	port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)
+
+	// A client key, certified by the CA for this session and owner,
+	// as the gomote command does via the SignSSHKey RPC.
+	dir := t.TempDir()
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	block, err := ssh.MarshalPrivateKey(priv, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	keyFile := filepath.Join(dir, "id_ed25519")
+	if err := os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	sshPub, err := ssh.NewPublicKey(pub)
+	if err != nil {
+		t.Fatal(err)
+	}
+	cert, err := SignPublicSSHKey(ctx, caSigner, ssh.MarshalAuthorizedKey(sshPub), sess, ownerID, time.Hour)
+	if err != nil {
+		t.Fatal(err)
+	}
+	certFile := filepath.Join(dir, "id_ed25519-cert.pub")
+	if err := os.WriteFile(certFile, cert, 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	opts := []string{
+		"-o", "CertificateFile=" + certFile,
+		"-i", keyFile,
+		"-o", "IdentitiesOnly=yes",
+		"-o", "UserKnownHostsFile=/dev/null",
+		"-o", "StrictHostKeyChecking=no",
+		"-o", "BatchMode=yes",
+		"-o", "LogLevel=ERROR",
+	}
+	target := sess + "@localhost"
+
+	// The property the mote protocol needs: every byte passes through
+	// in both directions unmodified, including the ones a cooked pty
+	// would eat (\x00, \x03, \x13, \x7f, \r).
+	const binary = "mote server hello \x00\x01\xfe\xff\n\x03\x04\x11\x13\x7f\r\nrest"
+
+	tests := []struct {
+		name   string
+		stdin  string
+		cmd    []string // remote command; none means ssh with no arguments, a shell reading stdin
+		stdout string
+		stderr string
+		code   int
+	}{
+		{name: "exec", cmd: []string{"echo hello direct"}, stdout: "hello direct\n"},
+		{name: "noArgs", stdin: "echo from-shell\nexit 0\n", stdout: "from-shell\n"},
+		{name: "exitStatus", cmd: []string{"exit 7"}, code: 7},
+		{name: "stderr", cmd: []string{"echo out; echo err >&2"}, stdout: "out\n", stderr: "err\n"},
+		{name: "binarySafe", stdin: binary, cmd: []string{"cat"}, stdout: binary},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			args := append(append([]string{"-p", port}, opts...), target)
+			args = append(args, tt.cmd...)
+			cmd := exec.Command("ssh", args...)
+			var outb, errb bytes.Buffer
+			cmd.Stdin = strings.NewReader(tt.stdin)
+			cmd.Stdout = &outb
+			cmd.Stderr = &errb
+			err := cmd.Run()
+			code := 0
+			if ee, ok := err.(*exec.ExitError); ok {
+				code = ee.ExitCode()
+			} else if err != nil {
+				t.Fatalf("ssh: %v", err)
+			}
+			if code != tt.code || outb.String() != tt.stdout || errb.String() != tt.stderr {
+				t.Errorf("code=%d stdout=%q stderr=%q, want %d, %q, %q",
+					code, outb.String(), errb.String(), tt.code, tt.stdout, tt.stderr)
+			}
+		})
+	}
+
+	t.Run("scp", func(t *testing.T) {
+		if fc.sftpServer() == "" {
+			t.Skip("no sftp-server binary found")
+		}
+		requireCommand(t, "scp")
+		src := filepath.Join(dir, "src.bin")
+		data := []byte("scp test \x00\x01\xfe\xff\r\n data")
+		if err := os.WriteFile(src, data, 0o644); err != nil {
+			t.Fatal(err)
+		}
+		remote := filepath.Join(dir, "remote.bin") // "remote" is the local fs: sftp-server runs in-process
+		back := filepath.Join(dir, "back.bin")
+		for _, files := range [][]string{
+			{src, target + ":" + remote},
+			{target + ":" + remote, back},
+		} {
+			args := append(append([]string{"-P", port}, opts...), files...)
+			out, err := exec.Command("scp", args...).CombinedOutput()
+			if err != nil {
+				t.Fatalf("scp %v: %v\n%s", files, err, out)
+			}
+		}
+		got, err := os.ReadFile(back)
+		if err != nil || !bytes.Equal(got, data) {
+			t.Errorf("round trip = %q, %v; want %q", got, err, data)
+		}
+	})
+}
+
+// requireCommand skips the test unless the named command is installed
+// and can be run. Running it is the part worth checking: some builders
+// have an ssh and an scp in PATH that fail to exec.
+func requireCommand(t *testing.T, name string, args ...string) {
+	t.Helper()
+	path, err := exec.LookPath(name)
+	if err != nil {
+		t.Skipf("no %s command: %v", name, err)
+	}
+	// The command may report usage and exit with a status, which is
+	// fine; only a failure to run it at all disqualifies the system.
+	var execErr *exec.Error
+	if err := exec.Command(path, args...).Run(); errors.As(err, &execErr) {
+		t.Skipf("cannot run %s: %v", path, err)
+	}
+}
+
+// A directFakeClient is a buildlet client whose ConnectSSH returns a
+// connection to an in-process fake buildlet sshd.
+type directFakeClient struct {
+	buildlet.FakeClient
+	t          *testing.T
+	hostSigner ssh.Signer
+	authorized ssh.PublicKey
+}
+
+// sftpServer returns the path of the OpenSSH sftp-server binary,
+// or "" if none is installed.
+func (c *directFakeClient) sftpServer() string {
+	for _, p := range []string{
+		"/usr/libexec/sftp-server",         // macOS
+		"/usr/lib/openssh/sftp-server",     // Debian
+		"/usr/libexec/openssh/sftp-server", // Fedora
+		"/usr/lib/ssh/sftp-server",         // Arch
+	} {
+		if _, err := os.Stat(p); err == nil {
+			return p
+		}
+	}
+	return ""
+}
+
+func (c *directFakeClient) ConnectSSH(user, authorizedPubKey string) (net.Conn, error) {
+	if user != "swarming" {
+		return nil, fmt.Errorf("ConnectSSH user = %q, want swarming", user)
+	}
+	c1, c2 := net.Pipe()
+	go c.serveSSH(c2)
+	return c1, nil
+}
+
+// serveSSH runs the fake buildlet sshd on conn: public key
+// authentication with the gomote key, and sessions that run exec and
+// shell requests locally and hand the sftp subsystem to sftp-server.
+// A session requesting a pty is an error: the direct proxy path must
+// not allocate one.
+func (c *directFakeClient) serveSSH(conn net.Conn) {
+	defer conn.Close()
+	config := &ssh.ServerConfig{
+		PublicKeyCallback: func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
+			if !bytes.Equal(key.Marshal(), c.authorized.Marshal()) {
+				return nil, fmt.Errorf("unauthorized key")
+			}
+			return nil, nil
+		},
+	}
+	config.AddHostKey(c.hostSigner)
+	sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
+	if err != nil {
+		c.t.Logf("fake sshd: handshake: %v", err)
+		return
+	}
+	defer sconn.Close()
+	go ssh.DiscardRequests(reqs)
+	for newCh := range chans {
+		if newCh.ChannelType() != "session" {
+			newCh.Reject(ssh.UnknownChannelType, "unknown channel type")
+			continue
+		}
+		ch, chReqs, err := newCh.Accept()
+		if err != nil {
+			continue
+		}
+		go c.serveSession(ch, chReqs)
+	}
+}
+
+func (c *directFakeClient) serveSession(ch ssh.Channel, reqs <-chan *ssh.Request) {
+	for req := range reqs {
+		var cmd *exec.Cmd
+		switch req.Type {
+		default:
+			req.Reply(false, nil)
+			continue
+		case "env":
+			req.Reply(true, nil)
+			continue
+		case "pty-req":
+			c.t.Errorf("fake sshd: unexpected pty-req on direct session")
+			req.Reply(false, nil)
+			continue
+		case "exec":
+			var p struct{ Command string }
+			ssh.Unmarshal(req.Payload, &p)
+			cmd = exec.Command("/bin/sh", "-c", p.Command)
+		case "shell":
+			cmd = exec.Command("/bin/sh")
+		case "subsystem":
+			var p struct{ Name string }
+			ssh.Unmarshal(req.Payload, &p)
+			server := c.sftpServer()
+			if p.Name != "sftp" || server == "" {
+				req.Reply(false, nil)
+				continue
+			}
+			cmd = exec.Command(server)
+		}
+		req.Reply(true, nil)
+		// Drain any remaining requests so the connection loop
+		// does not stall while the command runs.
+		go func() {
+			for req := range reqs {
+				req.Reply(false, nil)
+			}
+		}()
+		c.runCommand(ch, cmd)
+		return
+	}
+	ch.Close()
+}
+
+func (c *directFakeClient) runCommand(ch ssh.Channel, cmd *exec.Cmd) {
+	defer ch.Close()
+	cmd.Stdout = ch
+	cmd.Stderr = ch.Stderr()
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		c.t.Errorf("fake sshd: %v", err)
+		return
+	}
+	go func() {
+		io.Copy(stdin, ch)
+		stdin.Close()
+	}()
+	err = cmd.Run()
+	code := 0
+	if ee, ok := err.(*exec.ExitError); ok {
+		code = ee.ExitCode()
+	} else if err != nil {
+		c.t.Errorf("fake sshd: running command: %v", err)
+		return
+	}
+	ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
+}