Skip to content

feat(remote): add advertised endpoint registry#2362

Merged
juliusmarminge merged 23 commits intot3code/hosted-pairing-uifrom
t3code/advertised-endpoints
Apr 28, 2026
Merged

feat(remote): add advertised endpoint registry#2362
juliusmarminge merged 23 commits intot3code/hosted-pairing-uifrom
t3code/advertised-endpoints

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 27, 2026

Summary

  • add provider-neutral AdvertisedEndpoint contracts and client-runtime URL helpers
  • expose desktop advertised endpoints over IPC
  • surface endpoint compatibility in settings and select endpoints for pairing links
  • add custom HTTPS endpoint registration through T3CODE_DESKTOP_HTTPS_ENDPOINTS

Validation

  • bun run --filter @t3tools/client-runtime test src/advertisedEndpoint.test.ts
  • bun run --filter @t3tools/desktop test src/serverExposure.test.ts
  • bun run --filter @t3tools/web test src/localApi.test.ts
  • bun fmt
  • bun lint
  • bun typecheck

Stacked on #2361.

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


Note

Medium Risk
Adds new endpoint selection logic plus a desktop-managed SSH tunneling/pairing flow via Electron IPC, impacting remote connection/pairing UX and connection lifecycle. Risk is moderate due to new process-spawning/IPC paths and persistence schema changes, though scoped to remote access features.

Overview
Introduces a provider-neutral AdvertisedEndpoint model and plumbs it through the desktop app so the UI can fetch and choose among multiple reachable endpoints (loopback/LAN, optional custom HTTPS via T3CODE_DESKTOP_HTTPS_ENDPOINTS, and a new Tailscale provider).

Updates Settings → Connections to display and persist a user-selected default endpoint (used for QR/copy pairing URLs), expose advanced endpoint lists, and improve pairing copy actions (separate “copy code” vs “copy pairing URL”).

Adds a desktop-managed SSH remote environment launch/bridge: discovers SSH hosts, prompts for passwords in-app, launches or reuses a remote t3 server over SSH, maintains local port-forward tunnels, and exposes supporting IPC APIs; saved environment persistence now optionally stores desktopSsh metadata across desktop and web storage.

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

Note

Add advertised endpoint registry and desktop SSH environment management

  • Introduces a new AdvertisedEndpoint contract and registry in packages/contracts/src/remoteAccess.ts and packages/client-runtime/src/advertisedEndpoint.ts, covering loopback, LAN, Tailscale IP, and MagicDNS HTTPS providers.
  • Adds DesktopSshEnvironmentBridge in apps/desktop/src/sshEnvironment.ts to discover SSH hosts, establish tunnels, handle password prompts via IPC askpass scripts, and bootstrap bearer sessions for remote environments.
  • Exposes getAdvertisedEndpoints and all SSH bridge methods to the renderer via apps/desktop/src/preload.ts; the root route now renders SshPasswordPromptDialog for authenticated sessions.
  • Extends SavedEnvironmentRecord and both desktop/browser persistence layers to carry an optional desktopSsh target, preserving it across registry reads and writes.
  • Adds connectDesktopSshEnvironment to the environment service, with deduplication of concurrent connection attempts, SSH bearer token recovery on 401, and rollback of the saved registry on persistence failure.
  • The Connections settings page gains an SSH mode for discovering or manually adding hosts, an advertised endpoint list with default selection, and per-endpoint pairing URL copy actions.
  • Risk: T3CODE_DESKTOP_HTTPS_ENDPOINTS is no longer forwarded to the backend child process; deployments relying on that env var in the child must switch to the new advertised endpoint IPC path.

Macroscope summarized e46a25f.

@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: cfefece0-f04d-4af4-9945-c15fff854ce5

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/advertised-endpoints

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Apr 27, 2026
@juliusmarminge juliusmarminge force-pushed the t3code/hosted-pairing-ui branch from 34102d3 to 814c206 Compare April 27, 2026 02:31
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch from a9381f0 to 660f950 Compare April 27, 2026 02:31
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 27, 2026

Approvability

Verdict: Needs human review

4 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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

Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
Comment thread apps/desktop/src/serverExposure.ts
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch 2 times, most recently from 7bccf71 to 2045416 Compare April 27, 2026 02:56
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: Custom endpoints bypass HTTPS compatibility classification
    • Changed the code to only pass hostedHttpsCompatibility: "compatible" when the custom endpoint URL actually uses the https: protocol, otherwise omitting the override so the auto-classifier in createAdvertisedEndpoint correctly detects http: URLs as "mixed-content-blocked".

Create PR

Or push these changes by commenting:

@cursor push 31abbb689a
Preview (31abbb689a)
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
@@ -156,13 +156,14 @@
   }
 
   for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
+    const isHttps = new URL(customEndpointUrl).protocol === "https:";
     endpoints.push(
       createManualEndpoint({
         id: `manual:${customEndpointUrl}`,
         label: "Custom HTTPS",
         httpBaseUrl: customEndpointUrl,
         reachability: "public",
-        hostedHttpsCompatibility: "compatible",
+        ...(isHttps ? { hostedHttpsCompatibility: "compatible" as const } : {}),
         status: "unknown",
         description: "User-configured HTTPS endpoint for this desktop backend.",
       }),

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

label: "Custom HTTPS",
httpBaseUrl: customEndpointUrl,
reachability: "public",
hostedHttpsCompatibility: "compatible",
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.

Custom endpoints bypass HTTPS compatibility classification

Low Severity

The hostedHttpsCompatibility: "compatible" value is hardcoded for all custom endpoint URLs from T3CODE_DESKTOP_HTTPS_ENDPOINTS, bypassing the automatic classifyHostedHttpsCompatibility check. If a user mistakenly supplies an http:// URL in this env var, the endpoint will be incorrectly marked as compatible with the hosted HTTPS app. The UI would display "Works with hosted app" for an endpoint that is actually mixed-content-blocked, and selectPairingEndpoint would incorrectly prefer it over a valid LAN endpoint. Omitting the hostedHttpsCompatibility override would let the auto-classifier correctly detect http: URLs as "mixed-content-blocked" while still returning "unknown" for legitimate HTTPS URLs — at which point callers like resolveDesktopCoreAdvertisedEndpoints could pass "compatible" only after confirming the scheme.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2045416. Configure here.

@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 27, 2026

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Advertised endpoints error overwrites unrelated exposure error state
    • Added a dedicated desktopAdvertisedEndpointsError state variable so getAdvertisedEndpoints() failures no longer overwrite desktopServerExposureError, and the error is displayed near the endpoints list instead of in the Network access row.
  • ✅ Fixed: Malformed custom URL discards all valid core endpoints
    • Wrapped the custom URL loop body in a try-catch so a malformed entry is skipped with a console warning instead of propagating an exception that discards all already-collected core endpoints.

Create PR

Or push these changes by commenting:

@cursor push f266b9ef02
Preview (f266b9ef02)
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -20,6 +20,7 @@
     "electron-updater": "^6.6.2"
   },
   "devDependencies": {
+    "@t3tools/client-runtime": "workspace:*",
     "@t3tools/contracts": "workspace:*",
     "@t3tools/shared": "workspace:*",
     "@types/node": "catalog:",

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
@@ -55,7 +55,10 @@
 } from "./clientPersistence.ts";
 import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
 import { showDesktopConfirmDialog } from "./confirmDialog.ts";
-import { resolveDesktopServerExposure } from "./serverExposure.ts";
+import {
+  resolveDesktopCoreAdvertisedEndpoints,
+  resolveDesktopServerExposure,
+} from "./serverExposure.ts";
 import { syncShellEnvironment } from "./syncShellEnvironment.ts";
 import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
 import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
@@ -102,6 +105,7 @@
 const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
 const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
 const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
+const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
 const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
 const STATE_DIR = Path.join(BASE_DIR, "userdata");
 const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
@@ -297,6 +301,7 @@
   delete env.T3CODE_DESKTOP_WS_URL;
   delete env.T3CODE_DESKTOP_LAN_ACCESS;
   delete env.T3CODE_DESKTOP_LAN_HOST;
+  delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS;
   return env;
 }
 
@@ -308,6 +313,20 @@
   };
 }
 
