From aa271c7912909ec8feda681959df77252a174894 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:11:15 +0000 Subject: [PATCH 1/2] ci: run the Go agent E2E suite on Windows, and fail if it skips windows-e2e never ran any of the 22 agent E2E tests. Its only go test step ran before the job downloaded the binaries, and findE2EBinary's ../../dist fallback was the literal "..._linux_amd64" at every call site, so on Windows (where build.sh also appends .exe) it looked for a file that could never be there. Every test skipped and the job went green. Nothing in either job would notice the Linux step going the same way either: go test reports skips as a pass. windows-e2e now runs `go test -v ./cmd/meowshell/` in a new step after the artifact is downloaded into its private staging directory, with MEOWSHELL/TAILCAT set explicitly to the Windows binaries (returned unchecked, so a broken binary fails instead of skipping). It runs before the testderp step, which exports TAILCAT_DERPMAP_URL; these tests use TS_DEBUG_TAILCAT_LOCAL_DERP per server, as in the build job. The job timeout goes from 15 to 25 minutes to cover the second run. findE2EBinary now takes the bare binary name and builds the fallback from runtime.GOOS/GOARCH the way build.sh's build() does (armv7 for arm, .exe on Windows). It logs a fixed marker when it resolves a binary and puts one in its skip message. e2e/require-agent-e2e-ran.sh wraps the go test in both E2E steps and fails the step if any test skipped for a missing binary, or if no lookup resolved at all (tests filtered out, -v dropped). *.sh is pinned to eol=lf so the windows-latest autocrlf=true checkout does not hand Git Bash a CRLF script. The .NET E2E suite needs no change for this: it runs only in the Linux dotnet job, and its lookup has no hardcoded platform name. CLAUDE.md now says so, and describes the new Windows state. Verified on linux/amd64 with .tailcat-src set up per CLAUDE.md: go vet ./... and GOOS=windows go vet ./... pass, as does GOOS=windows go test -c ./cmd/meowshell/. go test ./... passes except TestListenUnixAllowsRootOwnedStickyTmpStyleParent, which fails the same way on the parent commit (it only runs as root, and this sandbox runs as root). Through the guard with explicit binaries: 26 lookups resolved, 0 skips, exit 0; again with the env vars unset, going through the new fallback name. Mutations: dist/ moved aside -> 22 skips, go test PASS, guard exit 1; -run limited to the naming test -> guard exit 1 for no lookups; nonexistent explicit binaries -> go test fails and the guard passes that through; the .exe suffix dropped -> TestE2EDistNameMatchesBuildSh fails. actionlint is clean. Nothing here has run on Windows; only a windows-e2e run shows that the suite passes there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WM5HyhwRLKBDyHR6qitkk --- .gitattributes | 5 ++ .github/workflows/ci.yml | 30 +++++++-- CLAUDE.md | 20 +++--- cmd/meowshell/agent_auth_e2e_test.go | 8 +-- cmd/meowshell/agent_e2e_test.go | 62 +++++++++++++++++-- cmd/meowshell/agent_forward_e2e_test.go | 4 +- cmd/meowshell/agent_health_e2e_test.go | 4 +- cmd/meowshell/agent_security_e2e_test.go | 8 +-- cmd/meowshell/agent_sftp_e2e_test.go | 4 +- .../agent_tailcat_forward_e2e_test.go | 4 +- cmd/meowshell/agent_tcp_e2e_test.go | 10 +-- cmd/meowshell/mosh_agent_e2e_test.go | 8 +-- e2e/require-agent-e2e-ran.sh | 36 +++++++++++ 13 files changed, 163 insertions(+), 40 deletions(-) create mode 100755 e2e/require-agent-e2e-ran.sh diff --git a/.gitattributes b/.gitattributes index 9f48f5f..c27a46e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,8 @@ # This is the mirror image of tailcat's own .gitattributes, which pins # build-tags.txt to eol=lf for the same class of reason. *.patch -text + +# Same class of bug for the shell scripts CI runs under Git Bash on Windows +# (e2e/require-agent-e2e-ran.sh in windows-e2e): an autocrlf=true checkout +# makes them CRLF, and bash then reads every line with a trailing \r. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76f3343..6a7b8a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,13 +97,14 @@ jobs: # Run the agent E2E suite only after the real binaries exist; the # earlier go test step intentionally runs before ./build.sh and those - # tests skip when dist/ is absent. + # tests skip when dist/ is absent. The wrapper fails the step if any + # of them still skipped for a missing binary, or none ran at all. - name: End-to-end test the agent against the binaries just built env: GOTOOLCHAIN: local MEOWSHELL: ${{ github.workspace }}/dist/meowshell_linux_amd64 TAILCAT: ${{ github.workspace }}/dist/tailcat_linux_amd64 - run: go test ./cmd/meowshell/ -count=1 + run: ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 - name: Scan the patched tailcat build input for known vulnerabilities env: @@ -244,7 +245,9 @@ jobs: windows-e2e: needs: build runs-on: windows-latest - timeout-minutes: 15 + # Was 15 before this job also ran the Go agent E2E suite, which recompiles + # and reruns cmd/meowshell's tests on top of the vet/test step. + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -287,7 +290,8 @@ jobs: # validation, N9) only builds and runs under GOOS=windows, so it never # gets exercised by the "build" job's own go vet/go test, which run on # ubuntu-latest -- this is the only place in CI a real Windows host - # actually type-checks and runs that code. + # actually type-checks and runs that code. The agent E2E tests skip + # here, since the binaries are not fetched yet; they run further down. - name: Vet and test meowshell on Windows shell: bash env: @@ -324,6 +328,24 @@ jobs: name: tailcat-binaries path: ${{ runner.temp }}/meowshell-dist + # The same agent E2E suite the "build" job runs after ./build.sh, here + # against the Windows binaries it built. Until this step existed the + # suite only ever ran in the vet/test step above, with no binaries and + # a fallback that only knew linux_amd64, so all of it skipped and the + # job went green. MEOWSHELL/TAILCAT stay explicit so a broken binary + # fails rather than skips; the wrapper fails the step if anything + # still skipped for a missing binary. Before the DERP relay step on + # purpose: that exports TAILCAT_DERPMAP_URL for later steps, and these + # tests run their servers in TS_DEBUG_TAILCAT_LOCAL_DERP mode instead, + # exactly as they do in the "build" job, which never sets it. + - name: End-to-end test the agent against the Windows binaries + shell: bash + env: + GOTOOLCHAIN: local + MEOWSHELL: ${{ runner.temp }}\meowshell-dist\meowshell_windows_amd64.exe + TAILCAT: ${{ runner.temp }}\meowshell-dist\tailcat_windows_amd64.exe + run: ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 + - name: Start a local DERP relay for the E2E tests shell: pwsh run: ./e2e/start-testderp.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 70a7d53..511c413 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,13 +51,19 @@ fail once before believing it. Both E2E suites are wired into CI, but only just: the `dotnet` job sets `DOTNET_E2E_*`, and the `build` job runs the Go agent E2E tests in a step *after* `./build.sh`, because the `go test ./...` step before it has no `dist/` -yet and skipped all 22 for as long as they existed. Those two steps are the -only reason any of it runs — `findE2EBinary`'s `../../dist` fallback skips on a -missing file, while an explicit `$MEOWSHELL`/`$TAILCAT` is returned unchecked, -so keep setting them and a broken binary fails loudly instead of going quiet. -Windows is still dark: `windows-e2e` also runs `go test` before fetching the -artifact, and the dist name `findE2EBinary` falls back to is hardcoded -`linux_amd64`. +yet and skipped all 22 for as long as they existed. `windows-e2e` does the +same against the Windows binaries, in a step after it downloads the build +artifact (its own earlier `go test` step skips them too). Those steps are the +only reason any of it runs — `findE2EBinary`'s `../../dist` fallback (named +for the host's GOOS/GOARCH as `./build.sh` names it, `.exe` on Windows) skips +on a missing file, while an explicit `$MEOWSHELL`/`$TAILCAT` is returned +unchecked, so keep setting them and a broken binary fails loudly instead of +going quiet. Both Go agent E2E steps run through `e2e/require-agent-e2e-ran.sh`, +which fails the step if any test skipped for a missing binary or none resolved +one; it needs `go test -v`, since it greps the markers `findE2EBinary` logs. +The .NET E2E suite has no such guard and runs on Linux only: there is no .NET +job on Windows, and `FindRealBinaries` also no-ops when a `DOTNET_E2E_*` path +does not exist, so a wrong path there still goes quiet. ## E2E tests and the DERP relay diff --git a/cmd/meowshell/agent_auth_e2e_test.go b/cmd/meowshell/agent_auth_e2e_test.go index 9e442c7..84dea1a 100644 --- a/cmd/meowshell/agent_auth_e2e_test.go +++ b/cmd/meowshell/agent_auth_e2e_test.go @@ -79,7 +79,7 @@ func acceptHostKeyPrompt(t *testing.T, stdin *os.File, out *bufio.Reader) { } func TestAgentPasswordAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { cfg.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { @@ -127,7 +127,7 @@ func TestAgentPasswordAuth(t *testing.T) { } func TestAgentSuppliedPrivateKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") privatePEM, publicKey := newTestKeyPair(t) addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { @@ -189,7 +189,7 @@ func driveKeystoreAuth(t *testing.T, stdin *os.File, out *bufio.Reader, signer s } func TestAgentKeystoreKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") // An RSA key is the case the key-type-as-signature-algorithm shortcut got // wrong: ssh-rsa means SHA-1, which every current server refuses. The server @@ -312,7 +312,7 @@ func newEncryptedTestKeyPair(t *testing.T, passphrase string) (encryptedPEM []by // complete. mustReadFrame's 30s deadline (see readFrameWithDeadline) turns a // regression here into a clean failure instead of a hung test. func TestAgentEncryptedSuppliedPrivateKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") const passphrase = "correct-passphrase" encryptedPEM, publicKey := newEncryptedTestKeyPair(t, passphrase) diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go index fda9f49..051a595 100644 --- a/cmd/meowshell/agent_e2e_test.go +++ b/cmd/meowshell/agent_e2e_test.go @@ -9,13 +9,14 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "time" ) func TestAgentEndToEnd(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) @@ -89,22 +90,75 @@ func TestAgentEndToEnd(t *testing.T) { }) } -func findE2EBinary(t *testing.T, envVar, distName string) string { +// CI greps the `go test -v` log of every step meant to run these tests for +// both markers (e2e/require-agent-e2e-ran.sh): any "missing" line, or no +// "resolved" line at all, fails the step. Without that, a job whose binaries +// were never wired up passes green with every E2E test skipped -- which is +// exactly how windows-e2e ran none of them for as long as they existed. +const ( + e2eBinaryMissingMarker = "E2E binary missing:" + e2eBinaryResolvedMarker = "E2E binary resolved:" +) + +// findE2EBinary returns the real binary for this test: $envVar if set, +// else the one ./build.sh wrote for this host under ../../dist. +// +// An explicit $envVar is returned unchecked on purpose: CI always sets it, so +// a missing or broken binary fails the test loudly instead of skipping. +func findE2EBinary(t *testing.T, envVar, name string) string { t.Helper() if p := os.Getenv(envVar); p != "" { + t.Logf("%s $%s=%s", e2eBinaryResolvedMarker, envVar, p) return p } + distName := e2eDistName(name, runtime.GOOS, runtime.GOARCH) p := filepath.Join("..", "..", "dist", distName) if _, err := os.Stat(p); err != nil { - t.Skipf("no %s (looked for $%s and %s); build.sh must run first", distName, envVar, p) + t.Skipf("%s no %s (looked for $%s and %s); build.sh must run first", e2eBinaryMissingMarker, distName, envVar, p) } abs, err := filepath.Abs(p) if err != nil { t.Fatal(err) } + t.Logf("%s %s", e2eBinaryResolvedMarker, abs) return abs } +// e2eDistName is the file name ./build.sh's build() gives for +// goos/goarch. This used to be the literal "..._linux_amd64" at every call +// site, so on any other host -- Windows above all, where the file also ends +// in .exe -- the fallback looked for a binary that was never there and every +// E2E test skipped. build.sh only builds arm as GOARM=7, hence "armv7". +func e2eDistName(name, goos, goarch string) string { + s := name + "_" + goos + "_" + goarch + if goarch == "arm" { + s += "v7" + } + if goos == "windows" { + s += ".exe" + } + return s +} + +// The wanted names are copied from what ./build.sh writes (its build() and +// targets_for), not derived from e2eDistName's own logic: the point is that +// the fallback finds build.sh's output on the host running the tests. +func TestE2EDistNameMatchesBuildSh(t *testing.T) { + for _, tc := range []struct{ name, goos, goarch, want string }{ + {"meowshell", "linux", "amd64", "meowshell_linux_amd64"}, + {"tailcat", "linux", "arm64", "tailcat_linux_arm64"}, + {"meowshell", "linux", "arm", "meowshell_linux_armv7"}, + {"tailcat", "linux", "386", "tailcat_linux_386"}, + {"meowshell", "windows", "amd64", "meowshell_windows_amd64.exe"}, + {"tailcat", "windows", "arm64", "tailcat_windows_arm64.exe"}, + {"meowshell", "android", "arm64", "meowshell_android_arm64"}, + } { + if got := e2eDistName(tc.name, tc.goos, tc.goarch); got != tc.want { + t.Errorf("e2eDistName(%q, %q, %q) = %q, want %q", tc.name, tc.goos, tc.goarch, got, tc.want) + } + } +} + func startE2EServer(t *testing.T, tailcatBin, meowshellBin, home string) string { t.Helper() addrFile := filepath.Join(home, "addr") diff --git a/cmd/meowshell/agent_forward_e2e_test.go b/cmd/meowshell/agent_forward_e2e_test.go index b78214f..7a90cff 100644 --- a/cmd/meowshell/agent_forward_e2e_test.go +++ b/cmd/meowshell/agent_forward_e2e_test.go @@ -10,7 +10,7 @@ import ( ) func TestAgentLocalForwardEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -65,7 +65,7 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) { } func TestAgentSOCKSForwardEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/cmd/meowshell/agent_health_e2e_test.go b/cmd/meowshell/agent_health_e2e_test.go index 649da5f..47bfd96 100644 --- a/cmd/meowshell/agent_health_e2e_test.go +++ b/cmd/meowshell/agent_health_e2e_test.go @@ -44,8 +44,8 @@ import ( // legitimately report while it's still settling in a sandboxed test // environment. func TestAgentRelayHealthReporterAttachesToARealTailcatConnection(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) diff --git a/cmd/meowshell/agent_security_e2e_test.go b/cmd/meowshell/agent_security_e2e_test.go index 34a1ceb..d0e43c4 100644 --- a/cmd/meowshell/agent_security_e2e_test.go +++ b/cmd/meowshell/agent_security_e2e_test.go @@ -11,7 +11,7 @@ import ( ) func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, _ := startTestSSHServer(t, echoCommandHandler) knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -63,7 +63,7 @@ func TestAgentUnixSocketForward(t *testing.T) { if os.PathSeparator == '\\' { t.Skip("unix domain sockets aren't this test's concern on Windows") } - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -122,7 +122,7 @@ func TestAgentUnixSocketForward(t *testing.T) { } func TestAgentSocksAuthToken(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -207,7 +207,7 @@ func TestAgentSocksAuthToken(t *testing.T) { } func TestAgentConfigureCarriesProxyURL(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, _ := startTestSSHServer(t, echoCommandHandler) proxyLn, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go index 95e28ed..8a8b5ce 100644 --- a/cmd/meowshell/agent_sftp_e2e_test.go +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -12,8 +12,8 @@ import ( ) func TestAgentSFTPEndToEnd(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go index 1f3ef17..ae990c0 100644 --- a/cmd/meowshell/agent_tailcat_forward_e2e_test.go +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -13,8 +13,8 @@ import ( ) func TestAgentForwardsThroughTailcatDestination(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) diff --git a/cmd/meowshell/agent_tcp_e2e_test.go b/cmd/meowshell/agent_tcp_e2e_test.go index a27e396..bdcfd8d 100644 --- a/cmd/meowshell/agent_tcp_e2e_test.go +++ b/cmd/meowshell/agent_tcp_e2e_test.go @@ -26,7 +26,7 @@ import ( // keeps working normally afterward (a real exec command run against it still // completes and returns its output). func TestNonTerminalErrorDoesNotEndTheChannel(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stop := startTestSSHServer(t, echoCommandHandler) defer stop() knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -69,7 +69,7 @@ func TestNonTerminalErrorDoesNotEndTheChannel(t *testing.T) { } func TestAgentTCPEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, hostKey1, stopServer1 := startTestSSHServer(t, echoCommandHandler) knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -139,7 +139,7 @@ func TestAgentTCPEndToEnd(t *testing.T) { // nothing was listening to (what MeowshellAgentConnection sends when no // HostKeyPromptRequested handler is attached). func TestUnknownHostKeyIsReportedAsHostKeyUnknown(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stopServer := startTestSSHServer(t, echoCommandHandler) defer stopServer() @@ -191,7 +191,7 @@ func TestUnknownHostKeyIsReportedAsHostKeyUnknown(t *testing.T) { // unwind a "successfully opened" channel for. A server that rejects the // exec request lets the test trigger that failure deterministically. func TestOpenShellChannelReportsOpenFailureNotChannelOpenedThenError(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, stop := startTestSSHServerRejectingSessionStart(t) defer stop() knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -309,7 +309,7 @@ func startTestSSHServerRejectingSessionStart(t *testing.T) (addr string, stop fu // ordinary exec channel opened right after it must still succeed promptly // rather than wait on the first. func TestOpenChannelDoesNotBlockOtherChannels(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") const stuckCommand = "stuck" addr, stop := startTestSSHServerHangingOnExecCommand(t, stuckCommand) defer stop() diff --git a/cmd/meowshell/mosh_agent_e2e_test.go b/cmd/meowshell/mosh_agent_e2e_test.go index 09b5e5f..b2aef8a 100644 --- a/cmd/meowshell/mosh_agent_e2e_test.go +++ b/cmd/meowshell/mosh_agent_e2e_test.go @@ -20,7 +20,7 @@ import ( // uses), a real system mosh-server for the bootstrap's exec command, and a // real Mosh/UDP client dialing it -- the same three pieces production wires // together, just all on loopback. They skip (via findE2EBinary) without a -// built dist/meowshell_linux_amd64, and skip outright if this host has no +// built dist/meowshell for this host, and skip outright if this host has no // mosh-server on PATH: neither is optional stand-in behavior worth faking, // since bootstrapMosh's whole job is parsing that program's real output. @@ -126,7 +126,7 @@ func acceptMoshHostKey(t *testing.T, stdin *os.File, out *bufio.Reader) { // here proven from the outside through the compiled binary instead. func TestMoshAgentEndToEnd(t *testing.T) { requireMoshServerBinary(t) - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") pids := make(chan int, 1) addr, _, stop := startTestSSHServer(t, moshServerExecHandler(pids)) @@ -204,7 +204,7 @@ func TestMoshAgentEndToEnd(t *testing.T) { // confirms that message actually reaches the client as a structured "error" // rather than the process just hanging or exiting silently. func TestMoshAgentSurfacesAMissingMoshServer(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stop := startTestSSHServer(t, moshServerNotInstalledExecHandler) defer stop() @@ -241,7 +241,7 @@ func TestMoshAgentSurfacesAMissingMoshServer(t *testing.T) { // init()'s os.Args dispatch and main's actual os.Exit(1), which the direct // moshAgentCmd() call can't observe on its own. func TestMoshAgentRejectsTailcatAddressAsARealProcess(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") cmd := exec.Command(meowshellBin, "mosh-agent", validTailcatAddress) var stderr bytes.Buffer diff --git a/e2e/require-agent-e2e-ran.sh b/e2e/require-agent-e2e-ran.sh new file mode 100755 index 0000000..78985fd --- /dev/null +++ b/e2e/require-agent-e2e-ran.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run a `go test -v` command and fail unless the agent E2E tests really ran. +# +# ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 +# +# The agent E2E tests skip rather than fail when findE2EBinary finds no real +# binary, so a green `go test` says nothing about whether any of them ran: +# windows-e2e passed for as long as they existed with all of them skipped +# (it ran go test before fetching the binaries, and the fallback only knew +# the linux_amd64 file name). This fails the step if any test skipped for a +# missing binary, or if none resolved one at all -- the latter catches the +# tests having been filtered out or moved, which would skip nothing and +# still run nothing. The markers are the constants next to findE2EBinary in +# cmd/meowshell/agent_e2e_test.go; -v is required, or t.Logf/t.Skipf output +# never reaches the log. +set -uo pipefail + +log=$(mktemp) +trap 'rm -f "$log"' EXIT + +"$@" 2>&1 | tee "$log" +status=$? +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +if grep -F 'E2E binary missing:' "$log" >/dev/null; then + echo "::error::agent E2E tests skipped for a missing binary; set \$MEOWSHELL and \$TAILCAT to the binaries this job built or fetched:" >&2 + grep -F 'E2E binary missing:' "$log" >&2 + exit 1 +fi +if ! grep -F 'E2E binary resolved:' "$log" >/dev/null; then + echo "::error::no agent E2E test resolved a binary, so none of them ran (was -v dropped, or the tests filtered out?)" >&2 + exit 1 +fi +echo "agent E2E guard: $(grep -cF 'E2E binary resolved:' "$log") binary lookups resolved, none skipped" From 67a9e76c9f4ffb7bfd00a639fcfe95505ddd245a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:46:37 +0000 Subject: [PATCH 2/2] e2e: stop assuming a Unix server in the agent E2E tests The first windows-e2e run of the agent E2E suite passed all but four subtests, each one a test assuming the server side was Unix: - exec with a nonzero exit read back 1, not 42. The server runs exec commands through its user's shell, which on Windows is `pwsh -Command`; that turns a failing native command's exit code into 1, so `sh -c 'exit 42'` could never report 42. On Windows the test now sends `exit 42`, which PowerShell exits 42 on. - The shell channel saw its marker echoed but never exited: the test typed Enter as \n. A Unix pty maps a terminal's \r to \n, so that worked there; the Windows server's ConPTY does not, and PowerShell took \n as Ctrl+Enter, a new line rather than a submitted command, so "exit" sat unrun until the 30 s frame read timed out. Enter is now \r everywhere, which is what a real terminal (the app's xterm.js included) sends. - Two SFTP mode checks read back 0666 for 0640 and 0600. Windows has no Unix permissions; Go's os.Chmod there only toggles the read-only attribute from the owner write bit. servedFileMode says what a server on this platform reports; the Linux job still checks the exact bits. Verified on linux/amd64: the agent E2E suite through e2e/require-agent-e2e-ran.sh passes (26 lookups resolved, none skipped), so \r works through a Unix pty; GOOS=windows go vet is clean. The Windows-only branches are proven only by windows-e2e itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WM5HyhwRLKBDyHR6qitkk --- cmd/meowshell/agent_e2e_test.go | 19 ++++++++++++++++--- cmd/meowshell/agent_sftp_e2e_test.go | 25 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go index 051a595..0c22810 100644 --- a/cmd/meowshell/agent_e2e_test.go +++ b/cmd/meowshell/agent_e2e_test.go @@ -62,7 +62,15 @@ func TestAgentEndToEnd(t *testing.T) { }) t.Run("exec channel with a nonzero exit reports it as a structured value", func(t *testing.T) { - send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"sh", "-c", "'exit 42'"}}) + // The server runs an exec command through its user's shell: sh on + // Unix, but `pwsh -Command` on Windows, which turns a failing native + // command's exit code into 1 -- so `sh -c 'exit 42'` read back 1 + // there. `exit 42` is what each shell itself exits 42 on. + command := []string{"sh", "-c", "'exit 42'"} + if runtime.GOOS == "windows" { + command = []string{"exit", "42"} + } + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: command}) id := expectChannelOpened(t, out) exitCode := readExitOnly(t, out, id) @@ -78,14 +86,19 @@ func TestAgentEndToEnd(t *testing.T) { send(t, stdin, id, controlMessage{Msg: "resize", Cols: 120, Rows: 40}) - mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("echo shell-marker-e2e\n")}) + // Enter is \r, as a real terminal (and the app's xterm.js) sends it. + // A Unix pty turns it into \n, which is why \n used to work here; the + // Windows server's ConPTY does not, and PowerShell took \n as + // Ctrl+Enter -- a new line, never a submitted command -- so "exit" + // sat unrun until the frame read timed out. + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("echo shell-marker-e2e\r")}) got := readUntil(t, out, id, "shell-marker-e2e", 20*time.Second) if !bytes.Contains(got, []byte("shell-marker-e2e")) { t.Errorf("shell output = %q, want it to contain the echoed marker", got) } - mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("exit\n")}) + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("exit\r")}) readUntilExit(t, out, id) }) } diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go index 8a8b5ce..03949a8 100644 --- a/cmd/meowshell/agent_sftp_e2e_test.go +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -7,10 +7,27 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "time" ) +// servedFileMode is the permission bits a server on this platform reports +// for a file chmod-ed to mode. Windows has no Unix permissions: Go's +// os.Chmod there sets or clears only the read-only attribute, from the owner +// write bit, and os.Stat reports 0666 or 0444. Expecting 0640 back failed +// the first time these tests ran on Windows; the exact bits are the Linux +// job's to check. +func servedFileMode(mode uint32) uint32 { + if runtime.GOOS != "windows" { + return mode + } + if mode&0o200 != 0 { + return 0o666 + } + return 0o444 +} + func TestAgentSFTPEndToEnd(t *testing.T) { tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") @@ -46,8 +63,8 @@ func TestAgentSFTPEndToEnd(t *testing.T) { sftpOp(t, stdin, out, controlMessage{Op: "chmod", Path: "adir/file.txt", Mode: 0o640}) stat = sftpOp(t, stdin, out, controlMessage{Op: "stat", Path: "adir/file.txt"}) - if got := stat.Entries[0].Mode & 0o777; got != 0o640 { - t.Errorf("mode after chmod = %o, want 0640", got) + if got, want := stat.Entries[0].Mode&0o777, servedFileMode(0o640); got != want { + t.Errorf("mode after chmod = %o, want %o", got, want) } sftpOp(t, stdin, out, controlMessage{Op: "rename", Path: "adir/file.txt", NewPath: "adir/renamed.txt"}) @@ -71,8 +88,8 @@ func TestAgentSFTPEndToEnd(t *testing.T) { if len(stat.Entries) != 1 { t.Fatalf("stat = %+v", stat.Entries) } - if got := stat.Entries[0].Mode & 0o777; got != 0o600 { - t.Errorf("preserved mode = %o, want 0600", got) + if got, want := stat.Entries[0].Mode&0o777, servedFileMode(0o600); got != want { + t.Errorf("preserved mode = %o, want %o", got, want) } if got := stat.Entries[0].ModTime; got != mtime.Unix() { t.Errorf("preserved mtime = %d, want %d", got, mtime.Unix())