Skip to content

SSH environment stuck "Reconnecting" on Linux when Tailscale SSH requires interactive re-auth ("check" mode) #13717

Description

@shaskola

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 underlying ssh connection actually reaches the auth stage and prints:

# Tailscale SSH requires an additional check.
# To authenticate, visit: https://login.tailscale.com/a/xxxxxxxx

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 status shows it active; direct) and plain interactive ssh to 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 ssh command, extracts the login.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@host from a terminal, copy the login.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.)

Activity

  1. juliusmarminge commented on Sep 25, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (ed809f7ad). This is a real desktop SSH bug. Not already fixed. Stable v0.0.42 (719a76ca1, the build in this report) already launches ssh the same way and already abandons connection setup after 15 seconds. The 0.0.43 nightlies 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 on Reconnecting: <host> did not respond during connection setup. The host is up, and a normal interactive ssh prints:

    # Tailscale SSH requires an additional check.
    # To authenticate, visit: https://login.tailscale.com/a/xxxxxxxx
    

    T3 never shows or opens that URL. The workaround in the issue is right: run ssh yourself, open the link, approve it, and the next T3 attempt connects. Removing the check rule also avoids it.

    The Windows browser popup is not T3. There is no login.tailscale.com handling 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 local tailscaled is told to open the URL (control says to open URL …; opening… in tailscale/tailscale#7589). The Windows GUI actually opens it. On Linux, especially tailscaled under 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's ssh is 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}:

    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";

    const setupTimeoutDetail = `${target.label} did not respond during connection setup.${
    target._tag === "RelayConnectionTarget" ? ` ${NETWORK_BLOCKING_HINT}` : ""
    }`;

    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 specific Could not prepare the SSH environment: … error never gets a chance to surface.

    Losing that race interrupts the attempt. The child-process spawner terminates ssh when the owning effect is interrupted, so the held session is killed before anyone can finish the check.

    2. ssh is non-interactive, and stderr is kept until the process exits.

    Desktop SSH, when no password is cached, uses BatchMode=yes because the in-app password prompt service is available:

    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.

    const child = yield* spawner
    .spawn(
    ChildProcess.make(sshCommand, args, {
    env: environment,
    extendEnv: true,
    stdin: {
    stream: stdinStream(input.stdin),
    endOnDone: true,
    },

    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 ssh exits:

    const [stdout, stderr, exitCode] = yield* Effect.all(
    [
    collectProcessOutput(child.stdout),
    collectProcessOutput(child.stderr),
    child.exitCode.pipe(Effect.map(Number)),
    ],
    { concurrency: "unbounded" },

    const exitFailure = Effect.all(
    [collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number))],
    { concurrency: "unbounded" },

    The check banner is written while ssh is 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 after ssh exits 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 next ssh proceeds 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 ssh stderr while the process is alive, detect https://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 kill ssh once 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.42 already has both the 15 second race and the "read stderr only after exit" behavior, so this is not a regression in a later build.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions