diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
index 49f4b560..d444379b 100644
--- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
+++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
@@ -450,6 +450,13 @@ export function DesktopHostSwitcherDialog({
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
+ // Relay hosts have no HTTP address to probe — check reachability
+ // through a throwaway E2EE tunnel instead.
+ if (h.relay) {
+ const startedAt = performance.now();
+ const ok = await probeRelayHost(h.relay).catch(() => false);
+ return [h.id, { status: ok ? ('ok' as const) : ('unreachable' as const), latencyMs: Math.round(performance.now() - startedAt) } satisfies HostStatus] as const;
+ }
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
@@ -898,7 +905,9 @@ export function DesktopHostSwitcherDialog({
const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label);
- const displayUrl = redactSensitiveUrl(effectiveUrl);
+ // Relay hosts have a relay:// pseudo-URL that means nothing to a
+ // person — say how the connection works instead.
+ const displayUrl = host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(effectiveUrl);
return (
{
{redactSensitiveUrl(host.label)}
{directDefaultHostId === host.id ?
{t('desktopHostSwitcher.header.default')} : null}
- {redactSensitiveUrl(host.apiUrl || host.url)}
+
+ {host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}
+
void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
{directDefaultHostId === host.id ? : }
- beginEditDirectHost(host)} disabled={directSaving}>
-
- {t('desktopHostSwitcher.actions.edit')}
-
+ {/* The edit form is URL/token-centric; saving it would drop a
+ relay host's tunnel descriptor. Relay hosts are re-imported
+ via a fresh pairing link instead. */}
+ {host.relay ? null : (
+ beginEditDirectHost(host)} disabled={directSaving}>
+
+ {t('desktopHostSwitcher.actions.edit')}
+
+ )}
void handleRemoveDirectHost(host.id)} disabled={directSaving}>
{t('settings.common.actions.delete')}
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index ac318200..7e145137 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -1117,17 +1117,35 @@ async function main(options = {}) {
// a specific non-loopback host → that host), NOT from how the UI was opened — so
// "Local network" works even when the UI is opened on localhost, and is absent
// when the server is only bound to loopback (a LAN link would not connect).
- const resolvePairingTransports = () => {
+ // The IPv4 the requesting client actually reached this server on (if any).
+ // Strips the IPv6-mapped prefix; loopback means "not a LAN path".
+ const requestReachedLanAddress = (req) => {
+ const raw = typeof req?.socket?.localAddress === 'string' ? req.socket.localAddress : '';
+ const address = raw.startsWith('::ffff:') ? raw.slice(7) : raw;
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(address)) return null;
+ if (address.startsWith('127.')) return null;
+ return address;
+ };
+ const resolvePairingTransports = (req) => {
const activePort = tunnelRuntimeContext.getActivePort() || port;
const local = `http://127.0.0.1:${activePort}`;
let lanHost = null;
if (isNetworkExposedBindHost(effectiveBindHost)) {
+ // Prefer the address the client is ALREADY talking to us on — it is the
+ // one interface guaranteed to be routable from that client's network.
+ // Interface scanning is only a fallback: on servers with virtual bridges
+ // (docker0 etc.) the first non-internal IPv4 can be an address no other
+ // machine can reach, which produced pairing links whose LAN candidate
+ // silently failed and forced devices onto the relay.
+ lanHost = requestReachedLanAddress(req);
try {
- for (const list of Object.values(os.networkInterfaces())) {
- for (const entry of (list || [])) {
- if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; }
+ if (!lanHost) {
+ for (const list of Object.values(os.networkInterfaces())) {
+ for (const entry of (list || [])) {
+ if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; }
+ }
+ if (lanHost) break;
}
- if (lanHost) break;
}
} catch {
lanHost = null;
diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js
index d6ee822b..95cfc502 100644
--- a/packages/web/server/lib/opencode/core-routes.js
+++ b/packages/web/server/lib/opencode/core-routes.js
@@ -800,7 +800,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => {
res.setHeader('Cache-Control', 'no-store');
- res.json(getPairingTransports());
+ res.json(getPairingTransports(req));
});
});