diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f14abdd..4711f035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay. - Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time. ## [1.18.2] - 2026-08-10 diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index a460aec0..ee227a95 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -560,6 +560,23 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { } }; + const candidateUrlType = (url) => { + try { + return new URL(url).protocol === 'https:' ? 'tunnel' : 'lan'; + } catch { + return 'lan'; + } + }; + + const isLoopbackCandidateUrl = (url) => { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; + } catch { + return true; + } + }; + // `preferredServerUrl` is the caller-supplied externally reachable URL (the // desktop UI reaches its own server over loopback, so the request origin is not // scannable — it passes the LAN URL instead). Falls back to the request origin @@ -573,15 +590,21 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => { const pairingServerCandidates = async (req, { preferredServerUrl, includeRelay, includeDirect = true } = {}) => { const candidates = []; if (includeDirect) { - const direct = normalizeCandidateUrl(preferredServerUrl) || requestOrigin(req); + const preferred = normalizeCandidateUrl(preferredServerUrl); + const origin = normalizeCandidateUrl(requestOrigin(req)); + const direct = preferred || origin; if (direct) { - let type = 'lan'; - try { - const parsed = new URL(direct); - type = parsed.protocol === 'https:' ? 'tunnel' : 'lan'; - } catch { - } - candidates.push({ type, url: direct, priority: 10 }); + candidates.push({ type: candidateUrlType(direct), url: direct, priority: 10 }); + } + // The origin the creator is browsing over (e.g. a public https domain in + // front of a reverse proxy) is a reachable address the server cannot + // discover from its own interfaces. Carry it as an additional direct + // candidate so the paired device can keep using that same domain instead + // of depending on LAN hairpin behavior or relay availability. Loopback + // origins (desktop shell, localhost dev) are unreachable from another + // device and are skipped. + if (origin && direct && origin !== direct && !isLoopbackCandidateUrl(origin)) { + candidates.push({ type: candidateUrlType(origin), url: origin, priority: 20 }); } } // The client races candidates and falls back to relay only if the direct URL diff --git a/packages/web/server/lib/opencode/core-routes.test.js b/packages/web/server/lib/opencode/core-routes.test.js index 145484cf..b01531b9 100644 --- a/packages/web/server/lib/opencode/core-routes.test.js +++ b/packages/web/server/lib/opencode/core-routes.test.js @@ -331,12 +331,42 @@ describe('core-routes', () => { }); }); - it('advertises the caller-supplied serverUrl as the direct candidate over the request origin', async () => { + it('advertises the caller-supplied serverUrl first and keeps the request origin as a fallback candidate', async () => { const { app } = createPairingRouteApp(); const response = await request(app) .post('/api/client-auth/pairing/sessions') - .set('Host', 'runtime.example') + .set('Host', 'chamber.example.com') + .set('X-Forwarded-Proto', 'https') + .send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' }) + .expect(201); + + expect(response.body.server.candidates).toEqual([ + { type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 }, + { type: 'tunnel', url: 'https://chamber.example.com', priority: 20 }, + ]); + }); + + it('does not duplicate the request origin when it matches the caller-supplied serverUrl', async () => { + const { app } = createPairingRouteApp(); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', '192.168.1.20:2606') + .send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' }) + .expect(201); + + expect(response.body.server.candidates).toEqual([ + { type: 'lan', url: 'http://192.168.1.20:2606', priority: 10 }, + ]); + }); + + it('skips a loopback request origin as the fallback candidate', async () => { + const { app } = createPairingRouteApp(); + + const response = await request(app) + .post('/api/client-auth/pairing/sessions') + .set('Host', '127.0.0.1:2606') .send({ label: 'Pair phone', serverUrl: 'http://192.168.1.20:2606' }) .expect(201);