+function getDesktopAdvertisedEndpoints() {
+  const exposure = resolveDesktopServerExposure({
+    mode: desktopServerExposureMode,
+    port: backendPort,
+    networkInterfaces: OS.networkInterfaces(),
+    ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
+  });
+  return resolveDesktopCoreAdvertisedEndpoints({
+    port: backendPort,
+    exposure,
+    customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
+  });
+}
+
 function getDesktopSecretStorage() {
   return {
     isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
@@ -321,6 +340,13 @@
   return override && override.length > 0 ? override : undefined;
 }
 
+function resolveCustomHttpsEndpointUrls(): readonly string[] {
+  return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "")
+    .split(",")
+    .map((entry) => entry.trim())
+    .filter((entry) => entry.length > 0);
+}
+
 async function applyDesktopServerExposureMode(
   mode: DesktopServerExposureMode,
   options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },
@@ -1669,6 +1695,9 @@
     return nextState;
   });
 
+  ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);
+  ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints());
+
   ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
   ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
     const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;

diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -24,6 +24,7 @@
 const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
 const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
 const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
+const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
 
 contextBridge.exposeInMainWorld("desktopBridge", {
   getAppBranding: () => {
@@ -53,6 +54,7 @@
     ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
   getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
   setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
+  getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL),
   pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
   confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
   setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),

diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts
--- a/apps/desktop/src/serverExposure.test.ts
+++ b/apps/desktop/src/serverExposure.test.ts
@@ -1,6 +1,10 @@
 import { describe, expect, it } from "vitest";
 
-import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts";
+import {
+  resolveDesktopCoreAdvertisedEndpoints,
+  resolveDesktopServerExposure,
+  resolveLanAdvertisedHost,
+} from "./serverExposure.ts";
 
 describe("resolveLanAdvertisedHost", () => {
   it("prefers an explicit host override", () => {
@@ -74,6 +78,97 @@
   });
 });
 
+describe("resolveDesktopCoreAdvertisedEndpoints", () => {
+  it("advertises loopback and LAN endpoints without provider-specific assumptions", () => {
+    const exposure = resolveDesktopServerExposure({
+      mode: "network-accessible",
+      port: 3773,
+      networkInterfaces: {
+        en0: [
+          {
+            address: "192.168.1.44",
+            family: "IPv4",
+            internal: false,
+            netmask: "255.255.255.0",
+            cidr: "192.168.1.44/24",
+            mac: "00:00:00:00:00:00",
+          },
+        ],
+      },
+    });
+
+    expect(
+      resolveDesktopCoreAdvertisedEndpoints({
+        port: 3773,
+        exposure,
+        customHttpsEndpointUrls: ["https://desktop.example.ts.net"],
+      }),
+    ).toEqual([
+      {
+        id: "desktop-loopback:3773",
+        label: "This machine",
+        provider: {
+          id: "desktop-core",
+          label: "Desktop",
+          kind: "core",
+          isAddon: false,
+        },
+        httpBaseUrl: "http://127.0.0.1:3773/",
+        wsBaseUrl: "ws://127.0.0.1:3773/",
+        reachability: "loopback",
+        compatibility: {
+          hostedHttpsApp: "mixed-content-blocked",
+          desktopApp: "compatible",
+        },
+        source: "desktop-core",
+        status: "available",
+        description: "Loopback endpoint for this desktop app.",
+      },
+      {
+        id: "desktop-lan:http://192.168.1.44:3773",
+        label: "Local network",
+        provider: {
+          id: "desktop-core",
+          label: "Desktop",
+          kind: "core",
+          isAddon: false,
+        },
+        httpBaseUrl: "http://192.168.1.44:3773/",
+        wsBaseUrl: "ws://192.168.1.44:3773/",
+        reachability: "lan",
+        compatibility: {
+          hostedHttpsApp: "mixed-content-blocked",
+          desktopApp: "compatible",
+        },
+        source: "desktop-core",
+        status: "available",
+        isDefault: true,
+        description: "Reachable from devices on the same network.",
+      },
+      {
+        id: "manual:https://desktop.example.ts.net",
+        label: "Custom HTTPS",
+        provider: {
+          id: "manual",
+          label: "Manual",
+          kind: "manual",
+          isAddon: false,
+        },
+        httpBaseUrl: "https://desktop.example.ts.net/",
+        wsBaseUrl: "wss://desktop.example.ts.net/",
+        reachability: "public",
+        compatibility: {
+          hostedHttpsApp: "compatible",
+          desktopApp: "compatible",
+        },
+        source: "user",
+        status: "unknown",
+        description: "User-configured HTTPS endpoint for this desktop backend.",
+      },
+    ]);
+  });
+});
+
 describe("resolveDesktopServerExposure", () => {
   it("keeps the desktop server loopback-only when local-only mode is selected", () => {
     expect(

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
@@ -1,5 +1,13 @@
 import type { NetworkInterfaceInfo } from "node:os";
-import type { DesktopServerExposureMode } from "@t3tools/contracts";
+import {
+  createAdvertisedEndpoint,
+  type CreateAdvertisedEndpointInput,
+} from "@t3tools/client-runtime";
+import type {
+  AdvertisedEndpoint,
+  AdvertisedEndpointProvider,
+  DesktopServerExposureMode,
+} from "@t3tools/contracts";
 
 const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
 const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
@@ -13,6 +21,26 @@
   readonly advertisedHost: string | null;
 }
 
+export interface DesktopAdvertisedEndpointInput {
+  readonly port: number;
+  readonly exposure: DesktopServerExposure;
+  readonly customHttpsEndpointUrls?: readonly string[];
+}
+
+const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
+  id: "desktop-core",
+  label: "Desktop",
+  kind: "core",
+  isAddon: false,
+};
+
+const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
+  id: "manual",
+  label: "Manual",
+  kind: "manual",
+  isAddon: false,
+};
+
 const normalizeOptionalHost = (value: string | undefined): string | undefined => {
   const normalized = value?.trim();
   return normalized && normalized.length > 0 ? normalized : undefined;
@@ -78,3 +106,72 @@
     advertisedHost,
   };
 }
+
+function createDesktopEndpoint(
+  input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
+): AdvertisedEndpoint {
+  return createAdvertisedEndpoint({
+    ...input,
+    provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
+    source: "desktop-core",
+  });
+}
+
+function createManualEndpoint(
+  input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
+): AdvertisedEndpoint {
+  return createAdvertisedEndpoint({
+    ...input,
+    provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
+    source: "user",
+  });
+}
+
+export function resolveDesktopCoreAdvertisedEndpoints(
+  input: DesktopAdvertisedEndpointInput,
+): readonly AdvertisedEndpoint[] {
+  const endpoints: AdvertisedEndpoint[] = [
+    createDesktopEndpoint({
+      id: `desktop-loopback:${input.port}`,
+      label: "This machine",
+      httpBaseUrl: input.exposure.localHttpUrl,
+      reachability: "loopback",
+      status: "available",
+      description: "Loopback endpoint for this desktop app.",
+    }),
+  ];
+
+  if (input.exposure.endpointUrl) {
+    endpoints.push(
+      createDesktopEndpoint({
+        id: `desktop-lan:${input.exposure.endpointUrl}`,
+        label: "Local network",
+        httpBaseUrl: input.exposure.endpointUrl,
+        reachability: "lan",
+        status: "available",
+        isDefault: true,
+        description: "Reachable from devices on the same network.",
+      }),
+    );
+  }
+
+  for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
+    try {
+      endpoints.push(
+        createManualEndpoint({
+          id: `manual:${customEndpointUrl}`,
+          label: "Custom HTTPS",
+          httpBaseUrl: customEndpointUrl,
+          reachability: "public",
+          hostedHttpsCompatibility: "compatible",
+          status: "unknown",
+          description: "User-configured HTTPS endpoint for this desktop backend.",
+        }),
+      );
+    } catch {
+      console.warn(`Skipping malformed custom endpoint URL: ${customEndpointUrl}`);
+    }
+  }
+
+  return endpoints;
+}

diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -3,6 +3,7 @@
 import {
   type AuthClientSession,
   type AuthPairingLink,
+  type AdvertisedEndpoint,
   type DesktopServerExposureState,
   type EnvironmentId,
 } from "@t3tools/contracts";
@@ -257,6 +258,28 @@
   });
 }
 
