diff --git a/packages/app/src/servers/connect/pairing.test.ts b/packages/app/src/servers/connect/pairing.test.ts index 8b7593834c6..978af88ba53 100644 --- a/packages/app/src/servers/connect/pairing.test.ts +++ b/packages/app/src/servers/connect/pairing.test.ts @@ -4,7 +4,18 @@ import { pairingLink } from "./pairing" describe("pairing link", () => { test("reads the server address and code from opencode pair links", () => { expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({ - url: "http://192.168.1.2:49374", + urls: ["http://192.168.1.2:49374"], + code: "abc_DEF-123", + }) + }) + + test("reads every server address from opencode pair QR codes", () => { + expect( + pairingLink( + JSON.stringify({ code: "abc_DEF-123", urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"] }), + ), + ).toEqual({ + urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"], code: "abc_DEF-123", }) }) @@ -16,4 +27,12 @@ describe("pairing link", () => { expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined() expect(pairingLink("192.168.1.2:49374")).toBeUndefined() }) + + test("rejects malformed QR payloads", () => { + expect(pairingLink(JSON.stringify({ code: "abc", urls: [] }))).toBeUndefined() + expect(pairingLink(JSON.stringify({ code: "a/b", urls: ["http://192.168.1.2:49374"] }))).toBeUndefined() + expect(pairingLink(JSON.stringify({ code: "abc", urls: ["opencode-ios://host"] }))).toBeUndefined() + expect(pairingLink(JSON.stringify({ urls: ["http://192.168.1.2:49374"] }))).toBeUndefined() + expect(pairingLink("{not json")).toBeUndefined() + }) }) diff --git a/packages/app/src/servers/connect/pairing.ts b/packages/app/src/servers/connect/pairing.ts index 3ce428adbdd..f86a1dc7bfd 100644 --- a/packages/app/src/servers/connect/pairing.ts +++ b/packages/app/src/servers/connect/pairing.ts @@ -1,4 +1,5 @@ import { OpenCode } from "@opencode/client/promise" +import { Option, Schema } from "effect" import { normalizeServerUrl } from "@/runtime/server/registry" export function serverAddress(value: string) { @@ -15,26 +16,48 @@ export function serverAddress(value: string) { return normalized } -// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token. +const CODE = /^[A-Za-z0-9_-]+$/ + +const decodePayload = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ code: Schema.String, urls: Schema.Array(Schema.String) })), +) + +// `opencode pair` prints links carrying a single-use code that the server exchanges for a session token. +// Its QR code carries the same code with every reachable server address as {"code","urls"} JSON. export function pairingLink(value: string) { - const url = URL.parse(value.trim()) + const trimmed = value.trim() + + if (trimmed.startsWith("{")) { + const payload = Option.getOrUndefined(decodePayload(trimmed)) + + if (!payload || !CODE.test(payload.code)) return + const urls = [...new Set(payload.urls)].map(serverAddress) + + if (urls.length === 0 || urls.some((url) => url === undefined)) return + + return { urls: urls.filter((url) => url !== undefined), code: payload.code } + } + + const url = URL.parse(trimmed) if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return - const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1] + const code = /^\/auth\/connect\/([^/]+)$/.exec(url.pathname)?.[1] const address = serverAddress(url.origin) - if (!code || !address) return + if (!code || !CODE.test(code) || !address) return - return { url: address, code } + return { urls: [address], code } } export type Pairing = { readonly url: string; readonly password: string } -export function redeemPairingLink(link: { url: string; code: string }) { - return OpenCode.make({ baseUrl: link.url }) - .server.connect({ code: link.code }) - .then( - (session): Pairing => ({ url: link.url, password: session.token }), - () => undefined, - ) +// Every address may reach the same server, but the code is single-use, so at most one attempt succeeds. +export function redeemPairingLink(link: { urls: ReadonlyArray; code: string }) { + return Promise.any( + link.urls.map((url) => + OpenCode.make({ baseUrl: url }) + .server.connect({ code: link.code }, { signal: AbortSignal.timeout(5_000) }) + .then((session): Pairing => ({ url, password: session.token })), + ), + ).catch(() => undefined) } diff --git a/packages/app/src/servers/connect/screen.tsx b/packages/app/src/servers/connect/screen.tsx index b88a9a825b3..1bc2b26f507 100644 --- a/packages/app/src/servers/connect/screen.tsx +++ b/packages/app/src/servers/connect/screen.tsx @@ -44,7 +44,7 @@ export function ConnectServerScreen(props: { url?: string } = {}) { } // Keep the token in the form so a failed connection check can retry without the spent code. - setState({ url: link.url, password: redeemed.password }) + setState({ url: redeemed.url, password: redeemed.password }) } const url = serverAddress(state.url) diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 4d7e1d37905..bd4b349f82a 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -21,17 +21,21 @@ export default Runtime.handler( : (yield* Effect.tryPromise(() => client.server.info())).urls const pairing = yield* Effect.tryPromise(() => client.server.pair()) const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href) + // Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses. + const remote = urls.filter((url) => !isLoopback(new URL(url).hostname)) process.stdout.write( [ "", ` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`, "", ...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]), - ...(links[0] + ...(remote.length ? [ "", // uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row. - renderUnicodeCompact(links[0], { border: 2 }) + renderUnicodeCompact(JSON.stringify({ code: pairing.code, urls: remote }), { + border: 2, + }) .split("\n") .map((line) => " " + line) .join(EOL), @@ -56,3 +60,9 @@ export default Runtime.handler( ) }), ) + +function isLoopback(hostname: string) { + return ( + hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]" + ) +} diff --git a/packages/gui-extensions/src/pairing/page.tsx b/packages/gui-extensions/src/pairing/page.tsx index c30a0d807f2..3da07a10d5f 100644 --- a/packages/gui-extensions/src/pairing/page.tsx +++ b/packages/gui-extensions/src/pairing/page.tsx @@ -36,8 +36,9 @@ function SettingsPairing(props: { client: Client }) { // Reading pending query data would suspend the entire settings surface. const localInfo = () => (local.isSuccess ? local.data : undefined) - const localHost = createMemo(() => - localInfo()?.urls.find((value) => { + // Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses. + const localHosts = createMemo(() => + (localInfo()?.urls ?? []).filter((value) => { const host = new URL(value).hostname return ( @@ -78,10 +79,15 @@ function SettingsPairing(props: { client: Client }) {