Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 26 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions cmd/meowshell/agent_auth_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
81 changes: 74 additions & 7 deletions cmd/meowshell/agent_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -61,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)
Expand All @@ -77,34 +86,92 @@ 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)
})
}

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 <name> 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 <name> 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")
Expand Down
4 changes: 2 additions & 2 deletions cmd/meowshell/agent_forward_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/meowshell/agent_health_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
8 changes: 4 additions & 4 deletions cmd/meowshell/agent_security_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
29 changes: 23 additions & 6 deletions cmd/meowshell/agent_sftp_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,30 @@ 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_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"))
Expand Down Expand Up @@ -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"})
Expand All @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions cmd/meowshell/agent_tailcat_forward_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading
Loading