+function selectPairingEndpoint(
+  endpoints: ReadonlyArray<AdvertisedEndpoint>,
+): AdvertisedEndpoint | null {
+  const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable");
+  return (
+    availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ??
+    availableEndpoints.find((endpoint) => endpoint.isDefault) ??
+    availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ??
+    null
+  );
+}
+
+function resolveAdvertisedEndpointPairingUrl(
+  endpoint: AdvertisedEndpoint,
+  credential: string,
+): string {
+  if (endpoint.compatibility.hostedHttpsApp === "compatible") {
+    return resolveHostedPairingUrl(endpoint.httpBaseUrl, credential);
+  }
+  return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential);
+}
+
 function resolveCurrentOriginPairingUrl(credential: string): string {
   const url = new URL("/pair", window.location.href);
   return setPairingTokenOnUrl(url, credential).toString();
@@ -265,6 +288,7 @@
 type PairingLinkListRowProps = {
   pairingLink: ServerPairingLinkRecord;
   endpointUrl: string | null | undefined;
+  endpoints: ReadonlyArray<AdvertisedEndpoint>;
   revokingPairingLinkId: string | null;
   onRevoke: (id: string) => void;
 };
@@ -272,6 +296,7 @@
 const PairingLinkListRow = memo(function PairingLinkListRow({
   pairingLink,
   endpointUrl,
+  endpoints,
   revokingPairingLinkId,
   onRevoke,
 }: PairingLinkListRowProps) {
@@ -293,12 +318,17 @@
         : null,
     [endpointUrl, pairingLink.credential],
   );
+  const endpointPairingUrl = useMemo(() => {
+    const endpoint = selectPairingEndpoint(endpoints);
+    return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null;
+  }, [endpoints, pairingLink.credential]);
   const shareablePairingUrl =
-    endpointUrl != null && endpointUrl !== ""
+    endpointPairingUrl ??
+    (endpointUrl != null && endpointUrl !== ""
       ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential))
       : isLoopbackHostname(window.location.hostname)
         ? null
-        : currentOriginPairingUrl;
+        : currentOriginPairingUrl);
   const copyValue = shareablePairingUrl ?? pairingLink.credential;
   const canCopyToClipboard =
     typeof window !== "undefined" &&
@@ -635,6 +665,7 @@
 
 type PairingClientsListProps = {
   endpointUrl: string | null | undefined;
+  endpoints: ReadonlyArray<AdvertisedEndpoint>;
   isLoading: boolean;
   pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
   clientSessions: ReadonlyArray<ServerClientSessionRecord>;
@@ -646,6 +677,7 @@
 
 const PairingClientsList = memo(function PairingClientsList({
   endpointUrl,
+  endpoints,
   isLoading,
   pairingLinks,
   clientSessions,
@@ -661,6 +693,7 @@
           key={pairingLink.id}
           pairingLink={pairingLink}
           endpointUrl={endpointUrl}
+          endpoints={endpoints}
           revokingPairingLinkId={revokingPairingLinkId}
           onRevoke={onRevokePairingLink}
         />
@@ -684,6 +717,58 @@
   );
 });
 
+type AdvertisedEndpointListRowProps = {
+  endpoint: AdvertisedEndpoint;
+};
+
+const endpointCompatibilityLabel = (endpoint: AdvertisedEndpoint): string => {
+  if (endpoint.compatibility.hostedHttpsApp === "compatible") {
+    return "Works with hosted app";
+  }
+  if (endpoint.compatibility.hostedHttpsApp === "mixed-content-blocked") {
+    return "Desktop or direct browser only";
+  }
+  if (endpoint.compatibility.hostedHttpsApp === "requires-configuration") {
+    return "Needs HTTPS setup";
+  }
+  return "Compatibility unknown";
+};
+
+const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
+  endpoint,
+}: AdvertisedEndpointListRowProps) {
+  return (
+    <div className={ITEM_ROW_CLASSNAME}>
+      <div className={ITEM_ROW_INNER_CLASSNAME}>
+        <div className="min-w-0 flex-1 space-y-1">
+          <div className="flex min-h-5 items-center gap-1.5">
+            <ConnectionStatusDot
+              tooltipText={`${endpoint.provider.label} · ${endpoint.reachability}`}
+              dotClassName={
+                endpoint.compatibility.hostedHttpsApp === "compatible"
+                  ? "bg-success"
+                  : "bg-muted-foreground/40"
+              }
+            />
+            <h3 className="text-sm font-medium text-foreground">{endpoint.label}</h3>
+            {endpoint.provider.isAddon ? (
+              <span className="rounded-md border border-border/50 bg-muted/50 px-1 py-0.5 text-[10px] text-muted-foreground/80">
+                Add-on
+              </span>
+            ) : null}
+          </div>
+          <p className="truncate text-xs text-muted-foreground" title={endpoint.httpBaseUrl}>
+            {endpoint.httpBaseUrl}
+          </p>
+        </div>
+        <p className="shrink-0 text-xs text-muted-foreground">
+          {endpointCompatibilityLabel(endpoint)}
+        </p>
+      </div>
+    </div>
+  );
+});
+
 type SavedBackendListRowProps = {
   environmentId: EnvironmentId;
   reconnectingEnvironmentId: EnvironmentId | null;
@@ -789,7 +874,13 @@
 
   const [desktopServerExposureState, setDesktopServerExposureState] =
     useState<DesktopServerExposureState | null>(null);
+  const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState<
+    ReadonlyArray<AdvertisedEndpoint>
+  >([]);
   const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null);
+  const [desktopAdvertisedEndpointsError, setDesktopAdvertisedEndpointsError] = useState<
+    string | null
+  >(null);
   const [desktopPairingLinks, setDesktopPairingLinks] = useState<
     ReadonlyArray<ServerPairingLinkRecord>
   >([]);
@@ -1120,9 +1211,23 @@
             error instanceof Error ? error.message : "Failed to load network exposure state.";
           setDesktopServerExposureError(message);
         });
