Skip to content

feat(desktop): add Tailscale endpoint add-on#2363

Merged
juliusmarminge merged 19 commits intot3code/advertised-endpointsfrom
t3code/tailscale-endpoint-addon
Apr 28, 2026
Merged

feat(desktop): add Tailscale endpoint add-on#2363
juliusmarminge merged 19 commits intot3code/advertised-endpointsfrom
t3code/tailscale-endpoint-addon

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 27, 2026

Summary

  • add a desktop Tailscale advertised endpoint provider as an add-on
  • detect Tailnet 100.64.0.0/10 IP endpoints from network interfaces
  • best-effort detect MagicDNS hostnames from tailscale status --json
  • keep HTTPS compatibility explicit: MagicDNS is surfaced as requiring Serve/configuration, custom HTTPS remains the compatible registration path

Validation

  • bun run --filter @t3tools/desktop test src/tailscaleEndpointProvider.test.ts src/serverExposure.test.ts
  • bun fmt
  • bun lint
  • bun typecheck

Stacked on #2362.

Co-authored-by: codex codex@users.noreply.github.com


Note

High Risk
High risk: introduces new SSH-based remote launch/tunneling with password prompting and token bootstrapping, plus expands persistence/IPC and connection lifecycle logic where mistakes could break remote connectivity or mishandle credentials.

Overview
Adds two new remote-connection capabilities: a Tailscale advertised-endpoint add-on (Tailnet IP + MagicDNS-derived HTTPS candidate) and a desktop-managed SSH launch/tunnel flow that can start/reuse a remote t3 server, forward it to loopback, and pair/save it like other environments.

Extends the saved-environment record format with optional desktopSsh metadata and updates desktop + web persistence to round-trip it. The desktop main process now exposes new SSH IPC APIs (host discovery, ensure tunnel, session/bootstrap helpers, password prompt/resolve) and merges Tailscale endpoints into getAdvertisedEndpoints.

Updates the web Connections settings UI to add an SSH add-environment mode (discovered hosts + manual entry) and adds an in-app SSH password prompt dialog. Refactors environment connection service to support SSH-forwarded session/token flows, including retry/recovery on ssh_http:401, deduping pending connections, and safer rollback when credential persistence fails.

Reviewed by Cursor Bugbot for commit c5c99a7. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add Tailscale endpoint discovery and SSH environment add-on to the desktop app

  • Adds resolveTailscaleAdvertisedEndpoints in tailscaleEndpointProvider.ts to discover Tailnet IPv4 and MagicDNS HTTPS endpoints from local network interfaces and tailscale status --json output, merging them into the advertised endpoints list.
  • Adds a full SSH environment subsystem in sshEnvironment.ts that manages SSH tunnels, askpass helpers, remote t3 CLI launch/pairing scripts, and an IPC bridge (DesktopSshEnvironmentBridge) for renderer-to-main SSH operations.
  • Extends connectDesktopSshEnvironment in service.ts to bootstrap, persist, and reconnect SSH-backed saved environments, including 401 recovery via bearer re-issuance and registry rollback on failure.
  • Adds an SSH tab to the Add Environment dialog in ConnectionsSettings.tsx with auto-discovery from ~/.ssh/config and known_hosts, manual host entry with IPv6 support, and Connect/Reconnect actions.
  • Adds SshPasswordPromptDialog in SshPasswordPromptDialog.tsx to collect SSH passwords in-app and resolve them back to the main process.
  • Risk: SSH tunnel lifecycle is tied to app window and process signals; abrupt window closure cancels pending password prompts, and MagicDNS HTTPS endpoints are marked requires-configuration with unknown reachability by default.

Macroscope summarized c5c99a7.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

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

