# fix\(ssh\): Tailscale SSH check links open in the browser instead of a stuck reconnect · PR \#22146 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22146)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: Unavailable fork

Source branch: r9/f7-h1

Target branch: main

Head commit: f97bc69f0762ade9b55dbcece2faba0f44b527cb

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/22146)

[Commits](https://git.cafe/pingdotgg/t3code/pull/22146/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/22146/diffs?format=markdown)

## Description

---

## Problem

With a Tailscale SSH `"action": "check"` ACL, `ssh` prints an approval link on stderr and holds the session until someone approves it. T3 Code read stderr only after `ssh` exited, so the link never reached the user. On Linux `tailscaled` often cannot open a browser, so the environment sits on "Reconnecting: `<host>` did not respond during connection setup." forever (#13717).

## Change

- `runSshCommand` (`packages/ssh`) reads stderr while `ssh` runs. On Tailscale's `To authenticate, visit: https://login.tailscale.com/a/...` line it reports the link once through a new `onTailscaleCheck` auth option. Only `login.tailscale.com/a/` links match.
- The environment manager passes that hook to the launch and pairing commands. When it fires, the manager records the link for the host and calls a new `openApprovalUrl` method on the SSH prompt service, which the desktop implements with the existing `ElectronShell.openExternal`.
- While that session waits, another `ensureEnvironment` for the host fails at once with "Tailscale SSH needs you to approve the connection to `<host>`: `<link>`" instead of queuing behind it. The client's 15 s setup timeout always fires first, so this retry error is what puts the link in the Connections row. The record clears when the held command ends.
- The held session is not cut short: the desktop IPC call keeps running after the client gives up, until the existing launch timeout (15 min in packaged builds, which install the release archive; 90 s in dev builds, which run the server from the checkout). Approving within that window lets the same session finish, and the next retry reuses its tunnel. If nobody approves in time, that session ends with its link, and the next retry starts a new session and opens the new link.

Size: 76 production lines (+76 −5) in 4 files, 210 with tests. The link crosses three layers (stderr reader, environment manager, desktop opener), and the retry path carries it to the row; each needs a few lines.

Built on main 9aaca71bc6, after #19972. #19972 changed `redactSshErrorOutput` right below the new stderr reader in `command.ts`; this PR leaves that function and its `keepErrorOutputTail` path as they are, and the stderr it collects reaches the same error handling as before.

## Scope and approval

Bug #13717, triaged by @juliusmarminge on 2026-09-25 ([comment](https://github.com/pingdotgg/t3code/issues/13717#issuecomment-5841074206)): "T3 should watch `ssh` stderr while the process is alive, detect `https://login.tailscale.com/…`, and open it with the desktop external-open path", "The same URL needs to show in the connection status", and the 15 s timeout "should not kill `ssh` once that banner is seen". This PR does those three things. It leaves the supervisor's 15 s timeout alone: on main the desktop's `ssh` already outlives it until the launch timeout (90 s in the dev build tested here, host log below; 15 min in packaged builds).

## Verification

Two new tests in `packages/ssh`:
- `command.test.ts`: the banner, split across two stderr reads, comes from an `ssh` that never exits; the link is reported once while it runs.
- `tunnel.test.ts`: a launch held on the banner. The opener gets the link once, a second `ensureEnvironment` fails at once with it, and after approval the first call finishes and a third reuses its tunnel (one launch).

Both fail with main's production files (9aaca71bc6, and again after merging main 05696c636e) and pass on the branch. After that merge, `packages/ssh` 22/22 and `apps/desktop` `src/ssh` 4/4 pass, as do `tsc-rs --noEmit` for both packages and `vp fmt --check` / `vp lint` on the changed files.

New tests on main, then on the branch:

![New tests fail on main and pass on the branch](https://gist.githubusercontent.com/only21mil/27cc55281df962e5f16821502e3dee23/raw/2c4f3b4fc939bdf770cfb9ae227e73556fa61acf/tests-main-vs-branch.png)

Desktop check: Linux dev build under Xvfb, system OpenSSH client, and a scripted SSH host that sends the same two-line banner, holds authentication until the link is approved, then runs the app's commands. I paired `tailnet-box` with checks off, turned checks on, and relaunched the app with main's production files, then with the branch. Browser opens went to a local handler that records the URL and fetches nothing.

Main: every 15 s attempt ends in the generic setup timeout. The link never appears and no browser opens.

![Main: Connections row stuck on did not respond during connection setup](https://gist.githubusercontent.com/only21mil/27cc55281df962e5f16821502e3dee23/raw/2c4f3b4fc939bdf770cfb9ae227e73556fa61acf/main-reconnecting.png)

Branch: the browser opens the link as soon as the banner arrives, and the next retry shows it in the row's tooltip.

![Branch: the Connections tooltip shows the Tailscale approval link](https://gist.githubusercontent.com/only21mil/27cc55281df962e5f16821502e3dee23/raw/2c4f3b4fc939bdf770cfb9ae227e73556fa61acf/branch-link.png)

Branch: the link was approved 60 s after the banner. The held session finished, and the environment showed Connected 7 s later.

![Branch: environment connected after approving the link](https://gist.githubusercontent.com/only21mil/27cc55281df962e5f16821502e3dee23/raw/2c4f3b4fc939bdf770cfb9ae227e73556fa61acf/branch-connected.png)

Host and browser-handler log for both runs:

![Timeline: main holds a new session every 90 s with no browser open; branch opens the link once and connects after approval](https://gist.githubusercontent.com/only21mil/27cc55281df962e5f16821502e3dee23/raw/2c4f3b4fc939bdf770cfb9ae227e73556fa61acf/host-and-browser-log.png)

Not checked:
- A real tailnet with a check-mode ACL. The host here is scripted from the banner quoted in the issue.
- macOS and Windows builds. There the Tailscale app may already open the link itself, so the user could see two tabs.
- A packaged build. The desktop run used a dev build; a packaged build takes the same stderr and retry path but waits up to 15 min for the launch.
- Self-hosted control servers such as Headscale print a different host. This change only matches `login.tailscale.com`.
- The row keeps the prefix every SSH prepare error already has ("Could not prepare the SSH environment: Error invoking remote method…"), so the full link shows in the tooltip. This PR leaves that wording alone.
- After approval, retries keep showing the link until the held command ends. When a packaged build first installs the release archive in that session, this can last a few minutes after approval.
- The tunnel (`-N`) and stop commands do not watch for the banner. The tunnel starts seconds after the launch, inside Tailscale's check period.
- Mobile: SSH environments are desktop-only.

Prior PRs: none found for this issue (open and closed PRs searched for #13717, "tailscale" and "login.tailscale.com").

Production fix, tests, PR description and images written with Claude Opus 5.5 in T3 Code Nightly.

🤖 Generated with Claude Opus 5.5 in T3 Code Nightly



Closes #13717