+      void desktopBridge
+        .getAdvertisedEndpoints()
+        .then((endpoints) => {
+          if (cancelled) return;
+          setDesktopAdvertisedEndpoints(endpoints);
+        })
+        .catch((error: unknown) => {
+          if (cancelled) return;
+          const message =
+            error instanceof Error ? error.message : "Failed to load reachable endpoints.";
+          setDesktopAdvertisedEndpointsError(message);
+        });
     } else {
       setDesktopServerExposureState(null);
+      setDesktopAdvertisedEndpoints([]);
       setDesktopServerExposureError(null);
+      setDesktopAdvertisedEndpointsError(null);
     }
 
     return () => {
@@ -1138,7 +1243,9 @@
     setDesktopClientSessions([]);
     setDesktopAccessManagementError(null);
     setDesktopServerExposureState(null);
+    setDesktopAdvertisedEndpoints([]);
     setDesktopServerExposureError(null);
+    setDesktopAdvertisedEndpointsError(null);
   }, [canManageLocalBackend]);
   const visibleDesktopPairingLinks = useMemo(
     () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
@@ -1150,87 +1257,102 @@
         <>
           <SettingsSection title="Manage local backend">
             {desktopBridge ? (
-              <SettingsRow
-                title="Network access"
-                description={
-                  desktopServerExposureState?.endpointUrl
-                    ? `Reachable at ${desktopServerExposureState.endpointUrl}`
-                    : desktopServerExposureState?.mode === "network-accessible"
-                      ? desktopServerExposureState.advertisedHost
-                        ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
-                        : "Exposed on all interfaces."
-                      : desktopServerExposureState
-                        ? "Limited to this machine."
-                        : "Loading…"
-                }
-                status={
-                  desktopServerExposureError ? (
-                    <span className="block text-destructive">{desktopServerExposureError}</span>
-                  ) : null
-                }
-                control={
-                  <AlertDialog
-                    open={pendingDesktopServerExposureMode !== null}
-                    onOpenChange={(open) => {
-                      if (isUpdatingDesktopServerExposure) return;
-                      if (!open) setPendingDesktopServerExposureMode(null);
-                    }}
-                  >
-                    <Switch
-                      checked={desktopServerExposureState?.mode === "network-accessible"}
-                      disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure}
-                      onCheckedChange={(checked) => {
-                        setPendingDesktopServerExposureMode(
-                          checked ? "network-accessible" : "local-only",
-                        );
+              <>
+                <SettingsRow
+                  title="Network access"
+                  description={
+                    desktopServerExposureState?.endpointUrl
+                      ? `Reachable at ${desktopServerExposureState.endpointUrl}`
+                      : desktopServerExposureState?.mode === "network-accessible"
+                        ? desktopServerExposureState.advertisedHost
+                          ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
+                          : "Exposed on all interfaces."
+                        : desktopServerExposureState
+                          ? "Limited to this machine."
+                          : "Loading…"
+                  }
+                  status={
+                    desktopServerExposureError ? (
+                      <span className="block text-destructive">{desktopServerExposureError}</span>
+                    ) : null
+                  }
+                  control={
+                    <AlertDialog
+                      open={pendingDesktopServerExposureMode !== null}
+                      onOpenChange={(open) => {
+                        if (isUpdatingDesktopServerExposure) return;
+                        if (!open) setPendingDesktopServerExposureMode(null);
                       }}
-                      aria-label="Enable network access"
-                    />
-                    <AlertDialogPopup>
-                      <AlertDialogHeader>
-                        <AlertDialogTitle>
-                          {pendingDesktopServerExposureMode === "network-accessible"
-                            ? "Enable network access?"
-                            : "Disable network access?"}
-                        </AlertDialogTitle>
-                        <AlertDialogDescription>
-                          {pendingDesktopServerExposureMode === "network-accessible"
-                            ? "T3 Code will restart to expose this environment over the network."
-                            : "T3 Code will restart and limit this environment back to this machine."}
-                        </AlertDialogDescription>
-                      </AlertDialogHeader>
-                      <AlertDialogFooter>
-                        <AlertDialogClose
-                          disabled={isUpdatingDesktopServerExposure}
-                          render={
-                            <Button variant="outline" disabled={isUpdatingDesktopServerExposure} />
-                          }
-                        >
-                          Cancel
-                        </AlertDialogClose>
-                        <Button
-                          onClick={handleConfirmDesktopServerExposureChange}
-                          disabled={
-                            pendingDesktopServerExposureMode === null ||
-                            isUpdatingDesktopServerExposure
-                          }
-                        >
-                          {isUpdatingDesktopServerExposure ? (
-                            <>
-                              <Spinner className="size-3.5" />
-                              Restarting…
-                            </>
-                          ) : pendingDesktopServerExposureMode === "network-accessible" ? (
-                            "Restart and enable"
-                          ) : (
-                            "Restart and disable"
-                          )}
-                        </Button>
-                      </AlertDialogFooter>
-                    </AlertDialogPopup>
-                  </AlertDialog>
-                }
-              />
+                    >
+                      <Switch
+                        checked={desktopServerExposureState?.mode === "network-accessible"}
+                        disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure}
+                        onCheckedChange={(checked) => {
+                          setPendingDesktopServerExposureMode(
+                            checked ? "network-accessible" : "local-only",
+                          );
+                        }}
+                        aria-label="Enable network access"
+                      />
+                      <AlertDialogPopup>
+                        <AlertDialogHeader>
+                          <AlertDialogTitle>
+                            {pendingDesktopServerExposureMode === "network-accessible"
+                              ? "Enable network access?"
+                              : "Disable network access?"}
+                          </AlertDialogTitle>
+                          <AlertDialogDescription>
+                            {pendingDesktopServerExposureMode === "network-accessible"
+                              ? "T3 Code will restart to expose this environment over the network."
+                              : "T3 Code will restart and limit this environment back to this machine."}
+                          </AlertDialogDescription>
+                        </AlertDialogHeader>
+                        <AlertDialogFooter>
+                          <AlertDialogClose
+                            disabled={isUpdatingDesktopServerExposure}
+                            render={
+                              <Button
+                                variant="outline"
+                                disabled={isUpdatingDesktopServerExposure}
+                              />
+                            }
+                          >
+                            Cancel
+                          </AlertDialogClose>
+                          <Button
+                            onClick={handleConfirmDesktopServerExposureChange}
+                            disabled={
+                              pendingDesktopServerExposureMode === null ||
+                              isUpdatingDesktopServerExposure
+                            }
+                          >
+                            {isUpdatingDesktopServerExposure ? (
+                              <>
+                                <Spinner className="size-3.5" />
+                                Restarting…
+                              </>
+                            ) : pendingDesktopServerExposureMode === "network-accessible" ? (
+                              "Restart and enable"
+                            ) : (
+                              "Restart and disable"
+                            )}
+                          </Button>
+                        </AlertDialogFooter>
+                      </AlertDialogPopup>
+                    </AlertDialog>
+                  }
+                />
+                {desktopAdvertisedEndpointsError ? (
+                  <div className="px-4 py-2">
+                    <span className="block text-xs text-destructive">
+                      {desktopAdvertisedEndpointsError}
+                    </span>
+                  </div>
+                ) : null}
+                {desktopAdvertisedEndpoints.map((endpoint) => (
+                  <AdvertisedEndpointListRow key={endpoint.id} endpoint={endpoint} />
+                ))}
+              </>
             ) : (
               <SettingsRow
                 title="Network access"
@@ -1280,6 +1402,7 @@
               ) : null}
               <PairingClientsList
                 endpointUrl={desktopServerExposureState?.endpointUrl}
+                endpoints={desktopAdvertisedEndpoints}
                 isLoading={isLoadingDesktopAccessManagement}
                 pairingLinks={visibleDesktopPairingLinks}
                 clientSessions={desktopClientSessions}

diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx
--- a/apps/web/src/components/settings/SettingsPanels.browser.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx
@@ -258,6 +258,7 @@
 
 const createDesktopBridgeStub = (overrides?: {
   readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;
+  readonly advertisedEndpoints?: Awaited<ReturnType<DesktopBridge["getAdvertisedEndpoints"]>>;
   readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];
   readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];
 }): DesktopBridge => {
@@ -307,6 +308,7 @@
         endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null,
         advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null,
       })),
+    getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []),
     pickFolder: vi.fn().mockResolvedValue(null),
     confirm: vi.fn().mockResolvedValue(false),
     setTheme: vi.fn().mockResolvedValue(undefined),

diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts
--- a/apps/web/src/localApi.test.ts
+++ b/apps/web/src/localApi.test.ts
@@ -179,6 +179,7 @@
       endpointUrl: null,
       advertisedHost: null,
     }),
+    getAdvertisedEndpoints: async () => [],
     pickFolder: async () => null,
     confirm: async () => true,
     setTheme: async () => undefined,

