From f03f502585577d0cc0a88cbccb8e8daeb533580c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 1 May 2026 08:00:05 +0000 Subject: [PATCH] ssh/tailssh: test exit status with OpenSSH client Add coverage that exercises tailssh through the real ssh client so the client-visible exit status ordering is checked, including command-not-found behavior. Updates #18256 Change-Id: If2bae5b337d213390f4a9788501c1a59aea2eafb Signed-off-by: Kristoffer Dalby --- ssh/tailssh/tailssh_integration_test.go | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/ssh/tailssh/tailssh_integration_test.go b/ssh/tailssh/tailssh_integration_test.go index 0d9760db7..14d4b4a27 100644 --- a/ssh/tailssh/tailssh_integration_test.go +++ b/ssh/tailssh/tailssh_integration_test.go @@ -1006,6 +1006,86 @@ func TestIntegrationExitCodes(t *testing.T) { } } +// TestOpenSSHExitCodes verifies that exit codes are propagated to a real +// OpenSSH client. This covers the client-visible behavior from #18256. +func TestOpenSSHExitCodes(t *testing.T) { + if _, err := exec.LookPath("ssh"); err != nil { + t.Skipf("skipping test without OpenSSH client: %v", err) + } + + debugTest.Store(true) + t.Cleanup(func() { debugTest.Store(false) }) + + addr := testServer(t, "testuser", false, false) + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + + exitStatus := func(t *testing.T, err error) int { + t.Helper() + if err == nil { + return 0 + } + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("expected *exec.ExitError, got %T: %v", err, err) + } + return ee.ExitCode() + } + + tests := []struct { + name string + cmd string + wantCode int + }{ + { + name: "success", + cmd: "true", + wantCode: 0, + }, + { + name: "exit_code_passthrough", + cmd: "exit 42", + wantCode: 42, + }, + { + name: "command_not_found", + cmd: "/nonexistent/binary", + wantCode: 127, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ssh", + "-F", "none", + "-T", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "GlobalKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-o", "NumberOfPasswordPrompts=0", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-p", port, + "testuser@"+host, + tt.cmd, + ) + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("ssh command timed out; output:\n%s", out) + } + if got := exitStatus(t, err); got != tt.wantCode { + t.Fatalf("ssh exit code = %d, want %d; output:\n%s", got, tt.wantCode, out) + } + }) + } +} + // TestLocalUnixForwardingHalfClose verifies that the bidirectional copy // in Unix socket forwarding uses half-close correctly: when one direction // finishes, the other direction's data is not lost. This tests the bicopy