Run ID: 3c5e3084-7098-48e4-afb0-2ec78cd298f4

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/tailscale-endpoint-addon

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

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 27, 2026
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 27, 2026

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces a substantial new feature adding Tailscale endpoint discovery and desktop-managed SSH launch capabilities. It includes ~1400 lines of new SSH tunnel management code, new IPC channels, UI components for password prompts, and changes to the environment connection service. Unresolved review comments have identified a medium-severity race condition in tunnel management and potential SSH process leaks that warrant attention before merging.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from a9381f0 to 660f950 Compare April 27, 2026 02:31
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from c48c1fa to 946d3b6 Compare April 27, 2026 02:32
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from 660f950 to 7bccf71 Compare April 27, 2026 02:44
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from 946d3b6 to b02652e Compare April 27, 2026 02:44
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
Comment thread apps/desktop/src/main.ts
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from 7bccf71 to 2045416 Compare April 27, 2026 02:56
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch 2 times, most recently from 97b548b to 0f13603 Compare April 27, 2026 03:22
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Tailscale endpoints advertised as available in local-only mode
    • Added an early return in getDesktopAdvertisedEndpoints to skip Tailscale endpoint resolution when exposure mode is 'local-only', preventing unreachable CGNAT addresses from being advertised.

Create PR

Or push these changes by commenting:

@cursor push 14fa24e035
Preview (14fa24e035)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -326,6 +326,9 @@
     exposure,
     customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
   });
+  if (exposure.mode === "local-only") {
+    return coreEndpoints;
+  }
   const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
     port: backendPort,
     networkInterfaces: OS.networkInterfaces(),

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/main.ts
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from 0f13603 to b9c798a Compare April 27, 2026 04:06
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from 1001ee1 to ebaa38f Compare April 27, 2026 04:19
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from b9c798a to b642027 Compare April 27, 2026 04:20
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from ebaa38f to 3335b4e Compare April 27, 2026 04:35
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from b642027 to 1ea2ac7 Compare April 27, 2026 04:38
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from 3335b4e to 811089e Compare April 27, 2026 04:49
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from 1ea2ac7 to bdf8358 Compare April 27, 2026 04:50
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from 811089e to cdce392 Compare April 27, 2026 05:06
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from bdf8358 to ce784b1 Compare April 27, 2026 05:08
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from cdce392 to ee85eb4 Compare April 27, 2026 05:15
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from ce784b1 to ddb27a7 Compare April 27, 2026 05:16
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Tailscale IP may duplicate core LAN endpoint
    • Added isTailscaleIpv4Address check to isUsableLanIpv4Address so Tailscale CGNAT addresses (100.64.0.0/10) are excluded from LAN endpoint selection, preventing duplicate entries and ensuring the real LAN IP is used.

Create PR

Or push these changes by commenting:

@cursor push 9d54008f10
Preview (9d54008f10)
diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts
--- a/apps/desktop/src/serverExposure.ts
+++ b/apps/desktop/src/serverExposure.ts
@@ -8,6 +8,7 @@
   AdvertisedEndpointProvider,
   DesktopServerExposureMode,
 } from "@t3tools/contracts";
+import { isTailscaleIpv4Address } from "./tailscaleEndpointProvider.ts";
 
 const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
 const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
@@ -47,7 +48,9 @@
 };
 
 const isUsableLanIpv4Address = (address: string): boolean =>
-  !address.startsWith("127.") && !address.startsWith("169.254.");
+  !address.startsWith("127.") &&
+  !address.startsWith("169.254.") &&
+  !isTailscaleIpv4Address(address);
 
 export function resolveLanAdvertisedHost(
   networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/main.ts
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from ee85eb4 to f04975d Compare April 27, 2026 05:38
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from ddb27a7 to b585c3d Compare April 27, 2026 05:39
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

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 and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Network interfaces snapshot called twice, risking inconsistency
    • Captured OS.networkInterfaces() once into a local variable and passed it to both resolveDesktopServerExposure and resolveTailscaleAdvertisedEndpoints, eliminating the redundant syscall and ensuring both consumers use the same network snapshot.

Create PR

Or push these changes by commenting:

@cursor push 795c4f4466
Preview (795c4f4466)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -315,10 +315,11 @@
 }
 
 async function getDesktopAdvertisedEndpoints() {
+  const networkInterfaces = OS.networkInterfaces();
   const exposure = resolveDesktopServerExposure({
     mode: desktopServerExposureMode,
     port: backendPort,
-    networkInterfaces: OS.networkInterfaces(),
+    networkInterfaces,
     ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
   });
   const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
@@ -328,7 +329,7 @@
   });
   const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
     port: backendPort,