diff --git a/bun.lock b/bun.lock
--- a/bun.lock
+++ b/bun.lock
@@ -21,6 +21,7 @@
         "electron-updater": "^6.6.2",
       },
       "devDependencies": {
+        "@t3tools/client-runtime": "workspace:*",
         "@t3tools/contracts": "workspace:*",
         "@t3tools/shared": "workspace:*",
         "@types/node": "catalog:",

diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/advertisedEndpoint.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/client-runtime/src/advertisedEndpoint.test.ts
@@ -1,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import {
+  classifyHostedHttpsCompatibility,
+  createAdvertisedEndpoint,
+  deriveWsBaseUrl,
+  normalizeHttpBaseUrl,
+} from "./advertisedEndpoint.ts";
+
+const coreProvider = {
... diff truncated: showing 800 of 1049 lines

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

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Apr 27, 2026
Comment on lines +353 to +362
const endpointCopyOptions = useMemo(
() =>
endpoints
.filter((endpoint) => endpoint.status !== "unavailable")
.map((endpoint) => ({
key: endpointDefaultPreferenceKey(endpoint),
label: endpoint.label,
url: resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential),
compatibilityLabel: endpointCompatibilityLabel(endpoint),
})),
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 settings/ConnectionsSettings.tsx:353

endpointCopyOptions uses endpointDefaultPreferenceKey(endpoint) as the key property, but that function returns hardcoded category strings like "desktop-core:lan:http" for all endpoints starting with "desktop-lan:". When multiple LAN interfaces exist, they share the same key, causing duplicate React keys at line 492 and reconciliation warnings. Preserve endpoint.id in endpointCopyOptions and use it as the key prop.

-  const endpointCopyOptions = useMemo(
-    () =>
-      endpoints
-        .filter((endpoint) => endpoint.status !== "unavailable")
-        .map((endpoint) => ({
-          key: endpointDefaultPreferenceKey(endpoint),
+  const endpointCopyOptions = useMemo(
+    () =>
+      endpoints
+        .filter((endpoint) => endpoint.status !== "unavailable")
+        .map((endpoint) => ({
+          key: endpoint.id,
+          preferenceKey: endpointDefaultPreferenceKey(endpoint),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/ConnectionsSettings.tsx around lines 353-362:

`endpointCopyOptions` uses `endpointDefaultPreferenceKey(endpoint)` as the `key` property, but that function returns hardcoded category strings like `"desktop-core:lan:http"` for all endpoints starting with `"desktop-lan:"`. When multiple LAN interfaces exist, they share the same `key`, causing duplicate React keys at line 492 and reconciliation warnings. Preserve `endpoint.id` in `endpointCopyOptions` and use it as the `key` prop.

Evidence trail:
apps/web/src/components/settings/ConnectionsSettings.tsx lines 353-362 (endpointCopyOptions definition using endpointDefaultPreferenceKey), lines 272-294 (endpointDefaultPreferenceKey function showing hardcoded returns for desktop-lan: prefix at line 276-278), line 492 (MenuItem using option.key as React key)

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

Fix All in Cursor

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

  • ✅ Fixed: Duplicate React keys for multiple custom endpoints
    • Added endpoint.httpBaseUrl to the fallback key in endpointDefaultPreferenceKey so that multiple custom HTTPS endpoints (which share the same provider, reachability, scheme, and label) now produce distinct keys, fixing both duplicate React keys and incorrect default badge display.

Create PR

Or push these changes by commenting:

@cursor push dde41bba9d
Preview (dde41bba9d)
diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md
--- a/.docs/remote-architecture.md
+++ b/.docs/remote-architecture.md
@@ -123,8 +123,12 @@
 
 Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable.
 
-Endpoint selection should prefer:
+The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration.
 
+Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity.
+
+When no user default is saved, endpoint selection should prefer:
+
 1. endpoints compatible with the hosted HTTPS app
 2. explicitly default endpoints
 3. non-loopback endpoints

diff --git a/REMOTE.md b/REMOTE.md
--- a/REMOTE.md
+++ b/REMOTE.md
@@ -22,11 +22,13 @@
 
 1. Open **Settings** → **Connections**.
 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.
-3. The settings panel will show reachable endpoints for the backend. At minimum this includes the local LAN HTTP endpoint when network access is enabled.
+3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints.
 4. Use **Create Link** to generate a pairing link you can share with another device.
 
-The app chooses the best reachable endpoint for pairing links:
+The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks.
 
+When no user default is saved, the app chooses the best reachable endpoint for pairing links:
+
 - HTTPS/WSS-compatible endpoints are preferred because they work from `https://app.t3.codes`.
 - Non-loopback HTTP endpoints are used for direct LAN pairing when HTTPS is not available.
 - Loopback-only endpoints are not useful for another device unless that device is the same machine.

diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -1,5 +1,5 @@
-import { PlusIcon, QrCodeIcon } from "lucide-react";
-import { memo, useCallback, useEffect, useMemo, useState } from "react";
+import { ChevronDownIcon, PlusIcon, QrCodeIcon } from "lucide-react";
+import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
 import {
   type AuthClientSession,
   type AuthPairingLink,
@@ -46,6 +46,8 @@
 import { stackedThreadToast, toastManager } from "../ui/toast";
 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
 import { Button } from "../ui/button";
+import { Group, GroupSeparator } from "../ui/group";
+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
 import { Textarea } from "../ui/textarea";
 import { setPairingTokenOnUrl } from "../../pairingUrl";
 import {
@@ -69,6 +71,7 @@
   reconnectSavedEnvironment,
   removeSavedEnvironment,
 } from "~/environments/runtime";
+import { useUiStateStore } from "~/uiStateStore";
 
 const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
   dateStyle: "medium",
@@ -247,8 +250,17 @@
 
 function selectPairingEndpoint(
   endpoints: ReadonlyArray<AdvertisedEndpoint>,
+  defaultEndpointKey?: string | null,
 ): AdvertisedEndpoint | null {
   const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable");
+  if (defaultEndpointKey) {
+    const selectedEndpoint = availableEndpoints.find(
+      (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey,
+    );
+    if (selectedEndpoint) {
+      return selectedEndpoint;
+    }
+  }
   return (
     availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ??
     availableEndpoints.find((endpoint) => endpoint.isDefault) ??
@@ -257,6 +269,30 @@
   );
 }
 
+function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string {
+  if (endpoint.id.startsWith("desktop-loopback:")) {
+    return "desktop-core:loopback:http";
+  }
+  if (endpoint.id.startsWith("desktop-lan:")) {
+    return "desktop-core:lan:http";
+  }
+  if (endpoint.id.startsWith("tailscale-ip:")) {
+    return "tailscale:ip:http";
+  }
+  if (endpoint.id.startsWith("tailscale-magicdns:")) {
+    return "tailscale:magicdns:https";
+  }
+
+  let scheme = "unknown";
+  try {
+    scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, "");
+  } catch {
+    // Keep the stored preference stable even if a custom endpoint is malformed.
+  }
+
+  return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}:${endpoint.httpBaseUrl}`;
+}
+
 function resolveAdvertisedEndpointPairingUrl(
   endpoint: AdvertisedEndpoint,
   credential: string,
@@ -279,6 +315,7 @@
   pairingLink: ServerPairingLinkRecord;
   endpointUrl: string | null | undefined;
   endpoints: ReadonlyArray<AdvertisedEndpoint>;
+  defaultEndpointKey: string | null;
   revokingPairingLinkId: string | null;
   onRevoke: (id: string) => void;
 };
@@ -287,6 +324,7 @@
   pairingLink,
   endpointUrl,
   endpoints,
+  defaultEndpointKey,
   revokingPairingLinkId,
   onRevoke,
 }: PairingLinkListRowProps) {
@@ -309,9 +347,21 @@
     [endpointUrl, pairingLink.credential],
   );
   const endpointPairingUrl = useMemo(() => {
-    const endpoint = selectPairingEndpoint(endpoints);
+    const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);
     return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null;
-  }, [endpoints, pairingLink.credential]);
+  }, [defaultEndpointKey, endpoints, pairingLink.credential]);
+  const endpointCopyOptions = useMemo(
+    () =>
+      endpoints
+        .filter((endpoint) => endpoint.status !== "unavailable")
+        .map((endpoint) => ({
+          key: endpointDefaultPreferenceKey(endpoint),
+          label: endpoint.label,
+          url: resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential),
+          compatibilityLabel: endpointCompatibilityLabel(endpoint),
+        })),
+    [endpoints, pairingLink.credential],
+  );
   const shareablePairingUrl =
     endpointPairingUrl ??
     (endpointUrl != null && endpointUrl !== ""
@@ -347,9 +397,16 @@
     },
   });
 
+  const copyPairingValue = useCallback(
+    (value: string) => {
+      copyToClipboard(value, undefined);
+    },
+    [copyToClipboard],
+  );
+
   const handleCopy = useCallback(() => {
-    copyToClipboard(copyValue, undefined);
-  }, [copyToClipboard, copyValue]);
+    copyPairingValue(copyValue);
+  }, [copyPairingValue, copyValue]);
 
   const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
 
@@ -412,9 +469,47 @@
         <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
           <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>
             {canCopyToClipboard ? (
-              <Button size="xs" variant="outline" onClick={handleCopy}>
-                {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"}
-              </Button>
+              shareablePairingUrl && endpointCopyOptions.length > 1 ? (
+                <Group aria-label="Copy pairing link">
+                  <Button size="xs" variant="outline" onClick={handleCopy}>
+                    {isCopied ? "Copied" : "Copy"}
+                  </Button>
+                  <GroupSeparator />
+                  <Menu>
+                    <MenuTrigger
+                      render={
+                        <Button
+                          size="icon-xs"
+                          variant="outline"
+                          aria-label="Pairing link copy options"
+                        />
+                      }
+                    >
+                      <ChevronDownIcon className="size-3.5" />
+                    </MenuTrigger>
+                    <MenuPopup align="end" className="min-w-56">
+                      {endpointCopyOptions.map((option) => (
+                        <MenuItem key={option.key} onClick={() => copyPairingValue(option.url)}>
+                          <span className="min-w-0 flex-1">
+                            <span className="block truncate">
+                              {option.key === defaultEndpointKey
+                                ? `Default: ${option.label}`
+                                : option.label}
+                            </span>
+                            <span className="block truncate text-[11px] text-muted-foreground">
+                              {option.compatibilityLabel}
+                            </span>
+                          </span>
+                        </MenuItem>
+                      ))}
+                    </MenuPopup>
+                  </Menu>
+                </Group>
+              ) : (
+                <Button size="xs" variant="outline" onClick={handleCopy}>
+                  {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"}
+                </Button>
+              )
             ) : (
               <DialogTrigger render={<Button size="xs" variant="outline" />}>
                 {shareablePairingUrl ? "Show link" : "Show token"}
@@ -656,6 +751,7 @@
 type PairingClientsListProps = {
   endpointUrl: string | null | undefined;
   endpoints: ReadonlyArray<AdvertisedEndpoint>;
+  defaultEndpointKey: string | null;
   isLoading: boolean;
   pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
   clientSessions: ReadonlyArray<ServerClientSessionRecord>;
@@ -668,6 +764,7 @@
 const PairingClientsList = memo(function PairingClientsList({
   endpointUrl,
   endpoints,
+  defaultEndpointKey,
   isLoading,
   pairingLinks,
   clientSessions,
@@ -684,6 +781,7 @@
           pairingLink={pairingLink}
           endpointUrl={endpointUrl}
           endpoints={endpoints}
+          defaultEndpointKey={defaultEndpointKey}
           revokingPairingLinkId={revokingPairingLinkId}
           onRevoke={onRevokePairingLink}
         />
@@ -709,6 +807,8 @@
 
 type AdvertisedEndpointListRowProps = {
   endpoint: AdvertisedEndpoint;
+  isDefault: boolean;
+  onSetDefault: (endpoint: AdvertisedEndpoint) => void;
 };
 
 const endpointCompatibilityLabel = (endpoint: AdvertisedEndpoint): string => {
@@ -726,6 +826,8 @@
 
 const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
   endpoint,
+  isDefault,
+  onSetDefault,
 }: AdvertisedEndpointListRowProps) {
   return (
     <div className={ITEM_ROW_CLASSNAME}>
@@ -741,6 +843,11 @@
               }
             />
             <h3 className="text-sm font-medium text-foreground">{endpoint.label}</h3>
+            {isDefault ? (
+              <span className="rounded-md border border-primary/30 bg-primary/10 px-1 py-0.5 text-[10px] text-primary">
+                Default
+              </span>
+            ) : null}
             {endpoint.provider.isAddon ? (
               <span className="rounded-md border border-border/50 bg-muted/50 px-1 py-0.5 text-[10px] text-muted-foreground/80">
                 Add-on
@@ -751,14 +858,53 @@
             {endpoint.httpBaseUrl}
           </p>
         </div>
-        <p className="shrink-0 text-xs text-muted-foreground">
-          {endpointCompatibilityLabel(endpoint)}
-        </p>
+        <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
+          <p className="text-xs text-muted-foreground">{endpointCompatibilityLabel(endpoint)}</p>
+          {!isDefault ? (
+            <Button size="xs" variant="outline" onClick={() => onSetDefault(endpoint)}>
+              Set default
+            </Button>
+          ) : null}
+        </div>
       </div>
     </div>
   );
 });
 
+function NetworkAccessDescription({
+  endpoint,
+  hiddenEndpointCount,
+  expanded,
+  onToggleExpanded,
+  fallback,
+}: {
+  endpoint: AdvertisedEndpoint | null;
+  hiddenEndpointCount: number;
+  expanded: boolean;
+  onToggleExpanded: () => void;
+  fallback: ReactNode;
+}) {
+  if (!endpoint) {
+    return fallback;
+  }
+
+  return (
+    <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
+      <span className="min-w-0 truncate">Reachable at {endpoint.httpBaseUrl}</span>
+      {hiddenEndpointCount > 0 ? (
+        <button
+          type="button"
+          className="inline-flex h-5 shrink-0 items-center rounded-md border border-border/60 px-1.5 text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
+          onClick={onToggleExpanded}
+          aria-expanded={expanded}
+        >
+          {expanded ? "Hide" : `+${hiddenEndpointCount}`}
+        </button>
+      ) : null}
+    </span>
+  );
+}
+
 type SavedBackendListRowProps = {
   environmentId: EnvironmentId;
   reconnectingEnvironmentId: EnvironmentId | null;
@@ -903,6 +1049,13 @@
   const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<
     DesktopServerExposureState["mode"] | null
   >(null);
+  const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);
+  const defaultAdvertisedEndpointKey = useUiStateStore(
+    (state) => state.defaultAdvertisedEndpointKey,
+  );
+  const setDefaultAdvertisedEndpointKey = useUiStateStore(
+    (state) => state.setDefaultAdvertisedEndpointKey,
+  );
   const canManageLocalBackend = currentSessionRole === "owner";
   const isLocalBackendNetworkAccessible = desktopBridge
     ? desktopServerExposureState?.mode === "network-accessible"
@@ -1236,9 +1389,23 @@
     () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
     [desktopPairingLinks],
   );
-  const visibleDesktopAdvertisedEndpoints = isLocalBackendNetworkAccessible
-    ? desktopAdvertisedEndpoints
-    : [];
+  const visibleDesktopAdvertisedEndpoints = useMemo(
+    () => (isLocalBackendNetworkAccessible ? desktopAdvertisedEndpoints : []),
+    [desktopAdvertisedEndpoints, isLocalBackendNetworkAccessible],
+  );
+  const defaultDesktopAdvertisedEndpoint = useMemo(
+    () => selectPairingEndpoint(visibleDesktopAdvertisedEndpoints, defaultAdvertisedEndpointKey),
+    [defaultAdvertisedEndpointKey, visibleDesktopAdvertisedEndpoints],
+  );
+  const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint
+    ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint)
+    : null;
+  const handleSetDefaultAdvertisedEndpoint = useCallback(
+    (endpoint: AdvertisedEndpoint) => {
+      setDefaultAdvertisedEndpointKey(endpointDefaultPreferenceKey(endpoint));
+    },
+    [setDefaultAdvertisedEndpointKey],
+  );
   return (
     <SettingsPageContainer>
       {canManageLocalBackend ? (
@@ -1249,15 +1416,30 @@
                 <SettingsRow
                   title="Network access"
                   description={
-                    desktopServerExposureState?.endpointUrl
-                      ? `Reachable at ${desktopServerExposureState.endpointUrl}`
-                      : desktopServerExposureState?.mode === "network-accessible"
-                        ? desktopServerExposureState.advertisedHost
-                          ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
-                          : "Exposed on all interfaces."
-                        : desktopServerExposureState
-                          ? "Limited to this machine."
-                          : "Loading…"
+                    isLocalBackendNetworkAccessible ? (
+                      <NetworkAccessDescription
+                        endpoint={defaultDesktopAdvertisedEndpoint}
+                        hiddenEndpointCount={Math.max(
+                          visibleDesktopAdvertisedEndpoints.length - 1,
+                          0,
+                        )}
+                        expanded={isAdvertisedEndpointListExpanded}
+                        onToggleExpanded={() =>
+                          setIsAdvertisedEndpointListExpanded((expanded) => !expanded)
+                        }
+                        fallback={
+                          desktopServerExposureState?.endpointUrl
+                            ? `Reachable at ${desktopServerExposureState.endpointUrl}`
+                            : desktopServerExposureState?.advertisedHost
+                              ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
+                              : "Exposed on all interfaces."
+                        }
+                      />
+                    ) : desktopServerExposureState ? (
+                      "Limited to this machine."
+                    ) : (
+                      "Loading…"
+                    )
                   }
                   status={
                     desktopServerExposureError ? (
@@ -1330,9 +1512,19 @@
                     </AlertDialog>
                   }
                 />
-                {visibleDesktopAdvertisedEndpoints.map((endpoint) => (
-                  <AdvertisedEndpointListRow key={endpoint.id} endpoint={endpoint} />
-                ))}
+                {isAdvertisedEndpointListExpanded
+                  ? visibleDesktopAdvertisedEndpoints.map((endpoint) => {
+                      const endpointKey = endpointDefaultPreferenceKey(endpoint);
+                      return (
+                        <AdvertisedEndpointListRow
+                          key={endpoint.id}
+                          endpoint={endpoint}
+                          isDefault={endpointKey === defaultDesktopAdvertisedEndpointKey}
+                          onSetDefault={handleSetDefaultAdvertisedEndpoint}
+                        />
+                      );
+                    })
+                  : null}
               </>
             ) : (
               <SettingsRow
@@ -1384,6 +1576,7 @@
               <PairingClientsList
                 endpointUrl={desktopServerExposureState?.endpointUrl}
                 endpoints={visibleDesktopAdvertisedEndpoints}
+                defaultEndpointKey={defaultDesktopAdvertisedEndpointKey}
                 isLoading={isLoadingDesktopAccessManagement}
                 pairingLinks={visibleDesktopPairingLinks}
                 clientSessions={desktopClientSessions}

diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx
--- a/apps/web/src/components/settings/SettingsPanels.browser.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx
@@ -20,6 +20,7 @@
 import { __resetLocalApiForTests } from "../../localApi";
 import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
 import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
+import { useUiStateStore } from "../../uiStateStore";
 import { ConnectionsSettings } from "./ConnectionsSettings";
 import { GeneralSettingsPanel } from "./SettingsPanels";
 
@@ -345,6 +346,7 @@
     resetServerStateForTests();
     await __resetLocalApiForTests();
     localStorage.clear();
+    useUiStateStore.setState({ defaultAdvertisedEndpointKey: null });
     authAccessHarness.reset();
   });
 
@@ -501,6 +503,104 @@
       .not.toBeInTheDocument();
   });
 
+  it("collapses advertised endpoints behind the network access summary", async () => {
+    window.desktopBridge = createDesktopBridgeStub({
+      serverExposureState: {
+        mode: "network-accessible",
+        endpointUrl: "http://192.168.86.39:3773",
+        advertisedHost: "192.168.86.39",
+      },
+      advertisedEndpoints: [
+        {
+          id: "desktop-loopback:3773",
+          label: "This machine",
+          provider: {
+            id: "desktop-core",
+            label: "Desktop",
+            kind: "manual",
+            isAddon: false,
+          },
+          httpBaseUrl: "http://127.0.0.1:3773/",
+          wsBaseUrl: "ws://127.0.0.1:3773/",
+          reachability: "loopback",
+          compatibility: {
+            hostedHttpsApp: "mixed-content-blocked",
+            desktopApp: "compatible",
+          },
+          source: "desktop-core",
+          status: "available",
+        },
+        {
+          id: "desktop-lan:http://192.168.86.39:3773",
+          label: "Local network",
+          provider: {
+            id: "desktop-core",
+            label: "Desktop",
+            kind: "manual",
+            isAddon: false,
+          },
+          httpBaseUrl: "http://192.168.86.39:3773/",
+          wsBaseUrl: "ws://192.168.86.39:3773/",
+          reachability: "lan",
+          compatibility: {
+            hostedHttpsApp: "mixed-content-blocked",
+            desktopApp: "compatible",
+          },
+          source: "desktop-core",
+          status: "available",
+          isDefault: true,
+        },
+        {
+          id: "tailscale-ip:http://100.105.39.17:3773",
+          label: "Tailscale IP",
+          provider: {
+            id: "tailscale",
+            label: "Tailscale",
+            kind: "private-network",
+            isAddon: true,
+          },
+          httpBaseUrl: "http://100.105.39.17:3773/",
+          wsBaseUrl: "ws://100.105.39.17:3773/",
+          reachability: "private-network",
+          compatibility: {
+            hostedHttpsApp: "mixed-content-blocked",
+            desktopApp: "compatible",
+          },
+          source: "desktop-addon",
+          status: "available",
+        },
+      ],
+    });
+    authAccessHarness.setSnapshot({
+      pairingLinks: [],
+      clientSessions: [],
+    });
+    setServerConfigSnapshot(createBaseServerConfig());
+
+    mounted = await render(
+      <AppAtomRegistryProvider>
+        <ConnectionsSettings />
+      </AppAtomRegistryProvider>,
+    );
+
+    await expect
+      .element(page.getByText("Reachable at http://192.168.86.39:3773/"))
+      .toBeInTheDocument();
+    await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument();
+    await expect
+      .element(page.getByRole("heading", { name: "Local network", exact: true }))
+      .not.toBeInTheDocument();
+
+    await page.getByRole("button", { name: "+2" }).click();
+
+    await expect
+      .element(page.getByRole("heading", { name: "Local network", exact: true }))
+      .toBeInTheDocument();
+    await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument();
+    await page.getByRole("button", { name: "Set default" }).first().click();
+    await expect.element(page.getByText("Reachable at http://127.0.0.1:3773/")).toBeInTheDocument();
+  });
+
   it("shows diagnostics inside About with a single logs-folder action", async () => {
     setServerConfigSnapshot(createBaseServerConfig());
 

diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -52,7 +52,7 @@
   children,
 }: {
   title: ReactNode;
-  description: string;
+  description: ReactNode;
   status?: ReactNode;
   resetAction?: ReactNode;
   control?: ReactNode;

diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts
--- a/apps/web/src/uiStateStore.test.ts
+++ b/apps/web/src/uiStateStore.test.ts
@@ -9,6 +9,7 @@
   type PersistedUiState,
   persistState,
   reorderProjects,
+  setDefaultAdvertisedEndpointKey,
   setProjectExpanded,
   setThreadChangedFilesExpanded,
   syncProjects,
@@ -22,6 +23,7 @@
     projectOrder: [],
     threadLastVisitedAtById: {},
     threadChangedFilesExpandedById: {},
+    defaultAdvertisedEndpointKey: null,
     ...overrides,
   };
 }
@@ -79,6 +81,18 @@
     expect(next).toBe(initialState);
   });
 
+  it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => {
+    const initialState = makeUiState();
+
+    const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http");
+
+    expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");
+    expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next);
+    expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({
+      defaultAdvertisedEndpointKey: null,
+    });
+  });
+
   it("reorderProjects moves all member keys of a multi-member group together", () => {
     const keyALocal = "env-local:proj-a";
     const keyARemote = "env-remote:proj-a";
@@ -531,6 +545,17 @@
     expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
   });
 
+  it("persists the default advertised endpoint preference", () => {
+    const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http");
+
+    persistState(state);
+
+    const persisted = JSON.parse(
+      localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
+    ) as PersistedUiState;
+    expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");
+  });
+
   it("preserves expand state across restart when project's logical key changes", () => {
     // After restart, in-memory previousExpandedById is empty, so the
     // previousLogicalKey-to-state bridge in syncProjects cannot help. The

diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts
--- a/apps/web/src/uiStateStore.ts
+++ b/apps/web/src/uiStateStore.ts
@@ -19,6 +19,7 @@
   collapsedProjectCwds?: string[];
   expandedProjectCwds?: string[];
   projectOrderCwds?: string[];
+  defaultAdvertisedEndpointKey?: string | null;
   threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
 }
 
@@ -32,8 +33,12 @@
   threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
 }
 
-export interface UiState extends UiProjectState, UiThreadState {}
+export interface UiEndpointState {
+  defaultAdvertisedEndpointKey: string | null;
+}
 
+export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
+
 export interface SyncProjectInput {
   /** Physical project key (env + cwd). Used for manual sort order. */
   key: string;
@@ -52,6 +57,7 @@
   projectOrder: [],
   threadLastVisitedAtById: {},
   threadChangedFilesExpandedById: {},
+  defaultAdvertisedEndpointKey: null,
 };
 
 const persistedCollapsedProjectCwds = new Set<string>();
@@ -88,6 +94,11 @@
     hydratePersistedProjectState(parsed);
     return {
       ...initialState,
+      defaultAdvertisedEndpointKey:
+        typeof parsed.defaultAdvertisedEndpointKey === "string" &&
+        parsed.defaultAdvertisedEndpointKey.length > 0
+          ? parsed.defaultAdvertisedEndpointKey
+          : null,
       threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
         parsed.threadChangedFilesExpandedById,
       ),
@@ -179,6 +190,7 @@
         collapsedProjectCwds,
         expandedProjectCwds,
         projectOrderCwds,
+        defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
         threadChangedFilesExpandedById,
       } satisfies PersistedUiState),
     );
@@ -532,6 +544,17 @@
   };
 }
 
+export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState {
+  const nextKey = key && key.length > 0 ? key : null;
+  if (state.defaultAdvertisedEndpointKey === nextKey) {
+    return state;
+  }
+  return {
+    ...state,
+    defaultAdvertisedEndpointKey: nextKey,
+  };
+}
+
 export function toggleProject(state: UiState, projectId: string): UiState {
   const expanded = state.projectExpandedById[projectId] ?? true;
   return {
@@ -606,6 +629,7 @@
   markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
   clearThreadUi: (threadId: string) => void;
   setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
+  setDefaultAdvertisedEndpointKey: (key: string | null) => void;
   toggleProject: (projectId: string) => void;
   setProjectExpanded: (projectId: string, expanded: boolean) => void;
   reorderProjects: (
@@ -625,6 +649,8 @@
   clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
   setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
     set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
+  setDefaultAdvertisedEndpointKey: (key) =>
+    set((state) => setDefaultAdvertisedEndpointKey(state, key)),
   toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
   setProjectExpanded: (projectId, expanded) =>
     set((state) => setProjectExpanded(state, projectId, expanded)),

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

Reviewed by Cursor Bugbot for commit 1001ee1. Configure here.

Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
@juliusmarminge juliusmarminge force-pushed the t3code/advertised-endpoints branch 6 times, most recently from ee85eb4 to f04975d Compare April 27, 2026 05:38
juliusmarminge and others added 4 commits April 26, 2026 23:36
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/advertised-endpoints branch from f04975d to 3009dd0 Compare April 27, 2026 06:37
juliusmarminge and others added 3 commits April 27, 2026 18:14
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 and others added 16 commits April 27, 2026 18:14
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
- 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>
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Apr 28, 2026
@juliusmarminge juliusmarminge merged commit 02e194c into t3code/hosted-pairing-ui Apr 28, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/advertised-endpoints branch April 28, 2026 01:14
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 +1259 to +1266
} catch (error) {
setRuntimeError(activeRecord.environmentId, error);
const removed = await removeConnection(activeRecord.environmentId).catch(() => false);
if (!removed) {
await connection.dispose().catch(() => undefined);
}
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.

🟢 Low runtime/service.ts:1259

In the auth error recovery path, when the recursive ensureSavedEnvironmentConnection call at line 1251 fails, the outer catch block (lines 1259-1265) disposes connection a second time. The variable connection still references the already-disposed connection from line 1194, not any connection from the failed recursive call. This double-dispose is incorrect cleanup logic that could cause issues if dispose() has non-idempotent side effects.

    } catch (error) {
      setRuntimeError(activeRecord.environmentId, error);
      const removed = await removeConnection(activeRecord.environmentId).catch(() => false);
-      if (!removed) {
-        await connection.dispose().catch(() => undefined);
+      if (!removed && connection) {
+        await (connection as EnvironmentConnection).dispose().catch(() => undefined);
      }
      throw error;
    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/environments/runtime/service.ts around lines 1259-1266:

In the auth error recovery path, when the recursive `ensureSavedEnvironmentConnection` call at line 1251 fails, the outer catch block (lines 1259-1265) disposes `connection` a second time. The variable `connection` still references the already-disposed connection from line 1194, not any connection from the failed recursive call. 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 1194 (connection created), 1249 (first dispose), 1251-1255 (recursive call that can throw), 1259-1265 (outer catch with second dispose at line 1263). apps/web/src/environments/runtime/connection.ts lines 141-145 (cleanup function), 163-166 (dispose implementation showing non-trivial work in dispose).

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)

setConnectingSshHostAlias(target.alias);
if (savedBackendMode === "ssh") {
setSavedBackendError(null);
} else {
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 settings/ConnectionsSettings.tsx:1463

In handleConnectSshHost, errors from connecting to discovered SSH hosts are always routed to savedBackendError (shown in the manual entry section) instead of discoveredSshHostsError (shown near the discovered hosts list). The if (savedBackendMode === "ssh") check at line 1485 evaluates to true when invoked from the discovered hosts list, so the else branches at lines 1488–1489 are unreachable dead code. This causes connection errors to appear in the wrong UI section.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/ConnectionsSettings.tsx around line 1463:

In `handleConnectSshHost`, errors from connecting to discovered SSH hosts are always routed to `savedBackendError` (shown in the manual entry section) instead of `discoveredSshHostsError` (shown near the discovered hosts list). The `if (savedBackendMode === "ssh")` check at line 1485 evaluates to true when invoked from the discovered hosts list, so the `else` branches at lines 1488–1489 are unreachable dead code. This causes connection errors to appear in the wrong UI section.

Evidence trail:
ConnectionsSettings.tsx line 1168: `useState<"pairing-url" | "host-code" | "ssh">` shows the three possible values
ConnectionsSettings.tsx lines 1951-2020: ternary chain showing the discovered hosts section is in the else branch (only when mode is ssh)
ConnectionsSettings.tsx line 2054: only call site of `handleConnectSshHost` is within the discovered hosts section
ConnectionsSettings.tsx lines 1461-1489: the function checks `savedBackendMode === "ssh"` and routes to `savedBackendError` or `discoveredSshHostsError`
ConnectionsSettings.tsx lines 2043-2045: `discoveredSshHostsError` displayed near discovered hosts list
ConnectionsSettings.tsx lines 2069-2071: `savedBackendError` displayed near manual entry form

Comment on lines +207 to +212
const colonSegments = hostname.split(":");
if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) {
hostname = colonSegments[0]!.trim();
port = Number.parseInt(colonSegments[1]!, 10);
}
}
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 settings/ConnectionsSettings.tsx:207

When the host ends with a trailing colon (e.g., myhost:), the split on : produces ['myhost', ''] which fails the /^\d+$/u test, so the code falls through without stripping the colon. This leaves hostname as 'myhost:', causing SSH connections to fail with invalid hostname errors. Consider adding a check to trim the trailing colon when the port segment is empty.

-  } else {
-    const colonSegments = hostname.split(":");
-    if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) {
-      hostname = colonSegments[0]!.trim();
-      port = Number.parseInt(colonSegments[1]!, 10);
+  } else {
+    const colonSegments = hostname.split(":");
+    if (colonSegments.length === 2) {
+      if (/^\d+$/u.test(colonSegments[1] ?? "")) {
+        hostname = colonSegments[0]!.trim();
+        port = Number.parseInt(colonSegments[1]!, 10);
+      } else if ((colonSegments[1] ?? "").length === 0) {
+        hostname = colonSegments[0]!.trim();
+      }
     }
   }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/ConnectionsSettings.tsx around lines 207-212:

When the host ends with a trailing colon (e.g., `myhost:`), the split on `:` produces `['myhost', '']` which fails the `/^\d+$/u` test, so the code falls through without stripping the colon. This leaves `hostname` as `'myhost:'`, causing SSH connections to fail with invalid hostname errors. Consider adding a check to trim the trailing colon when the port segment is empty.

Evidence trail:
apps/web/src/components/settings/ConnectionsSettings.tsx lines 207-212 at REVIEWED_COMMIT: The code splits on `:` and only strips hostname + parses port when the second segment matches `/^\d+$/u`. An empty string from `myhost:` fails this regex, leaving the hostname unchanged as `'myhost:'`.

const filePath = process.argv[2];
const defaultPort = Number.parseInt(process.argv[3] ?? "", 10);
const scanWindow = Number.parseInt(process.argv[4] ?? "", 10);
const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : "";
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 sshScripts/remote-pick-port.cjs:6

When the script is called without a file path argument, process.argv[2] is undefined and fs.existsSync(undefined) throws TypeError: path must be a string or Buffer. This prevents the script from falling back to the default port and instead crashes. Consider adding ?? "" to process.argv[2] to match the handling of the other arguments.

Suggested change
const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : "";
const raw = fs.existsSync(filePath ?? "") ? fs.readFileSync(filePath, "utf8").trim() : "";
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/sshScripts/remote-pick-port.cjs around line 6:

When the script is called without a file path argument, `process.argv[2]` is `undefined` and `fs.existsSync(undefined)` throws `TypeError: path must be a string or Buffer`. This prevents the script from falling back to the default port and instead crashes. Consider adding `?? ""` to `process.argv[2]` to match the handling of the other arguments.

Evidence trail:
apps/desktop/src/sshScripts/remote-pick-port.cjs lines 3-6 (REVIEWED_COMMIT): Line 3 shows `const filePath = process.argv[2];` without `?? ""`, while lines 4-5 show `process.argv[3] ?? ""` and `process.argv[4] ?? ""`. Line 6 calls `fs.existsSync(filePath)` directly. Node.js fs.existsSync documentation (https://nodejs.org/api/fs.html) confirms it throws TypeError for non-string/Buffer/URL paths.

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