Repository navigation
[Bug]: After re-pairing, an environment's other routes keep the expired login, and removing a route deletes the new one #17014
Description
Activity
Note
Grok responding on behalf of Julius.
Thanks for the detailed write-up. I checked it against
main(14fe0158ed) by reading the code; I haven't run the repro on a device.Confirmed in code
- Credentials are per route. A paired route's id is
bearer:<envId>:<origin>(onboarding.ts#L94-L102, used at L136). The resolver looks up the credential by that id, and a learned route borrows the credential of the route it was learned through (resolver.ts#L150-L161, routes.ts#L311-L333). - Re-pairing only writes the new route's credential.
registerreplaces a route only when it has the same id or the same base URL, and otherwise inserts the new route next to the others (registry.ts#L566-L585, routes.ts#L143-L167). The catalog write stores the credential under the new id only (storageDocument.ts#L136-L160). Nothing updates the other bearer routes. Learned routes the server still reports keep their id, so they keep borrowing from whichever route they were learned through (routes.ts#L268-L274). - Legacy ids aren't rewritten. The legacy mobile migration still writes
bearer:<envId>(migration.ts#L67), and feat(clients): reach one environment over several routes #15467 changed onboarding away from that form. I found no code that renames old ids. One caveat: re-pairing at the same base URL does replace a legacy route, becausefindRouteToSameAddresscompares addresses, not ids. - Removing a route deletes its credential.
removeRoute→routesAfterRemovingdrops the route and any learned routes that borrow from it (registry.ts#L946-L968, routes.ts#L340-L361).setRoutesInCatalogthen deletes the dropped routes' credentials (storageDocument.ts#L77-L133). So in your reduced repro (pair over LAN, revoke, re-pair over Tailscale, remove the Tailscale route), only the LAN route is left, still holding the revoked token. - Transient errors win over blocked ones.
connectOverRoutesreturnstransient ?? blocked, and it tries routes that failed the 2.5 s check last (driver.ts#L78-L135). A timeout on an unreachable LAN route is therefore reported instead of the 401 from Tailscale. The doc comment says this is deliberate, so the supervisor keeps retrying a route that may come back. - Every 401 shows the same message.
EnvironmentAuthInvalidErroralways maps to "The environment credential is invalid." (errors.ts#L121-L126). - Blocked has no re-pair action. On a
ConnectionBlockedError, the supervisor goes toblockedand waits for a signal (supervisor.ts#L928-L944). The presentation layer turns that into anerrorphase that carries the message (presentation.ts#L51-L56). The shared mobile notice only offers Retry now (EnvironmentConnectionNotice.tsx#L88-L96). I didn't check every mobile screen.
One correction
The client isn't dropping "Session token expired." That text is the server's log annotation (EnvironmentAuth.ts#L695-L705, SessionStore.ts#L105-L116). The 401 body only carries
reason: missing_credential | invalid_credential(environmentHttp.ts#L98-L101, http.ts#L96-L112). Showing "expired on " would need a contract change as well as a client change.Not verified
- I couldn't reconstruct from code exactly how your Tailscale route ended up holding the September token. If it was a route learned through the legacy
bearer:<envId>LAN route, re-pairing at the same LAN URL should have replaced that route and deleted its credential. The learned route would then point at a missing credential and fail with "Connection credential … is unavailable", not send the old token. Re-pairing doesn't cascade to learned routes the wayroutesAfterRemovingdoes. Can you say whether the Tailscale route was one you added yourself, or one the app learned? And was the September LAN pairing at exactly the same URL as the October one?
Possible fixes (untested sketches)
- After a bearer pairing, write the new token to every direct bearer route of that environment. Another option is to key direct-route credentials per environment instead of per route, and keep relay and SSH separate.
- When re-pairing replaces a route, re-point or drop the learned routes that borrow from it, matching what
routesAfterRemovingalready does. - In
connectOverRoutes, keep retrying while any route failed transiently, but surface an authenticationblockederror from a route that reached the right server. - Add an expired/revoked reason to
EnvironmentAuthInvalidReason, and show a Pair again action on authentication failures.
Workaround: as you found, remove the whole environment on the phone and pair again.
Related: #13055 (fixed 30-day session lifetime, which triggered this; open PR #13092 is titled as making the lifetime configurable), #15802 (re-pairing at the same address leaves a stale entry), #13072 and #8112 (other "credential is invalid / have to re-pair" reports). #5332 is closed. None of the open PRs I checked change this re-pair or removal path.
- Credentials are per route. A paired route's id is
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.via-triageFiled through npx t3 triageFiled through npx t3 triage
on Oct 8, 2026 Thanks, the correction about the expiry reason is fair: it's only logged server-side, so fix 4 needs the contract change you describe.
On your questions:
- I never added a route by hand. I paired by scanning the QR code, and the other routes (Tailscale included) appeared on their own, so they were learned, not added. When the session lapsed, I scanned the QR code again to refresh the credentials. That only refreshed the LAN route; the learned routes kept the old token.
- The host's records agree: the phone only ever redeemed two pairing codes (6 Sep and 7 Oct), both from LAN client IPs on different subnets, so the two pairings were at different base URLs and the second didn't replace the first route. No code was ever redeemed over Tailscale.
From the user side, this is the core problem: scanning the QR code is how the routes came into being, so scanning it again reads as "refresh this machine". Nothing in the UI suggests the routes hold separate credentials, or that a rescan only updates one of them. I'd expect re-pairing to update every direct route of that environment, which is your fixes 1 and 2. Fixes 1–4 all look right to me.
Confirmed workaround: deleting the environment entirely and scanning the QR code again fixed it. It's now connecting over Tailscale. Removing individual routes and rescanning had not.
Area
apps/mobile (shared logic in packages/client-runtime, so web and desktop are probably affected too)
Impact
Blocks work completely
Before submitting
What happened
My iOS app lost access to my Mac (T3 Code Nightly, reached over LAN and Tailscale) and I could not get it back without removing the whole environment.
The server traces from step 6 show that the network was fine and that the phone sent the old, expired login over Tailscale:
The only expired, unrevoked session for this phone in
auth_sessionsis the one from 6 Sep. The Oct 7 session was still valid and was never used after its first 5 minutes.Diagnosis (v0.0.46-nightly.20261007.2787)
Since #15467 (Oct 4), an environment holds several routes, and each route has its own credential. Re-pairing replaces the credential only on the route it pairs through. Every other route keeps the credential it already had, even an expired or revoked one.
bearer:<envId>:<origin>(packages/client-runtime/src/connection/onboarding.ts:100-102). Routes saved before feat(clients): reach one environment over several routes #15467 kept the old idbearer:<envId>(the onboarding line that feat(clients): reach one environment over several routes #15467 changed;apps/mobile/src/connection/migration.ts:67), and nothing migrates them.EnvironmentRegistry.registeradds the new route next to the existing ones. It only replaces a route with the same id or the same address (registry.ts:566-585,routes.ts:146-167). No step copies the new credential onto the environment's other bearer routes, and no step drops credentials that have expired. So a Sept route, or a route learned through it (feat(clients): learn an environment's LAN and tailnet addresses #15468,routes.ts:268-277,routes.ts:315-333), keeps the dead credential indefinitely.removeRoute→routesAfterRemovingdrops the route and deletes its credential (registry.ts:946-968,routes.ts:340-361). Removing the route that holds the new credential therefore leaves only the expired one.connectOverRoutesskips routes that fail the 2.5 s check on the first pass and tries them last, and reportstransient ?? blocked(driver.ts:70-130). An unreachable LAN address's timeout therefore hides the 401 from the Tailscale route, and reordering routes has no visible effect.errors.ts:121-126), discarding the server's reason ("Session token expired."). The supervisor then sits inblockedwith no re-pair prompt (supervisor.ts:927-942).The server accepts a bearer session token on any of its addresses, so nothing on the server side requires one credential per direct route. Learned routes already share a credential.
Steps to reproduce
Revoking a session stands in for waiting 30 days.
Expected behavior
Re-pairing gives the environment a working login on every direct route. An expired or revoked login is never sent again.
Actual behavior
The other routes keep the dead login. Removing the newly paired route leaves the environment permanently on the dead login, with only "The environment credential is invalid." shown. While any route is unreachable, the auth failure is hidden behind a timeout.
Suggested direction
Version or commit
iOS beta app 2.0.0 (build 110). Host: T3 Code Nightly 0.0.46-nightly.20261006.2752 desktop app on macOS. Code references checked against v0.0.46-nightly.20261007.2787.
Environment
macOS 26 host running the server bundled in the desktop app (port 3773), reached over LAN and Tailscale. iPhone on iOS 26 running the TestFlight beta of the app.
Workaround
Remove the whole environment on the phone (not individual routes) and pair again. This clears every route and credential.
Related