-    networkInterfaces: OS.networkInterfaces(),
+    networkInterfaces,
   });
   return [...coreEndpoints, ...tailscaleEndpoints];
 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit b585c3d. Configure here.

Comment thread apps/desktop/src/main.ts
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from f04975d to 3009dd0 Compare April 27, 2026 06:37
juliusmarminge and others added 5 commits April 26, 2026 23:38
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/tailscale-endpoint-addon branch from b585c3d to f74ec04 Compare April 27, 2026 06:39
juliusmarminge and others added 14 commits April 27, 2026 18:14
- Discover SSH hosts and persist SSH targets
- Bootstrap tunneled SSH sessions with desktop password prompts
- Extend IPC and storage tests for SSH metadata
- Validate SSH targets and known-host parsing more strictly
- Retry desktop SSH session refresh on auth failures
- Preserve saved registry state when bearer persistence fails
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
- Resolve dev remote package specs to `t3@nightly`
- Cover the dev fallback in sshEnvironment tests
- surface stdout when remote launch or pairing fails
- report parse errors and invalid remote port or credential values
- Add a capped scroll area for discovered SSH hosts
- Keep the manual SSH form always visible and simplify the dialog layout
- Ensure the scroll area viewport respects inherited max height
- No functional change
- Keep staged code style consistent
- Move SSH IPC handlers and password prompt state out of main.ts
- Keep SSH environment launch and auth flow owned by sshEnvironment.ts
- Externalize askpass, remote launch, and runner helpers into script assets
- Copy SSH scripts into `dist-electron` for packaging
- Co-authored-by: codex <codex@users.noreply.github.com>
- Remove native password prompts from posix and Windows scripts
- Fail loudly when T3_SSH_AUTH_SECRET is missing
- use type-only imports required by verbatim module syntax
- fix SSH desktop build/typecheck regressions and auth test isolation
- tighten browser test selectors for the add-environment dialog

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge merged commit e46a25f into t3code/advertised-endpoints Apr 28, 2026
11 of 12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/tailscale-endpoint-addon branch April 28, 2026 01:14
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Apr 28, 2026
Comment on lines +1041 to +1046
async dispose(): Promise<void> {
const entries = [...this.tunnels.values()];
this.tunnels.clear();
this.pendingTunnelEntries.clear();
await Promise.all(entries.map((entry) => stopTunnel(entry).catch(() => undefined)));
}
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.

🟢 Low src/sshEnvironment.ts:1041

dispose() clears pendingTunnelEntries without awaiting or cancelling the in-flight promises, so a tunnel creation already in progress continues executing. When it completes, it calls this.tunnels.set(key, tunnelEntry) (line 1022), adding an SSH child process to the already-cleared tunnels map. This orphaned process is never stopped because dispose() already finished its stopTunnel calls, resulting in a leaked SSH process.

  async dispose(): Promise<void> {
+    const pending = [...this.pendingTunnelEntries.values()];
+    await Promise.allSettled(pending);
    const entries = [...this.tunnels.values()];
    this.tunnels.clear();
    this.pendingTunnelEntries.clear();
    await Promise.all(entries.map((entry) => stopTunnel(entry).catch(() => undefined)));
  }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/sshEnvironment.ts around lines 1041-1046:

`dispose()` clears `pendingTunnelEntries` without awaiting or cancelling the in-flight promises, so a tunnel creation already in progress continues executing. When it completes, it calls `this.tunnels.set(key, tunnelEntry)` (line 1022), adding an SSH child process to the already-cleared `tunnels` map. This orphaned process is never stopped because `dispose()` already finished its `stopTunnel` calls, resulting in a leaked SSH process.

Evidence trail:
apps/desktop/src/sshEnvironment.ts lines 1039-1043 (dispose method), line 1022 (tunnels.set), lines 958-973 (ChildProcess.spawn), line 1032 (pendingTunnelEntries.set), line 948 (IIFE async function starts immediately)

Comment on lines +1022 to +1030
this.tunnels.set(key, tunnelEntry);
try {
await tunnelReady;
return tunnelEntry;
} catch (error) {
await stopTunnel(tunnelEntry).catch(() => undefined);
this.deleteTunnelIfCurrent(tunnelEntry);
throw error;
}
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 src/sshEnvironment.ts:1022

