From aaa8edfb8f2ef3159c0623fa5b73fc32768d0c4d Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:32 +0200 Subject: [PATCH 01/28] Expose test DERP payload counters --- e2e/testderp/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/testderp/main.go b/e2e/testderp/main.go index 0cb8aa7..a98abd6 100644 --- a/e2e/testderp/main.go +++ b/e2e/testderp/main.go @@ -131,6 +131,10 @@ func run(statusFile, verifyClientURL string, verifyClientFailOpen bool) error { w.Header().Set("Content-Type", "application/json") w.Write(mapJSON) }) + mapMux.HandleFunc("/metrics.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, d.ExpVar(false).String()) + }) mapSrv := &http.Server{Handler: mapMux} go mapSrv.Serve(mapLn) defer mapSrv.Close() From b5047476696d79780724aa2bc8ab52342898939f Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:37 +0200 Subject: [PATCH 02/28] Export test DERP metrics endpoint --- e2e/start-testderp.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/start-testderp.sh b/e2e/start-testderp.sh index 7bbf513..fce7311 100755 --- a/e2e/start-testderp.sh +++ b/e2e/start-testderp.sh @@ -23,7 +23,9 @@ for _ in $(seq 1 100); do # Unset outside Actions: this script is also the documented way to # run an E2E suite locally (see README.md), where there is no # $GITHUB_ENV to export through and set -u would abort here. + metrics_url="${url%/derpmap.json}/metrics.json" echo "TAILCAT_DERPMAP_URL=$url" >> "${GITHUB_ENV:-/dev/null}" + echo "TESTDERP_METRICS_URL=$metrics_url" >> "${GITHUB_ENV:-/dev/null}" echo "local DERP relay ready: $url" exit 0 fi From 7605c4bd56d09a8c8f83e9a91f7fd328e68cc0f1 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:41 +0200 Subject: [PATCH 03/28] Prove direct payload bypasses DERP --- .../MeowshellAgentConnectionE2ETests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs index 034f1de..1d54156 100644 --- a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; +using System.Text.Json; using Meowshell; namespace Meowshell.Tests; @@ -19,6 +20,7 @@ private static void Mask(string value) private const string TailcatEnvVar = "DOTNET_E2E_TAILCAT_BIN"; private const string MeowshellEnvVar = "DOTNET_E2E_MEOWSHELL_BIN"; + private const string RelayMetricsEnvVar = "TESTDERP_METRICS_URL"; private readonly string _dir = Directory.CreateTempSubdirectory("agent-connection-e2e-").FullName; @@ -373,6 +375,83 @@ public async Task SessionLogCallbackSeesTheConnectionSetupsOwnDiagnostics() } } + [Fact] + public async Task DirectPathLargePayloadBypassesTheRelay() + { + var real = FindRealBinaries(); + if (real is null) return; + var (bin, _) = real.Value; + + var metricsUrl = Environment.GetEnvironmentVariable(RelayMetricsEnvVar); + Assert.False(string.IsNullOrWhiteSpace(metricsUrl), + $"Real-binary E2E requires {RelayMetricsEnvVar}; CI must start e2e/testderp before this test."); + + // Deliberately do not use RelayE2E.StartServerAsync here. That helper + // gives each server its own embedded loopback DERP. This test needs + // both endpoints on the shared testderp instance so its counters are + // an independent upper bound on how much payload traversed the relay. + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "direct-server-home"), + WorkDirectory = Path.Combine(_dir, "direct-server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + var becameDirect = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + void OnPath(MeowshellPathStatus status) + { + if (status.Direct) becameDirect.TrySetResult(status); + } + + connection.PathChanged += OnPath; + try + { + if (connection.CurrentPath?.Direct != true) + await becameDirect.Task.WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + connection.PathChanged -= OnPath; + } + + Assert.True(connection.CurrentPath?.Direct, + $"Tailcat never established a direct path; last path was {connection.CurrentPath}."); + + // Let any relay-assisted discovery frames settle before taking the + // baseline. Direct data may still coexist with tiny DERP discovery + // traffic; the assertion below deliberately allows a small control + // budget while proving the 1 MiB application payload did not traverse + // the relay. + await Task.Delay(500); + var before = await ReadRelayPayloadBytesAsync(metricsUrl!); + + const int applicationBytes = 1024 * 1024; + await using var exec = await connection.OpenExecAsync( + [$"head -c {applicationBytes} /dev/zero | tr '\\0' 'D'"]); + + long received = 0; + var buffer = new byte[32 * 1024]; + int read; + while ((read = await exec.Output.ReadAsync(buffer)) > 0) + received += read; + + Assert.Equal(0, await exec.Completed); + Assert.Equal(applicationBytes, received); + + var after = await ReadRelayPayloadBytesAsync(metricsUrl!); + var relayDelta = after - before; + + const long maxControlBytes = 64 * 1024; + Assert.InRange(relayDelta, 0, maxControlBytes); + } + [Fact] public async Task ExecChannelDeliversLargeOutputIntactUnderBackpressure() { @@ -406,6 +485,15 @@ public async Task ExecChannelDeliversLargeOutputIntactUnderBackpressure() Assert.All(received, b => Assert.Equal((byte)'A', b)); } + private static async Task ReadRelayPayloadBytesAsync(string metricsUrl) + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var document = JsonDocument.Parse(await http.GetStringAsync(metricsUrl)); + var root = document.RootElement; + return root.GetProperty("bytes_received").GetInt64() + + root.GetProperty("bytes_sent").GetInt64(); + } + private static async Task Socks5GreetAsync(Socket socket, byte[] methods, CancellationToken cancellationToken) { var greeting = new byte[2 + methods.Length]; From c8ef4641c4e3072fccb442922ed57a5497f9ee92 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:47 +0200 Subject: [PATCH 04/28] Run direct-path accounting E2E against shared DERP --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3c144..e672437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,6 +356,9 @@ jobs: dotnet restore dotnet/Meowshell.sln --force --no-cache --nologo dotnet restore scripts/CommentStripper/CommentStripper.csproj --force --no-cache --nologo + - name: Start a shared local DERP relay for direct-path E2E + run: ./e2e/start-testderp.sh + - name: Test # The E2E tests in Meowshell.Tests run against these real binaries # instead of the fake stand-ins the rest of the suite uses; they @@ -383,6 +386,10 @@ jobs: path: test-results/ if-no-files-found: warn + - name: Stop the shared local DERP relay + if: always() + run: kill "$(cat /tmp/testderp.pid)" 2>/dev/null || true + - name: Install the Android workload # Needed to pack Meowshell's android-targeted build below. run: dotnet workload install android From fc63c0175473ccf72083984ad3b905de65800cdd Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:50:37 +0200 Subject: [PATCH 05/28] Make direct-path assertion explicit --- dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs index 1d54156..d2bef56 100644 --- a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs @@ -421,7 +421,7 @@ void OnPath(MeowshellPathStatus status) connection.PathChanged -= OnPath; } - Assert.True(connection.CurrentPath?.Direct, + Assert.True(connection.CurrentPath?.Direct == true, $"Tailcat never established a direct path; last path was {connection.CurrentPath}."); // Let any relay-assisted discovery frames settle before taking the From 050036a571ed8c7728817bd48c6204e7546b0193 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:50:41 +0200 Subject: [PATCH 06/28] Keep shared DERP alive through .NET package checks --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e672437..6c49065 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -386,10 +386,6 @@ jobs: path: test-results/ if-no-files-found: warn - - name: Stop the shared local DERP relay - if: always() - run: kill "$(cat /tmp/testderp.pid)" 2>/dev/null || true - - name: Install the Android workload # Needed to pack Meowshell's android-targeted build below. run: dotnet workload install android @@ -433,6 +429,10 @@ jobs: path: nupkg/* if-no-files-found: error + - name: Stop the shared local DERP relay + if: always() + run: kill "$(cat /tmp/testderp.pid)" 2>/dev/null || true + # Meowshell.Demo: a real, installable app, not just a pass/fail check. # One button generates a fresh throwaway shell address, one field copies # it. Published as a single universal APK bundling all four ABIs, the From 6470b06a9b0d8862bce2b91a2e3b9d068bed79a9 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:50:45 +0200 Subject: [PATCH 07/28] Document test DERP metrics export --- e2e/start-testderp.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/start-testderp.sh b/e2e/start-testderp.sh index fce7311..dc960e9 100755 --- a/e2e/start-testderp.sh +++ b/e2e/start-testderp.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Starts e2e/testderp (a single-node, loopback-only DERP relay) in the -# background and exports TAILCAT_DERPMAP_URL for the rest of this job, so +# background and exports TAILCAT_DERPMAP_URL plus TESTDERP_METRICS_URL for +# the rest of this job, so # meowshell/tailcat E2E tests never depend on reaching the public Tailscale # relay infrastructure. Every subsequent step in the job inherits the # variable as a real environment variable (via $GITHUB_ENV), and tailcat From 5e39763f5db2a39e44eb472e01e5e20cee80f068 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:26 +0200 Subject: [PATCH 08/28] Use one in-process Tailcat client for agent SSH --- cmd/meowshell/agent.go | 53 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/cmd/meowshell/agent.go b/cmd/meowshell/agent.go index 802e0c4..001332b 100644 --- a/cmd/meowshell/agent.go +++ b/cmd/meowshell/agent.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "io" + "log" "net" "os" "path/filepath" @@ -20,6 +21,7 @@ import ( "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" "tailscale.com/types/key" + "tailscale.com/types/logger" ) const agentUsage = `meowshell agent -- a persistent, multiplexed SSH connection @@ -33,8 +35,9 @@ protocol.go) instead of the one-process-per-operation model "meowshell connect"/"cp" use. Opening a shell and running a command against the same host costs one login instead of two. - is a tailcat address (dialed through tailcat's own bare -client mode, same as "connect"/"cp") or a "[user@]host[:port]" TCP address + is a tailcat address (dialed by the in-process tailcat client +so the same live WireGuard path can be observed and reused for forwarding) +or a "[user@]host[:port]" TCP address for a general (non-tailcat) SSH host, verified against a known_hosts file (--known-hosts) with trust-on-first-use for a host seen for the first time. --jump chains through one or more intermediate TCP hosts first, each @@ -162,6 +165,14 @@ func agentCmd(args []string) error { if err := session.writeControl(0, connected); err != nil { return err } + + pathCtx, cancelPath := context.WithCancel(context.Background()) + defer cancelPath() + if source := session.tailcatPathSource(); source != nil { + go reportTailcatPath(pathCtx, source, agentPathPollInterval, func(msg controlMessage) error { + return session.writeControl(0, msg) + }) + } return <-frameErrCh } @@ -351,16 +362,32 @@ func (a *agentSession) connect(ctx context.Context, opts connectOptions) error { user := "" if last && looksLikeTailcatAddress(hop) { - dial = tailcatDialer(opts.tailcatBin, tailcatClientArgv(opts.key, opts.derpMapURL, opts.verbose, hop, opts.port)) hkCallback = tailcatHostKeyCallback() tcKey, err := tailcatKeyFromName(opts.key) if err != nil { closeClients(chain) - return fmt.Errorf("resolving --key %q for forwarding: %w", opts.key, err) + return fmt.Errorf("resolving --key %q for Tailcat: %w", opts.key, err) + } + tcClient := &tailcat.Client{ + Server: tailcat.Addr(hop), + Key: tcKey, + DERPMapURL: opts.derpMapURL, + Logf: logger.Discard, + } + if opts.verbose { + tcClient.Logf = log.Printf + } + dial, err = tailcatClientDialer(tcClient, opts.port) + if err != nil { + closeClients(chain) + return err } a.tcAddr = tailcat.Addr(hop) a.tcKey = tcKey a.tcDERPMapURL = opts.derpMapURL + a.tcMu.Lock() + a.tcClient = tcClient + a.tcMu.Unlock() } else { var hostPort string user, hostPort = splitUserHost(hop, opts.port) @@ -389,6 +416,9 @@ func (a *agentSession) connect(ctx context.Context, opts connectOptions) error { sc, err := dialSSHClient(ctx, dial, remoteAddr, user, hkCallback, opts.auth) if err != nil { closeClients(chain) + if last && looksLikeTailcatAddress(hop) { + a.closeTailcatClient() + } return fmt.Errorf("connecting to %s: %w", connectTargetForDiagnostics(hop, last && looksLikeTailcatAddress(hop)), err) } chain = append(chain, sc) @@ -421,12 +451,25 @@ func (a *agentSession) closeHops() { // after which closing/clearing the client is bounded. closeClients(a.hops) a.resetSFTPClient(nil) + a.closeTailcatClient() +} + +func (a *agentSession) closeTailcatClient() { a.tcMu.Lock() + defer a.tcMu.Unlock() if a.tcClient != nil { a.tcClient.Close() a.tcClient = nil } - a.tcMu.Unlock() +} + +func (a *agentSession) tailcatPathSource() *tailcatForwardClient { + a.tcMu.Lock() + defer a.tcMu.Unlock() + if a.tcClient == nil { + return nil + } + return &tailcatForwardClient{cl: a.tcClient} } const ( From a8fa3fa255ff74d5c436693406c72d6e97fdeb59 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:29 +0200 Subject: [PATCH 09/28] Expose live Tailcat path and direct probing --- cmd/meowshell/tailcatdial.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go index cd3d4dd..f78089a 100644 --- a/cmd/meowshell/tailcatdial.go +++ b/cmd/meowshell/tailcatdial.go @@ -20,6 +20,35 @@ type tailcatForwardClient struct { cl *tailcat.Client } +func tailcatClientDialer(cl *tailcat.Client, port string) (dialer, error) { + p, err := strconv.ParseUint(port, 10, 16) + if err != nil || p == 0 { + return nil, fmt.Errorf("invalid Tailcat SSH port %q", port) + } + return func(ctx context.Context) (net.Conn, error) { + return cl.DialTCPPort(ctx, uint16(p)) + }, nil +} + +func (c *tailcatForwardClient) PathStatus() (agentPathStatus, bool) { + status, ok := c.cl.PathStatus() + if !ok { + return agentPathStatus{}, false + } + return agentPathStatus{ + direct: status.Direct, + endpoint: status.Endpoint, + relayRegion: status.RelayRegion, + txBytes: status.TxBytes, + rxBytes: status.RxBytes, + }, true +} + +func (c *tailcatForwardClient) ProbePath(ctx context.Context) error { + _, err := c.cl.DiscoPing(ctx) + return err +} + func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { if network != "tcp" { return nil, fmt.Errorf("tailcat forwarding only supports tcp, not %q", network) From 8b9cb150e35c653b827ad8cb565d9c6c3aaa2184 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:33 +0200 Subject: [PATCH 10/28] Actively probe relayed agent paths --- cmd/meowshell/agent_path.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cmd/meowshell/agent_path.go b/cmd/meowshell/agent_path.go index 8113ca8..7424e46 100644 --- a/cmd/meowshell/agent_path.go +++ b/cmd/meowshell/agent_path.go @@ -19,6 +19,10 @@ type agentPathStatusSource interface { PathStatus() (agentPathStatus, bool) } +type agentPathProber interface { + ProbePath(context.Context) error +} + type agentPathState struct { direct bool via string @@ -69,6 +73,19 @@ func reportTailcatPath( } last = state haveLast = true + + // Tailcat's DiscoPing actively nudges the call-me-maybe endpoint + // exchange. A persistent SSH connection can otherwise remain on its + // bootstrap DERP route for much longer than necessary when there is + // little tunnel traffic. Probe only while relayed; failures are + // diagnostic and never tear down the live SSH session. + if !status.direct { + if prober, ok := source.(agentPathProber); ok { + probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + _ = prober.ProbePath(probeCtx) + cancel() + } + } return true } From 7ca2abc4192ad46bc52c5f2e5648d7f1e7a52d50 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:37 +0200 Subject: [PATCH 11/28] Test relayed-to-direct path probing --- cmd/meowshell/agent_path_test.go | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cmd/meowshell/agent_path_test.go b/cmd/meowshell/agent_path_test.go index 3a3c1d0..6545ce9 100644 --- a/cmd/meowshell/agent_path_test.go +++ b/cmd/meowshell/agent_path_test.go @@ -97,3 +97,56 @@ func TestReportTailcatPathIgnoresUnknownAndEmitsOnlyPathChanges(t *testing.T) { t.Fatalf("second path = direct %#v via %q, want direct with no relay", got[1].Direct, got[1].Via) } } + +type probingPathSource struct { + mu sync.Mutex + direct bool + probes int +} + +func (s *probingPathSource) PathStatus() (agentPathStatus, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return agentPathStatus{ + direct: s.direct, + relayRegion: "ci", + }, true +} + +func (s *probingPathSource) ProbePath(context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + s.probes++ + s.direct = true + return nil +} + +func TestReportTailcatPathProbesRelayedConnectionAndEmitsDirectUpgrade(t *testing.T) { + source := &probingPathSource{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var got []controlMessage + reportTailcatPath(ctx, source, time.Millisecond, func(msg controlMessage) error { + got = append(got, msg) + if len(got) == 2 { + cancel() + } + return nil + }) + + if len(got) != 2 { + t.Fatalf("got %d path updates, want 2", len(got)) + } + if got[0].Direct == nil || *got[0].Direct || got[0].Via != "ci" { + t.Fatalf("first update = %#v, want relayed via ci", got[0]) + } + if got[1].Direct == nil || !*got[1].Direct || got[1].Via != "" { + t.Fatalf("second update = %#v, want direct", got[1]) + } + source.mu.Lock() + defer source.mu.Unlock() + if source.probes == 0 { + t.Fatal("relayed path was never actively probed") + } +} From 9757f8a5e2bbc8155887c86cbecf00605337b6e1 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:15:51 +0200 Subject: [PATCH 12/28] Decouple host builds from patched Tailcat API --- cmd/meowshell/tailcatdial.go | 63 ++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go index f78089a..b72ab30 100644 --- a/cmd/meowshell/tailcatdial.go +++ b/cmd/meowshell/tailcatdial.go @@ -9,6 +9,7 @@ import ( "net/netip" "os" "path/filepath" + "reflect" "strconv" "strings" @@ -31,19 +32,67 @@ func tailcatClientDialer(cl *tailcat.Client, port string) (dialer, error) { } func (c *tailcatForwardClient) PathStatus() (agentPathStatus, bool) { - status, ok := c.cl.PathStatus() - if !ok { + // PathStatus is a small API we add to the pinned Tailcat source in + // build.sh. Normal vet/unit-test workflows intentionally compile against + // the pristine upstream checkout before that patch is applied, so keep the + // optional API behind reflection here. Release binaries are built only + // after live-path-status.patch has been applied and therefore expose it. + method := reflect.ValueOf(c.cl).MethodByName("PathStatus") + if !method.IsValid() || method.Type().NumIn() != 0 || method.Type().NumOut() != 2 { return agentPathStatus{}, false } + out := method.Call(nil) + if out[1].Kind() != reflect.Bool || !out[1].Bool() { + return agentPathStatus{}, false + } + return decodeTailcatPathStatus(out[0]) +} + +func decodeTailcatPathStatus(value reflect.Value) (agentPathStatus, bool) { + for value.IsValid() && (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) { + if value.IsNil() { + return agentPathStatus{}, false + } + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return agentPathStatus{}, false + } + + direct := value.FieldByName("Direct") + endpoint := value.FieldByName("Endpoint") + relayRegion := value.FieldByName("RelayRegion") + txBytes := value.FieldByName("TxBytes") + rxBytes := value.FieldByName("RxBytes") + if direct.Kind() != reflect.Bool || + endpoint.Kind() != reflect.String || + relayRegion.Kind() != reflect.String || + !isReflectInt(txBytes) || + !isReflectInt(rxBytes) { + return agentPathStatus{}, false + } + return agentPathStatus{ - direct: status.Direct, - endpoint: status.Endpoint, - relayRegion: status.RelayRegion, - txBytes: status.TxBytes, - rxBytes: status.RxBytes, + direct: direct.Bool(), + endpoint: endpoint.String(), + relayRegion: relayRegion.String(), + txBytes: txBytes.Int(), + rxBytes: rxBytes.Int(), }, true } +func isReflectInt(value reflect.Value) bool { + if !value.IsValid() { + return false + } + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + default: + return false + } +} + func (c *tailcatForwardClient) ProbePath(ctx context.Context) error { _, err := c.cl.DiscoPing(ctx) return err From 839d71745b163cd7abcece10aeccb867a0ab502a Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:15:56 +0200 Subject: [PATCH 13/28] Test optional Tailcat path decoding --- cmd/meowshell/tailcatdial_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmd/meowshell/tailcatdial_test.go b/cmd/meowshell/tailcatdial_test.go index c9089d5..e894d4a 100644 --- a/cmd/meowshell/tailcatdial_test.go +++ b/cmd/meowshell/tailcatdial_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "runtime" "testing" @@ -88,3 +89,33 @@ func TestTailcatKeyFromNameRejectsOversizedKeyFile(t *testing.T) { t.Fatal("oversized key file was accepted") } } + +func TestDecodeTailcatPathStatus(t *testing.T) { + type patchedPathStatus struct { + Direct bool + Endpoint string + RelayRegion string + TxBytes int64 + RxBytes int64 + } + + got, ok := decodeTailcatPathStatus(reflect.ValueOf(patchedPathStatus{ + Direct: true, + Endpoint: "192.0.2.10:41641", + RelayRegion: "", + TxBytes: 123, + RxBytes: 456, + })) + if !ok { + t.Fatal("patched Tailcat path status was not decoded") + } + if !got.direct || got.endpoint != "192.0.2.10:41641" || got.relayRegion != "" || got.txBytes != 123 || got.rxBytes != 456 { + t.Fatalf("decoded path = %#v", got) + } +} + +func TestDecodeTailcatPathStatusRejectsUnexpectedShape(t *testing.T) { + if _, ok := decodeTailcatPathStatus(reflect.ValueOf(struct{ Direct bool }{Direct: true})); ok { + t.Fatal("unexpected PathStatus shape was accepted") + } +} From ff07e5184495351497bd0119efc1d2b315cb81b9 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:16:03 +0200 Subject: [PATCH 14/28] Document pristine Tailcat host-test contract --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c49065..7a5cea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,11 @@ jobs: # through a tailcat destination dials via tailcat.Client, the same # way tailcat's own "forward"/"socks" subcommands do), so go vet/go # test need that directory to exist before either can even resolve - # imports. A plain, unpatched clone is enough here: the two patches - # below only change Android-specific networking behavior, irrelevant - # to vetting/testing meowshell on this runner's own host platform. - # ./build.sh (further down) later reuses and patches this same - # checkout for the real cross-compiled build. + # imports. A plain, unpatched clone is intentional here. Optional + # live-path telemetry is discovered dynamically by meowshell so this + # host vet/test pass remains valid against pristine upstream Tailcat. + # ./build.sh (further down) later reuses this checkout, applies the + # reviewed live-path and platform patches, and builds release binaries. - name: Fetch tailcat source (for go.mod's replace directive) env: SRC_REF: ${{ inputs.tailcat_ref }} From 38e2372a4da3dc8726f325ec242e6a2ffeef3e45 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:34:36 +0200 Subject: [PATCH 15/28] Use statically checked Tailcat path API --- cmd/meowshell/tailcatdial.go | 63 ++++-------------------------------- 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go index b72ab30..f78089a 100644 --- a/cmd/meowshell/tailcatdial.go +++ b/cmd/meowshell/tailcatdial.go @@ -9,7 +9,6 @@ import ( "net/netip" "os" "path/filepath" - "reflect" "strconv" "strings" @@ -32,67 +31,19 @@ func tailcatClientDialer(cl *tailcat.Client, port string) (dialer, error) { } func (c *tailcatForwardClient) PathStatus() (agentPathStatus, bool) { - // PathStatus is a small API we add to the pinned Tailcat source in - // build.sh. Normal vet/unit-test workflows intentionally compile against - // the pristine upstream checkout before that patch is applied, so keep the - // optional API behind reflection here. Release binaries are built only - // after live-path-status.patch has been applied and therefore expose it. - method := reflect.ValueOf(c.cl).MethodByName("PathStatus") - if !method.IsValid() || method.Type().NumIn() != 0 || method.Type().NumOut() != 2 { + status, ok := c.cl.PathStatus() + if !ok { return agentPathStatus{}, false } - out := method.Call(nil) - if out[1].Kind() != reflect.Bool || !out[1].Bool() { - return agentPathStatus{}, false - } - return decodeTailcatPathStatus(out[0]) -} - -func decodeTailcatPathStatus(value reflect.Value) (agentPathStatus, bool) { - for value.IsValid() && (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) { - if value.IsNil() { - return agentPathStatus{}, false - } - value = value.Elem() - } - if !value.IsValid() || value.Kind() != reflect.Struct { - return agentPathStatus{}, false - } - - direct := value.FieldByName("Direct") - endpoint := value.FieldByName("Endpoint") - relayRegion := value.FieldByName("RelayRegion") - txBytes := value.FieldByName("TxBytes") - rxBytes := value.FieldByName("RxBytes") - if direct.Kind() != reflect.Bool || - endpoint.Kind() != reflect.String || - relayRegion.Kind() != reflect.String || - !isReflectInt(txBytes) || - !isReflectInt(rxBytes) { - return agentPathStatus{}, false - } - return agentPathStatus{ - direct: direct.Bool(), - endpoint: endpoint.String(), - relayRegion: relayRegion.String(), - txBytes: txBytes.Int(), - rxBytes: rxBytes.Int(), + direct: status.Direct, + endpoint: status.Endpoint, + relayRegion: status.RelayRegion, + txBytes: status.TxBytes, + rxBytes: status.RxBytes, }, true } -func isReflectInt(value reflect.Value) bool { - if !value.IsValid() { - return false - } - switch value.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return true - default: - return false - } -} - func (c *tailcatForwardClient) ProbePath(ctx context.Context) error { _, err := c.cl.DiscoPing(ctx) return err From 23e0d50eab3c6b0c8845d6523122176b87656306 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:34:44 +0200 Subject: [PATCH 16/28] Remove reflection-only path shape tests --- cmd/meowshell/tailcatdial_test.go | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/cmd/meowshell/tailcatdial_test.go b/cmd/meowshell/tailcatdial_test.go index e894d4a..3a224bd 100644 --- a/cmd/meowshell/tailcatdial_test.go +++ b/cmd/meowshell/tailcatdial_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "reflect" "runtime" "testing" @@ -90,32 +89,3 @@ func TestTailcatKeyFromNameRejectsOversizedKeyFile(t *testing.T) { } } -func TestDecodeTailcatPathStatus(t *testing.T) { - type patchedPathStatus struct { - Direct bool - Endpoint string - RelayRegion string - TxBytes int64 - RxBytes int64 - } - - got, ok := decodeTailcatPathStatus(reflect.ValueOf(patchedPathStatus{ - Direct: true, - Endpoint: "192.0.2.10:41641", - RelayRegion: "", - TxBytes: 123, - RxBytes: 456, - })) - if !ok { - t.Fatal("patched Tailcat path status was not decoded") - } - if !got.direct || got.endpoint != "192.0.2.10:41641" || got.relayRegion != "" || got.txBytes != 123 || got.rxBytes != 456 { - t.Fatalf("decoded path = %#v", got) - } -} - -func TestDecodeTailcatPathStatusRejectsUnexpectedShape(t *testing.T) { - if _, ok := decodeTailcatPathStatus(reflect.ValueOf(struct{ Direct bool }{Direct: true})); ok { - t.Fatal("unexpected PathStatus shape was accepted") - } -} From ffa933ad83a71b93cd64473ddd828bcad6cb536d Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:34:47 +0200 Subject: [PATCH 17/28] Patch live Tailcat path API before host compilation --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a5cea9..8b6c5e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,11 @@ jobs: # through a tailcat destination dials via tailcat.Client, the same # way tailcat's own "forward"/"socks" subcommands do), so go vet/go # test need that directory to exist before either can even resolve - # imports. A plain, unpatched clone is intentional here. Optional - # live-path telemetry is discovered dynamically by meowshell so this - # host vet/test pass remains valid against pristine upstream Tailcat. - # ./build.sh (further down) later reuses this checkout, applies the - # reviewed live-path and platform patches, and builds release binaries. + # imports. Apply the reviewed live-path API patch before compiling + # meowshell: the agent now uses that exact Tailcat Client directly, so + # path reporting is statically type-checked rather than hidden behind + # reflection. ./build.sh later resets this disposable checkout and + # reapplies the full reviewed patch set before release binaries are built. - name: Fetch tailcat source (for go.mod's replace directive) env: SRC_REF: ${{ inputs.tailcat_ref }} @@ -47,6 +47,7 @@ jobs: git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" git -C .tailcat-src checkout --detach FETCH_HEAD + git -C .tailcat-src apply "$GITHUB_WORKSPACE/patches/tailcat/live-path-status.patch" - name: Vet and test meowshell env: @@ -258,6 +259,7 @@ jobs: git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" git -C .tailcat-src checkout --detach FETCH_HEAD + git -C .tailcat-src apply "$GITHUB_WORKSPACE/patches/tailcat/live-path-status.patch" # Some of cmd/meowshell's Windows-specific code (known_hosts DACL/SID # validation, N9) only builds and runs under GOOS=windows, so it never From 7e0f59d6107f2715d93cd375812e51bcfcbebf74 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:50:47 +0200 Subject: [PATCH 18/28] Probe Tailcat paths even before status settles --- cmd/meowshell/agent_path.go | 39 +++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/cmd/meowshell/agent_path.go b/cmd/meowshell/agent_path.go index 7424e46..1f1bc50 100644 --- a/cmd/meowshell/agent_path.go +++ b/cmd/meowshell/agent_path.go @@ -20,7 +20,7 @@ type agentPathStatusSource interface { } type agentPathProber interface { - ProbePath(context.Context) error + ProbePath(context.Context) (agentPathStatus, bool) } type agentPathState struct { @@ -58,11 +58,7 @@ func reportTailcatPath( var last agentPathState haveLast := false - poll := func() bool { - status, ok := source.PathStatus() - if !ok { - return true - } + emitStatus := func(status agentPathStatus) bool { msg := pathControlMessageFromStatus(status) state := agentPathState{direct: *msg.Direct, via: msg.Via} if haveLast && state == last { @@ -73,17 +69,30 @@ func reportTailcatPath( } last = state haveLast = true + return true + } + poll := func() bool { + status, ok := source.PathStatus() + if ok { + if !emitStatus(status) { + return false + } + if status.direct { + return true + } + } // Tailcat's DiscoPing actively nudges the call-me-maybe endpoint - // exchange. A persistent SSH connection can otherwise remain on its - // bootstrap DERP route for much longer than necessary when there is - // little tunnel traffic. Probe only while relayed; failures are - // diagnostic and never tear down the live SSH session. - if !status.direct { - if prober, ok := source.(agentPathProber); ok { - probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - _ = prober.ProbePath(probeCtx) - cancel() + // exchange. Probe while the path is unknown as well as while it is + // relayed: on a freshly started client Status can lag behind the live + // magicsock route, and returning early here used to prevent the very + // probe needed to establish a direct endpoint. + if prober, hasProber := source.(agentPathProber); hasProber { + probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + probed, probeOK := prober.ProbePath(probeCtx) + cancel() + if probeOK { + return emitStatus(probed) } } return true From 2eea4c70b008f42ac6bc5f9ff34f95d20cdb281a Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:50:53 +0200 Subject: [PATCH 19/28] Use live disco ping result for path state --- cmd/meowshell/tailcatdial.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go index f78089a..ebd013f 100644 --- a/cmd/meowshell/tailcatdial.go +++ b/cmd/meowshell/tailcatdial.go @@ -44,9 +44,22 @@ func (c *tailcatForwardClient) PathStatus() (agentPathStatus, bool) { }, true } -func (c *tailcatForwardClient) ProbePath(ctx context.Context) error { - _, err := c.cl.DiscoPing(ctx) - return err +func (c *tailcatForwardClient) ProbePath(ctx context.Context) (agentPathStatus, bool) { + result, err := c.cl.DiscoPing(ctx) + if err != nil || result == nil { + return agentPathStatus{}, false + } + status := agentPathStatus{ + direct: result.Endpoint != "", + endpoint: result.Endpoint, + } + if !status.direct { + status.relayRegion = result.DERPRegionCode + if status.relayRegion == "" { + status.relayRegion = fmt.Sprint(result.DERPRegionID) + } + } + return status, true } func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { From 3eb45096094caae684341e307959049fcdbbf074 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:50:56 +0200 Subject: [PATCH 20/28] Cover unknown-path direct probing --- cmd/meowshell/agent_path_test.go | 45 ++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/cmd/meowshell/agent_path_test.go b/cmd/meowshell/agent_path_test.go index 6545ce9..e369899 100644 --- a/cmd/meowshell/agent_path_test.go +++ b/cmd/meowshell/agent_path_test.go @@ -113,12 +113,12 @@ func (s *probingPathSource) PathStatus() (agentPathStatus, bool) { }, true } -func (s *probingPathSource) ProbePath(context.Context) error { +func (s *probingPathSource) ProbePath(context.Context) (agentPathStatus, bool) { s.mu.Lock() defer s.mu.Unlock() s.probes++ s.direct = true - return nil + return agentPathStatus{direct: true, endpoint: "203.0.113.7:41641"}, true } func TestReportTailcatPathProbesRelayedConnectionAndEmitsDirectUpgrade(t *testing.T) { @@ -150,3 +150,44 @@ func TestReportTailcatPathProbesRelayedConnectionAndEmitsDirectUpgrade(t *testin t.Fatal("relayed path was never actively probed") } } + +type initiallyUnknownProbingPathSource struct { + mu sync.Mutex + probes int +} + +func (s *initiallyUnknownProbingPathSource) PathStatus() (agentPathStatus, bool) { + return agentPathStatus{}, false +} + +func (s *initiallyUnknownProbingPathSource) ProbePath(context.Context) (agentPathStatus, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.probes++ + return agentPathStatus{direct: true, endpoint: "127.0.0.1:41641"}, true +} + +func TestReportTailcatPathProbesUnknownConnectionAndUsesLivePingResult(t *testing.T) { + source := &initiallyUnknownProbingPathSource{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var got []controlMessage + reportTailcatPath(ctx, source, time.Millisecond, func(msg controlMessage) error { + got = append(got, msg) + cancel() + return nil + }) + + if len(got) != 1 { + t.Fatalf("got %d path updates, want 1", len(got)) + } + if got[0].Direct == nil || !*got[0].Direct || got[0].Via != "" { + t.Fatalf("path update = %#v, want direct probe result", got[0]) + } + source.mu.Lock() + defer source.mu.Unlock() + if source.probes == 0 { + t.Fatal("unknown path was never actively probed") + } +} From 49da141e5743f06da3e9760dfec00ed6472d3235 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:16:13 +0200 Subject: [PATCH 21/28] Make E2E helpers tolerate live path notifications --- cmd/meowshell/agent_e2e_test.go | 40 +++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go index c879eeb..9cb7560 100644 --- a/cmd/meowshell/agent_e2e_test.go +++ b/cmd/meowshell/agent_e2e_test.go @@ -175,7 +175,7 @@ func expectConnected(t *testing.T, r *bufio.Reader) { } } -func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 { +func expectChannelOpenedMessage(t *testing.T, r *bufio.Reader) (frame, controlMessage) { t.Helper() for { f, err := readFrameWithDeadline(t, r) @@ -191,10 +191,46 @@ func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 { } switch msg.Msg { case "channel_opened": - return f.ChannelID + return f, msg case "error": t.Fatalf("agent returned an error opening the channel: %s: %s", msg.Code, msg.Message) + case "path": + // Live path telemetry is an asynchronous connection-level + // notification. It may legally arrive between a request and that + // request's reply, so request/response E2E helpers must not consume + // it as the synchronous response they are waiting for. + continue + } + } +} + +func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 { + t.Helper() + f, _ := expectChannelOpenedMessage(t, r) + return f.ChannelID +} + +func expectRequestReply(t *testing.T, r *bufio.Reader, requestID string) controlMessage { + t.Helper() + for { + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading reply for request %q: %v", requestID, err) + } + if f.Type != frameTypeControl { + continue + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + if msg.Msg == "path" { + continue + } + if msg.RequestID != requestID { + t.Fatalf("reply RequestID = %q, want %q (msg=%+v)", msg.RequestID, requestID, msg) } + return msg } } From ce5a084f09ccc37f43fd5f26b95c2d451bfbf911 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:16:18 +0200 Subject: [PATCH 22/28] Correlate SFTP replies past path telemetry --- cmd/meowshell/agent_sftp_e2e_test.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go index 9406c50..95e28ed 100644 --- a/cmd/meowshell/agent_sftp_e2e_test.go +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -114,8 +114,7 @@ func TestAgentSFTPEndToEnd(t *testing.T) { uploadViaAgent(t, stdin, out, "big.bin", payload, false, 0, 0) send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_download", Path: "big.bin"}) - f := mustReadFrame(t, out) - opened := decodeControl(t, f) + f, opened := expectChannelOpenedMessage(t, out) if opened.Msg != "channel_opened" || opened.Size != int64(len(payload)) { t.Fatalf("channel_opened = %+v, want Size %d", opened, len(payload)) } @@ -193,11 +192,7 @@ func sftpOp(t *testing.T, stdin interface { req.Msg = "sftp_op" req.RequestID = fmt.Sprintf("op%d", time.Now().UnixNano()) send(t, stdin, 0, req) - f := mustReadFrame(t, out) - msg := decodeControl(t, f) - if msg.RequestID != req.RequestID { - t.Fatalf("sftp_op %s: reply RequestID = %q, want %q (msg=%+v)", req.Op, msg.RequestID, req.RequestID, msg) - } + msg := expectRequestReply(t, out, req.RequestID) if msg.Msg == "error" { t.Fatalf("sftp_op %s %s failed: %s: %s", req.Op, req.Path, msg.Code, msg.Message) } @@ -211,8 +206,7 @@ func trySFTPOp(t *testing.T, stdin interface { req.Msg = "sftp_op" req.RequestID = fmt.Sprintf("op%d", time.Now().UnixNano()) send(t, stdin, 0, req) - f := mustReadFrame(t, out) - msg := decodeControl(t, f) + msg := expectRequestReply(t, out, req.RequestID) if msg.Msg == "error" { return msg.Code } From 3855e98d2c069f31854f5580f53dd0844b1abb45 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:16:21 +0200 Subject: [PATCH 23/28] Ignore path telemetry while opening forwards --- cmd/meowshell/agent_tailcat_forward_e2e_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go index 58aab63..7437ea3 100644 --- a/cmd/meowshell/agent_tailcat_forward_e2e_test.go +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -77,8 +77,7 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) { Msg: "open_channel", Kind: "forward_local", ListenAddr: "127.0.0.1:0", RemoteAddr: "localhost:" + backendPort, }) - f := mustReadFrame(t, out) - opened := decodeControl(t, f) + _, opened := expectChannelOpenedMessage(t, out) if opened.Msg != "channel_opened" || opened.BoundAddr == "" { t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) } From 8d20e33e57523d33fb6f8c4cc43bfefeb4efc4e7 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:18 +0200 Subject: [PATCH 24/28] Sync merged Go dependency updates --- go.mod | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 02fb52c..2d66dac 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/creack/pty v1.1.24 github.com/pkg/sftp v1.13.11 github.com/tailscale/tailcat v0.6.0 - github.com/unixshells/mosh-go v0.0.0-20260405220648-8dca5c67ec8e - golang.org/x/crypto v0.56.0 - golang.org/x/net v0.58.0 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 + github.com/unixshells/mosh-go v0.5.2 + golang.org/x/crypto v0.57.0 + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 tailscale.com v1.103.0-pre.0.20260904030409-31d8badb3bfb ) @@ -68,8 +68,8 @@ require ( go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect From f6dd488847bfbda788d5c69d107353fe4b1d5458 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:29 +0200 Subject: [PATCH 25/28] Sync merged Go dependency checksums --- go.sum | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/go.sum b/go.sum index bd403a8..2f44eab 100644 --- a/go.sum +++ b/go.sum @@ -209,6 +209,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/unixshells/mosh-go v0.0.0-20260405220648-8dca5c67ec8e h1:2gigof4QkpvcvTawKgTJTbm251hA01sN4JFuvxdl3GE= github.com/unixshells/mosh-go v0.0.0-20260405220648-8dca5c67ec8e/go.mod h1:4/y1LRlSJZ5GFeunI+aXqpqc2cvPtIWRgfCfxnjgz0Q= +github.com/unixshells/mosh-go v0.5.2 h1:UqEAdkoJi+YRBP6Rq5lyuD9ifkYphwZfpaDCRX6HxSE= +github.com/unixshells/mosh-go v0.5.2/go.mod h1:4/y1LRlSJZ5GFeunI+aXqpqc2cvPtIWRgfCfxnjgz0Q= github.com/unixshells/vt-go v0.1.0 h1:HWILcExV8MHc6o2YqIRMIM/KrjH31kPzZrlvLS9BG6Y= github.com/unixshells/vt-go v0.1.0/go.mod h1:dZpOXxVyO7LG1uxkVMixX7HwUf2ZbUkEKhtHHJTTIBI= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -223,27 +225,41 @@ go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= From ca0a62c68c3a88e68f34e063f7a98a70ae04dfe9 Mon Sep 17 00:00:00 2001 From: Stefan van der Merwe <44154511+Sniperlyf3@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:32 +0200 Subject: [PATCH 26/28] Sync setup-go v7 across E2E CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b6c5e7..019d5dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5 with: go-version: '1.27.1' @@ -243,7 +243,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5 with: go-version: '1.27.1' @@ -334,7 +334,7 @@ jobs: with: dotnet-version: "8.0.x" - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5 with: go-version: '1.27.1' From 6677f7f7a480539fbb95a4db91e1aedb50495448 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 19:27:34 +0000 Subject: [PATCH 27/28] cmd/meowshell: register anet interface getter for Android netmon.New This branch's Android probe E2E job (dotnet-android-e2e) fails with PROBE_SSH_FAIL, meowshell agent error connecting: connecting to tailcat destination: netmon.New: route ip+net: netlinkrib: permission denied. This PR switched the primary Tailcat SSH transport in agent.go from shelling out to the separate tailcat binary to an in-process tailcat.Client (tailcatdial.go's tailcatClientDialer, dialed from agent.connect's direct-address path and from forwardClient). That client's initLocked calls netmon.New() unconditionally, and netmon.New() opens a netlink socket and calls bind() on it, which Android's SELinux policy denies to every app in the untrusted_app domain. patches/tailcat/android-netmon-interface-getter.patch already works around this for the tailcat subprocess by registering an anet-backed netmon.RegisterInterfaceGetter in cmd/tailcat, but that patch only touches the tailcat module -- it never applied to the meowshell binary, which had no such registration and, until this branch, never called netmon.New() itself either. Now that the SSH transport runs netmon.New() inside the meowshell process too, meowshell needs its own copy of the same workaround. Add cmd/meowshell/netmon_android.go (//go:build android), mirroring the tailcat patch's RegisterInterfaceGetter + AltAddrs handling, and add github.com/wlynxg/anet v0.0.5 (the same version CLAUDE.md pins for .tailcat-src) to the root go.mod/go.sum so it resolves outside the tailcat replace directory. build.sh's ldflags already pass -checklinkname=0 for every android build target regardless of binary, so no build.sh change is needed for anet's go:linkname use to link into meowshell too. Verified: go vet ./... and go test ./... pass against a freshly bootstrapped .tailcat-src (patches applied per CLAUDE.md); the only failure is the pre-existing, environment-specific TestListenUnixAllowsRootOwnedStickyTmpStyleParent (reproduces identically on this branch before this commit -- this sandbox's /tmp does not honor a sticky-bit chmod). Ran the agent E2E suite against binaries just built by ./build.sh (156 passed, 0 skipped, same one pre-existing failure), confirming TestAgentForwardsThroughTailcatDestination (the in-process tailcat.Client path this fix targets) still passes on linux/amd64. ./build.sh itself built tailcat+meowshell for every linux/windows target with exit 0; android targets skipped for lack of an NDK in this sandbox (goenv's designed behavior, matching what CI's "Locate the pinned NDK" step exists to avoid). Could not do a full cgo cross-compile for android here (no NDK, no network budget to fetch one): got as far as confirming cmd/meowshell/netmon_android.go's imports resolve and gofmt is clean, and that its RegisterInterfaceGetter call and Interface{Interface, AltAddrs} literal match the exact tailscale.com/net/netmon and github.com/wlynxg/anet signatures already vetted by the reviewed tailcat patch this mirrors; the android build itself needs the pinned NDK CI installs, which is the one part of this fix that could not be verified end-to-end in this sandbox. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WM5HyhwRLKBDyHR6qitkk --- cmd/meowshell/netmon_android.go | 57 +++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 ++ 3 files changed, 60 insertions(+) create mode 100644 cmd/meowshell/netmon_android.go diff --git a/cmd/meowshell/netmon_android.go b/cmd/meowshell/netmon_android.go new file mode 100644 index 0000000..d73aad3 --- /dev/null +++ b/cmd/meowshell/netmon_android.go @@ -0,0 +1,57 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build android + +package main + +import ( + "github.com/wlynxg/anet" + "tailscale.com/net/netmon" +) + +// Go's net.Interfaces() and net.Interface.Addrs() open a netlink socket and +// call bind() on it explicitly; Android's SELinux policy denies that bind +// operation on a netlink_route_socket to every app in the untrusted_app +// domain. tailcat.Client.initLocked calls netmon.New() unconditionally +// before it can dial anything, so with no working interface getter that +// call fails outright with "netmon.New: route ip+net: netlinkrib: +// permission denied" -- the exact error PROBE_SSH_FAIL reported once this +// repo started dialing Tailcat addresses this way. +// +// Before the in-process tailcat.Client added in this branch, every Tailcat +// SSH connection went through the separate tailcat subprocess, and +// cmd/tailcat/netmon_android.go (added by +// patches/tailcat/android-netmon-interface-getter.patch) already registered +// this same workaround there -- so netmon.New() never ran unpatched inside +// an Android process. Forwarding's tailcatClientDialer and agent.connect's +// direct-address path now call netmon.New() in *this* binary instead, and +// that patch only touches the tailcat module, not meowshell's, so the +// meowshell binary needs its own copy of the registration or every direct +// Tailcat connection on Android regresses back to the permission denial. +// +// anet reimplements the same netlink RIB queries the stdlib does, but never +// binds the socket explicitly -- it lets the kernel's implicit +// autobind-on-send handle that instead, which Android's policy does not +// deny -- so it returns real, non-empty interface data where the stdlib +// call fails outright. +func init() { + netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) { + ifs, err := anet.Interfaces() + if err != nil { + return nil, err + } + ret := make([]netmon.Interface, len(ifs)) + for i := range ifs { + // AltAddrs, not a second RegisterInterfaceGetter-style hook: + // netmon.Interface.Addrs() only consults i.Interface.Addrs() + // (the same netlink-based stdlib call) when AltAddrs is nil, + // so leaving it unset here would hit the identical permission + // denial one level down, per interface, right after fixing the + // enumeration itself. + addrs, _ := anet.InterfaceAddrsByInterface(&ifs[i]) + ret[i] = netmon.Interface{Interface: &ifs[i], AltAddrs: addrs} + } + return ret, nil + }) +} diff --git a/go.mod b/go.mod index 2d66dac..fb2f25c 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20260904023712-e855235c55a2 // indirect github.com/u-root/u-root v0.14.0 // indirect github.com/unixshells/vt-go v0.1.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect diff --git a/go.sum b/go.sum index 2f44eab..f2cdcb6 100644 --- a/go.sum +++ b/go.sum @@ -215,6 +215,8 @@ github.com/unixshells/vt-go v0.1.0 h1:HWILcExV8MHc6o2YqIRMIM/KrjH31kPzZrlvLS9BG6 github.com/unixshells/vt-go v0.1.0/go.mod h1:dZpOXxVyO7LG1uxkVMixX7HwUf2ZbUkEKhtHHJTTIBI= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 16bbb6bcc223e5f561c126682395fa7704c40aba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 19:48:43 +0000 Subject: [PATCH 28/28] cmd/meowshell: fix forward_socks race against live path telemetry TestAgentForwardsThroughTailcatDestination/forward_socks read the channel_opened reply with a raw mustReadFrame+decodeControl instead of expectChannelOpenedMessage. agentCmd's reportTailcatPath goroutine (added by this PR) writes asynchronous "path" control frames on the same stream while the session runs, so that raw read could consume a path notification instead of the reply it was waiting for -- exactly what CI saw: opened.Msg == "path" with a populated Direct field and an empty BoundAddr. forward_local, right above it in the same test, was already fixed to use expectChannelOpenedMessage and never flaked. Fix: forward_socks now goes through expectChannelOpenedMessage too, which loops past "path" frames until channel_opened (or "error") arrives, with the existing readFrameWithDeadline timeout as backstop. Audited every other raw mustReadFrame/decodeControl use in this package for the same gap. All of them are safe already, for reasons worth recording rather than re-discovering later: - agent_auth_e2e_test.go, agent_security_e2e_test.go, agent_forward_e2e_test.go, agent_tcp_e2e_test.go: connect through startAgent/startTestSSHServer, a plain TCP "testuser@host:port" destination. tailcatPathSource() (agent.go) only returns non-nil when session.tcClient is set, which a TCP destination never does, so reportTailcatPath is never started for these -- no path frames can appear on their streams at all. - agent_sftp_e2e_test.go does connect through a tailcat address, but its raw reads are either filtered by ChannelID first (a path frame rides channel 0, so a loop waiting on a non-zero sftp channel skips it as a mismatched ChannelID before ever decoding it) or go through expectChannelOpenedMessage/expectRequestReply, both of which already skip Msg=="path". Verified against the real binaries (go build per CLAUDE.md; go1.27.1): - Reproduced first: with the bug still in place, `go test ./cmd/meowshell/ -run TestAgentForwardsThroughTailcatDestination -count=50` failed forward_socks 11/50 times (22%), each with the same Msg:path / empty BoundAddr signature as the CI log. - After the fix: the same command at -count=200 passed 200/200. - Full suite: `go vet ./...` clean; `go test ./...` passes except TestListenUnixAllowsRootOwnedStickyTmpStyleParent, which also fails on unmodified HEAD (6677f7f) in this sandbox because it runs as root -- a pre-existing, unrelated environment artifact, not something this change touches. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013WM5HyhwRLKBDyHR6qitkk --- cmd/meowshell/agent_tailcat_forward_e2e_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go index 7437ea3..1f3ef17 100644 --- a/cmd/meowshell/agent_tailcat_forward_e2e_test.go +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -99,8 +99,14 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) { t.Run("forward_socks", func(t *testing.T) { send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_socks", ListenAddr: "127.0.0.1:0"}) - f := mustReadFrame(t, out) - opened := decodeControl(t, f) + // Use the path-telemetry-aware helper, not a raw mustReadFrame: this + // server has a live path reporter goroutine (TS_DEBUG_TAILCAT_LOCAL_DERP + // above), which can interleave an asynchronous "path" control message + // between this request and its channel_opened reply. forward_local + // already goes through expectChannelOpenedMessage for the same reason; + // this subtest used to read the raw next frame instead and flaked + // whenever a path notification won the race. + _, opened := expectChannelOpenedMessage(t, out) if opened.Msg != "channel_opened" || opened.BoundAddr == "" { t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) }