Repository navigation
SSH environment stuck "Reconnecting" on Linux when Tailscale SSH requires interactive re-auth ("check" mode) #13717
Description
Activity
Triage
Confirmed on current
main(ed809f7ad). This is a real desktop SSH bug. Not already fixed. Stablev0.0.42(719a76ca1, the build in this report) already launchessshthe same way and already abandons connection setup after 15 seconds. The0.0.43nightlies do not handle the Tailscale check banner either. Updating will not change this.No open issue or PR covers it. #13636 and #11053 are about editor deep links when a host has Tailscale SSH and no local sshd. #10897 only changes the wording of the in-app password / verification-code dialog. Neither path sees this banner.
What you reported. An SSH environment whose Tailscale ACL uses
"action": "check"sits onReconnecting: <host> did not respond during connection setup.The host is up, and a normal interactivesshprints:# Tailscale SSH requires an additional check. # To authenticate, visit: https://login.tailscale.com/a/xxxxxxxxT3 never shows or opens that URL. The workaround in the issue is right: run
sshyourself, open the link, approve it, and the next T3 attempt connects. Removing thecheckrule also avoids it.The Windows browser popup is not T3. There is no
login.tailscale.comhandling anywhere in this repo, and no Windows-only branch that opens a browser for SSH stderr. Tailscale's own client does that. When check mode trips, the localtailscaledis told to open the URL (control says to open URL …; opening…in tailscale/tailscale#7589). The Windows GUI actually opens it. On Linux, especiallytailscaledunder systemd on a Wayland session (Omarchy / Hyprland), that open often never reaches a browser (tailscale/tailscale#8551). A normal terminal still shows the banner. T3'ssshis not a terminal, and T3 never reads the banner, so on Linux there is no second chance.What the code does
1. The row text is the generic setup timeout, not the SSH banner.
The Connections subtitle is
Reconnecting: ${connection.error}:t3code/apps/web/src/components/settings/ConnectionsSettings.tsx
Lines 1451 to 1454 in ed809f7
case "reconnecting": return { text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", tone: "error", That error is produced when connection establishment loses a race with a 15 second sleep:
const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; t3code/packages/client-runtime/src/connection/supervisor.ts
Lines 223 to 225 in ed809f7
const setupTimeoutDetail = `${target.label} did not respond during connection setup.${ target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : "" }`; t3code/packages/client-runtime/src/connection/supervisor.ts
Lines 511 to 535 in ed809f7
Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }), ), ]); if (establishment._tag === "Interrupted") { return { _tag: "Interrupted", established: false, stable: false, resetRetry: establishment.resetRetry, } satisfies AttemptOutcome; } if (establishment._tag === "TimedOut") { return { _tag: "Failure", established: false, stable: false, failure: { error: new ConnectionTransientError({ reason: "timeout", detail: setupTimeoutDetail, }), attemptSpan: Option.none(), }, SSH prepare runs inside that attempt (
resolver→ssh.prepare→ensureSshEnvironment). Tailscale holds the session open until the link is approved, which is longer than 15 seconds, so this timeout always wins. The supervisor then retries forever. The more specificCould not prepare the SSH environment: …error never gets a chance to surface.Losing that race interrupts the attempt. The child-process spawner terminates
sshwhen the owning effect is interrupted, so the held session is killed before anyone can finish the check.2.
sshis non-interactive, and stderr is kept until the process exits.Desktop SSH, when no password is cached, uses
BatchMode=yesbecause the in-app password prompt service is available:t3code/packages/ssh/src/tunnel.ts
Lines 1464 to 1474 in ed809f7
const authOptions = input.authSecret === null ? { batchMode: promptService.isAvailable ? ("yes" as const) : ("no" as const), interactiveAuth: !promptService.isAvailable, } : { authSecret: input.authSecret, batchMode: "no" as const, interactiveAuth: true, }; The command is spawned with stdin closed immediately. The tunnel also passes
-n -N. There is no PTY.t3code/packages/ssh/src/command.ts
Lines 213 to 221 in ed809f7
const child = yield* spawner .spawn( ChildProcess.make(sshCommand, args, { env: environment, extendEnv: true, stdin: { stream: stdinStream(input.stdin), endOnDone: true, }, t3code/packages/ssh/src/tunnel.ts
Lines 1142 to 1162 in ed809f7
const args = [ ...baseSshArgs(input.resolvedTarget, { batchMode: input.authOptions.batchMode ?? "no", }), "-o", "ExitOnForwardFailure=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-n", "-N", "-L", `${input.localPort}:127.0.0.1:${input.remotePort}`, hostSpec, stderr is only accumulated for the failure reported after
sshexits:t3code/packages/ssh/src/command.ts
Lines 241 to 247 in ed809f7
const [stdout, stderr, exitCode] = yield* Effect.all( [ collectProcessOutput(child.stdout), collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, t3code/packages/ssh/src/tunnel.ts
Lines 1221 to 1223 in ed809f7
const exitFailure = Effect.all( [collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, The check banner is written while
sshis still running. Nothing scans it. The 15 second interrupt throws that buffer away, so the URL never becomes the connection error either. The in-app password dialog does not apply: it only runs aftersshexits with a permission-denied style failure (isSshAuthFailure). A Tailscale check is not that. It is a server banner while the session waits.The tunnel's own readiness timeout is 20 seconds (
SSH_READY_TIMEOUT_MS), which is already longer than the supervisor's 15 seconds, so it does not matter here.3. Approving the check is enough for a later attempt.
Tailscale remembers a successful check for the rule's
checkPeriod. The nextsshproceeds without a new prompt. That is why the manual workaround unsticks T3, and why Windows looks like it works: Tailscale opens the browser itself, the user approves, the first T3 attempt may still die at 15 seconds, and the retry connects. On Linux the approval never happens, so every retry hits the same wall.Fix
T3 should watch
sshstderr while the process is alive, detecthttps://login.tailscale.com/…, and open it with the desktop external-open path (shell.openExternal/xdg-open). The same URL needs to show in the connection status, because opening a browser can fail on this session. The 15 second setup timeout should not killsshonce that banner is seen; either hold the attempt until the check finishes or the Tailscale session gives up, or surface the URL and keep retrying only after the user has had a chance to approve. Opening the browser and still tearing the session down at 15 seconds recreates the stuck loop whenever approval takes longer than that.v0.0.42already has both the 15 second race and the "read stderr only after exit" behavior, so this is not a regression in a later build.- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.via-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 25, 2026
On Linux (Omarchy/Arch), an SSH environment configured against a Tailscale SSH host with an ACL
"action": "check"policy gets permanently stuck as "Reconnecting:<host>did not respond during connection setup." The underlyingsshconnection actually reaches the auth stage and prints:but T3Code's SSH launcher never surfaces or opens this URL, so the check can never be completed and the connection times out indefinitely — even though the host is fully reachable over Tailscale (
tailscale statusshows itactive; direct) and plain interactivesshto the host works fine once the link is opened manually.Reported behavior on Windows is that this reauth flow does trigger an automatic browser popup; that does not happen on Linux, leaving affected environments unusable until the user manually replays the raw
sshcommand, extracts thelogin.tailscale.com/a/...link from stderr, and opens it by hand.Expected: T3Code should detect this banner and open the system browser (e.g. via
xdg-open/shell.openExternal) automatically, same as on Windows, or at minimum surface the URL in the UI so the user can click it.Environment: Omarchy (Arch Linux), Hyprland/Wayland, t3code-bin 0.0.42, Tailscale 1.102.3, SSH-launched environment.
Workaround: manually run
ssh user@hostfrom a terminal, copy thelogin.tailscale.com/a/...link from the output, open it in a browser, approve it, then the environment reconnects. (Or, remove the "check" requirement in the tailnet's ACL policy.)