In ensureTunnelEntry, the tunnel entry is added to this.tunnels at line 1022 before tunnelReady is awaited. A concurrent call with the same key can find this entry at line 931, fail its 2-second health check at line 935 (since the tunnel is still starting with a 20-second timeout), then kill the tunnel at line 938—destroying the tunnel the first call is still establishing. The pending-check at line 944 happens too late, after the tunnel is already killed. Consider moving the this.tunnels.set(key, tunnelEntry) call after the await tunnelReady succeeds, so the entry is only visible to other callers once it's actually ready.

-        this.tunnels.set(key, tunnelEntry);
         try {
           await tunnelReady;
+          this.tunnels.set(key, tunnelEntry);
           return tunnelEntry;
         } catch (error) {
           await stopTunnel(tunnelEntry).catch(() => undefined);
-          this.deleteTunnelIfCurrent(tunnelEntry);
           throw error;
         }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/sshEnvironment.ts around lines 1022-1030:

In `ensureTunnelEntry`, the tunnel entry is added to `this.tunnels` at line 1022 before `tunnelReady` is awaited. A concurrent call with the same key can find this entry at line 931, fail its 2-second health check at line 935 (since the tunnel is still starting with a 20-second timeout), then kill the tunnel at line 938—destroying the tunnel the first call is still establishing. The pending-check at line 944 happens too late, after the tunnel is already killed. Consider moving the `this.tunnels.set(key, tunnelEntry)` call after the `await tunnelReady` succeeds, so the entry is only visible to other callers once it's actually ready.

Evidence trail:
apps/desktop/src/sshEnvironment.ts lines 931-939 (entry lookup, 2s health check, stopTunnel); line 944-946 (pendingTunnelEntries check after tunnel already killed); lines 1018-1024 (20s timeout waitForHttpReady, this.tunnels.set before await tunnelReady); line 26 (SSH_READY_TIMEOUT_MS = 20_000)

Comment on lines +1261 to +1264
const removed = await removeConnection(activeRecord.environmentId).catch(() => false);
if (!removed) {
await connection.dispose().catch(() => undefined);
}
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.

🟢 Low runtime/service.ts:1261

In the auth error recovery path, when the recursive call to ensureSavedEnvironmentConnection at line 1251 fails, the outer catch block at lines 1259-1265 disposes connection again even though it was already disposed at line 1249. The local connection variable still holds the original disposed connection, so line 1264 executes connection.dispose() a second time. While the .catch(() => undefined) suppresses the error, this double-dispose is incorrect cleanup logic that could cause issues if dispose() has non-idempotent side effects.

     const removed = await removeConnection(activeRecord.environmentId).catch(() => false);
     if (!removed) {
-      await connection.dispose().catch(() => undefined);
+      // Already disposed at line 1249, no need to dispose again
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/environments/runtime/service.ts around lines 1261-1264:

In the auth error recovery path, when the recursive call to `ensureSavedEnvironmentConnection` at line 1251 fails, the outer catch block at lines 1259-1265 disposes `connection` again even though it was already disposed at line 1249. The local `connection` variable still holds the original disposed connection, so line 1264 executes `connection.dispose()` a second time. While the `.catch(() => undefined)` suppresses the error, this double-dispose is incorrect cleanup logic that could cause issues if `dispose()` has non-idempotent side effects.

Evidence trail:
apps/web/src/environments/runtime/service.ts lines 1249-1264 (line 1249: `await connection.dispose().catch(() => undefined)` in auth recovery, line 1251: recursive call that can throw, lines 1259-1265: catch block, line 1264: `await connection.dispose().catch(() => undefined)` called again). apps/web/src/environments/runtime/connection.ts lines 84, 141-149, 167-170 (line 84: `let disposed = false;` local variable, lines 141-149: `cleanup` function that sets disposed=true, lines 167-170: `dispose` method that calls `cleanup()` and `client.dispose()` without checking if already disposed). apps/web/src/rpc/wsTransport.ts lines 203-208 (WsTransport.dispose() is idempotent with early return, but this doesn't prevent the logic error at the connection level).

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

Labels

size:XXL 1,000+ 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