feat: T3 Connect environments switch to local connections automatically - #5463
feat: T3 Connect environments switch to local connections automatically#5463t3dotgg wants to merge 1 commit into
Conversation
Relay-connected environments now discover direct LAN and Tailscale routes to the same server and reconnect through them automatically, with the relay as fallback. - Server: authenticated GET /api/remote-access/endpoints (relay:read) advertises the server's own loopback/LAN/Tailscale endpoints, aware of the configured binding. Tailscale endpoint synthesis hoisted from desktop into @t3tools/tailscale and shared. - Client: while relay-connected, a scoped discovery fiber fetches advertised endpoints through the tunnel, probes direct candidates, verifies the environment id, and stores a per-environment route override. The supervisor replaces the lease without backoff and the relay broker connects through the override using the cached DPoP access token (host-independent; only per-request proofs are URL-bound). A failed direct route clears the override, starts a cooldown to prevent route flapping, and falls back to the relay in the same prepare call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| .slice(0, MAX_PROBED_CANDIDATES); | ||
| } | ||
|
|
||
| interface PromotionCooldown { |
There was a problem hiding this comment.
🟡 Medium connection/promotion.ts:80
reportOverrideFailed stores only one cooldown per environment, so when a second promoted endpoint fails it overwrites the first endpoint's still-active cooldown. If endpoint A fails, discovery promotes endpoint B, and B fails within five minutes, B's cooldown replaces A's — the next selectPromotionCandidates call can immediately re-select endpoint A even though its cooldown has not expired. Environments with multiple flaky advertised routes will retry endpoints before the documented PROMOTION_FAILURE_COOLDOWN_MS expires. Store cooldowns per endpoint (e.g. ReadonlyMap<EnvironmentId, ReadonlyArray<PromotionCooldown>>) so all unexpired cooldowns are retained.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/connection/promotion.ts around line 80:
`reportOverrideFailed` stores only one cooldown per environment, so when a second promoted endpoint fails it overwrites the first endpoint's still-active cooldown. If endpoint A fails, discovery promotes endpoint B, and B fails within five minutes, B's cooldown replaces A's — the next `selectPromotionCandidates` call can immediately re-select endpoint A even though its cooldown has not expired. Environments with multiple flaky advertised routes will retry endpoints before the documented `PROMOTION_FAILURE_COOLDOWN_MS` expires. Store cooldowns per endpoint (e.g. `ReadonlyMap<EnvironmentId, ReadonlyArray<PromotionCooldown>>`) so all unexpired cooldowns are retained.
| const loopbackEndpoint = createAdvertisedEndpoint({ | ||
| provider: SERVER_ENDPOINT_PROVIDER, | ||
| source: "server", | ||
| id: `server-loopback:${input.port}`, | ||
| label: "This machine", | ||
| httpBaseUrl: `http://127.0.0.1:${input.port}`, |
There was a problem hiding this comment.
🟡 Medium remoteAccess/ServerAdvertisedEndpoints.ts:77
resolveServerAdvertisedEndpoints always constructs the loopback endpoint as http://127.0.0.1:..., even when the server binds to an IPv6 loopback address like ::1 or [::1]. Consumers of the advertised endpoints receive an IPv4 URL that the server is not listening on, so the endpoint is unreachable. The function should use the actual configured loopback host (wrapped with formatHostForUrl) to build the loopback httpBaseUrl instead of hardcoding 127.0.0.1.
| const loopbackEndpoint = createAdvertisedEndpoint({ | |
| provider: SERVER_ENDPOINT_PROVIDER, | |
| source: "server", | |
| id: `server-loopback:${input.port}`, | |
| label: "This machine", | |
| httpBaseUrl: `http://127.0.0.1:${input.port}`, | |
| const loopbackEndpoint = createAdvertisedEndpoint({ | |
| provider: SERVER_ENDPOINT_PROVIDER, | |
| source: "server", | |
| id: `server-loopback:${input.port}`, | |
| label: "This machine", | |
| httpBaseUrl: `http://${formatHostForUrl(input.host ?? "127.0.0.1")}:${input.port}`, |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts around lines 77-82:
`resolveServerAdvertisedEndpoints` always constructs the loopback endpoint as `http://127.0.0.1:...`, even when the server binds to an IPv6 loopback address like `::1` or `[::1]`. Consumers of the advertised endpoints receive an IPv4 URL that the server is not listening on, so the endpoint is unreachable. The function should use the actual configured loopback host (wrapped with `formatHostForUrl`) to build the loopback `httpBaseUrl` instead of hardcoding `127.0.0.1`.
| description: "Loopback endpoint for this server.", | ||
| }); | ||
|
|
||
| if (mode === "loopback") { |
There was a problem hiding this comment.
🟡 Medium remoteAccess/ServerAdvertisedEndpoints.ts:88
In the loopback branch, every HTTPS Tailscale Serve endpoint is advertised on the assumption that Serve proxies to 127.0.0.1. When the server binds to an explicit loopback address like ::1 or 127.0.0.2, the listener is not reachable at 127.0.0.1, so the advertised HTTPS route points at a dead endpoint. Clients following it fail to connect, and direct promotion repeatedly probes an unreachable target. Consider filtering the Tailscale HTTPS endpoints to only those whose Serve target matches the actual bound loopback address, or document why this mismatch is acceptable if Serve is expected to bind 127.0.0.1 only.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts around line 88:
In the `loopback` branch, every HTTPS Tailscale Serve endpoint is advertised on the assumption that Serve proxies to `127.0.0.1`. When the server binds to an explicit loopback address like `::1` or `127.0.0.2`, the listener is not reachable at `127.0.0.1`, so the advertised HTTPS route points at a dead endpoint. Clients following it fail to connect, and direct promotion repeatedly probes an unreachable target. Consider filtering the Tailscale HTTPS endpoints to only those whose Serve target matches the actual bound loopback address, or document why this mismatch is acceptable if Serve is expected to bind `127.0.0.1` only.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit eb5f14b. Configure here.
| const next = new Map(current); | ||
| next.set(environmentId, { endpointId: override.endpointId, failedAtEpochMs: now }); | ||
| return next; | ||
| }); |
There was a problem hiding this comment.
Cooldown replaced across endpoints
Medium Severity
Promotion cooldowns are stored as one entry per environment, so a later failure on a different direct candidate overwrites the earlier cooldown. With both LAN and Tailscale advertised, a flaky pair can clear each other's cooldowns and immediately re-promote after each relay fallback, undoing the anti-ping-pong protection.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit eb5f14b. Configure here.
| // answers on the direct address. | ||
| const descriptor = yield* fetchDescriptor(input.endpoint.httpBaseUrl).pipe( | ||
| Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| ); |
There was a problem hiding this comment.
Direct auth timeout blocks fallback
Medium Severity
authorizeDpopDirect probes the descriptor with the default 10s timeout, while the supervisor's whole establish attempt is capped at 15s and the websocket ticket path already uses 3s. A blackholed LAN override can consume most of the budget before relay fallback starts, so the in-attempt fallback often loses the race and the connection attempt times out instead.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit eb5f14b. Configure here.
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. This PR introduces a new automatic local connection promotion feature with significant runtime behavior changes. Multiple unresolved review comments identify bugs in cooldown tracking, IPv6 handling, and timeout logic that should be addressed before merge. You can customize Macroscope's approvability policy. Learn more. |


Problem
Connecting to an environment through T3 Connect always routes through the relay tunnel, even when your phone or laptop is sitting on the same network (or tailnet) as the environment. That means tunnel latency for traffic that could be a LAN hop away.
Solution
Relay connections now promote themselves to a direct connection when one exists, and fall back to the relay when it stops working.
GET /api/remote-access/endpointsroute (relay:readscope), aware of what it is actually bound to. A loopback-only server advertises nothing promotable.environmentIdbefore trusting it.@t3tools/tailscaleso desktop and server advertise identicaltailscale-ip:/tailscale-magicdns:endpoint ids.Browser-hosted HTTPS clients can only promote to HTTPS endpoints (mixed-content rules); that falls out of the probe failing rather than special-cased platform logic. Desktop and mobile can promote to plain LAN HTTP endpoints.
Status
Warning
Theo has not tested this yet. Typechecks, lint, and the focused test suites pass (promotion candidate selection, resolver direct-route + fallback, supervisor lease replacement, server endpoint resolution across binding modes), but no one has verified the end-to-end promotion flow against a real relay-connected environment.
Built by Claude Fable 5 via Claude Code.
🤖 Generated with Claude Code
Note
Medium Risk
Changes connection authorization, supervisor reconnection, and a new authenticated API surface; behavior is heavily unit-tested but end-to-end promotion on real relay setups is noted as unverified.
Overview
Relay-connected environments can now upgrade to a direct route (LAN or private-network/Tailscale) when the server is reachable locally, without re-pairing or another relay bootstrap.
The server exposes
GET /api/remote-access/endpoints(relay:read), returning addresses that match actual bind mode (loopback-only servers omit promotable LAN routes; wildcard bindings enumerate interfaces; Tailscale Serve HTTPS can still appear on loopback). Tailscale advertised-endpoint synthesis moves into@t3tools/tailscalefor shared desktop/server IDs.On the client,
ConnectionPromotionfetches that list over the tunnel, ranks lan ahead of private-network, probes candidates viaenvironmentId, and stores an in-memory override. The relay resolver triesauthorizeDpopDirectwith the cached DPoP token (fresh proofs per origin) before the tunnel; failures clear the override, apply a 5-minute cooldown, and fall back in the same prepare. The supervisor re-discovers every ~3 minutes while relay-connected and reconnects without backoff when a route appears.User and internal docs describe automatic local promotion.
Reviewed by Cursor Bugbot for commit eb5f14b. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Switch T3 Connect environments to direct local connections automatically when available
ConnectionPromotionservice inpromotion.tsthat, while relay-connected, periodically fetches advertised endpoints from the environment, probes candidates, and stores a direct-route override with cooldown management after failures.EnvironmentSupervisorinsupervisor.tsto fork a promotion discovery loop and signal aPromoteRequestedreconnect without backoff when a direct route is found.resolver.tsto attemptauthorizeDpopDirectagainst a stored override before falling back to relay bootstrap.GET /api/remote-access/endpointsendpoint (defined inenvironmentHttp.ts, implemented inhttp.ts) that returns the server's advertised LAN, Tailscale, and loopback endpoints.resolveTailscaleAdvertisedEndpointsinto the shared@t3tools/tailscalepackage with a requiredsourcetag so both desktop and server producers share the same resolution logic.📊 Macroscope summarized eb5f14b. 16 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.