Skip to content

feat: T3 Connect environments switch to local connections automatically - #5463

Open
t3dotgg wants to merge 1 commit into
mainfrom
t3code/promote-t3-connect-local
Open

feat: T3 Connect environments switch to local connections automatically#5463
t3dotgg wants to merge 1 commit into
mainfrom
t3code/promote-t3-connect-local

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Aug 6, 2026

Copy link
Copy Markdown
Member

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.

  • The server advertises its own reachable endpoints (loopback, LAN, Tailscale) on a new authenticated GET /api/remote-access/endpoints route (relay:read scope), aware of what it is actually bound to. A loopback-only server advertises nothing promotable.
  • While relay-connected, the client fetches those endpoints through the tunnel, probes the direct candidates, and verifies each one is the same environment via the descriptor's environmentId before trusting it.
  • Promotion reuses the cached DPoP access token. The token is not host-bound (only per-request proofs are, and those are minted fresh per origin), so switching routes needs no relay round-trip and no re-bootstrap.
  • A failed direct route clears the override, starts a 5-minute cooldown for that endpoint so a flaky LAN cannot ping-pong the connection, and falls back to the relay within the same connection attempt.
  • The Tailscale endpoint synthesis moved from the desktop app into @t3tools/tailscale so desktop and server advertise identical tailscale-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/tailscale for shared desktop/server IDs.

On the client, ConnectionPromotion fetches that list over the tunnel, ranks lan ahead of private-network, probes candidates via environmentId, and stores an in-memory override. The relay resolver tries authorizeDpopDirect with 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

  • Adds a ConnectionPromotion service in promotion.ts that, while relay-connected, periodically fetches advertised endpoints from the environment, probes candidates, and stores a direct-route override with cooldown management after failures.
  • Extends EnvironmentSupervisor in supervisor.ts to fork a promotion discovery loop and signal a PromoteRequested reconnect without backoff when a direct route is found.
  • Updates the relay broker in resolver.ts to attempt authorizeDpopDirect against a stored override before falling back to relay bootstrap.
  • Adds a new GET /api/remote-access/endpoints endpoint (defined in environmentHttp.ts, implemented in http.ts) that returns the server's advertised LAN, Tailscale, and loopback endpoints.
  • Moves resolveTailscaleAdvertisedEndpoints into the shared @t3tools/tailscale package with a required source tag 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.

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>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5dcce6d0-0b9f-4fe9-8718-8e48cab2fcd2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 6, 2026
.slice(0, MAX_PROBED_CANDIDATES);
}

interface PromotionCooldown {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +77 to +82
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}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eb5f14b. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant