feat(cli): include every pairing address in QR codes (#53588)

This commit is contained in:
Dax
2026-10-06 16:13:34 -04:00
committed by GitHub
Unverified
parent 36a38058fb
commit 4a0f273fa7
6 changed files with 96 additions and 28 deletions
@@ -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()
})
})
+35 -12
View File
@@ -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<string>; 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)
}
+1 -1
View File
@@ -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)
+12 -2
View File
@@ -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]"
)
}
+22 -9
View File
@@ -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 }) {
<Row title={ctx.t("connection")} description={ctx.t("local.description")}>
<Button
variant="neutral"
disabled={!localHost()}
disabled={localHosts().length === 0}
onClick={() =>
dialogs.open(() => (
<DialogPairing title={ctx.t("connection")} host={localHost()!} code={() => props.client.code()} />
<DialogPairing
title={ctx.t("connection")}
host={localHosts()[0]!}
hosts={localHosts()}
code={() => props.client.code()}
/>
))
}
>
@@ -120,7 +126,12 @@ function SettingsPairing(props: { client: Client }) {
/** The pending return from "Copied" to the copy label. */
type CopiedTimer = { timeout?: ReturnType<typeof setTimeout> }
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
function DialogPairing(props: {
title: string
host: string
hosts: ReadonlyArray<string>
code: () => Promise<string>
}) {
const ctx = useExtension()
const system = ctx.system
@@ -156,11 +167,13 @@ function DialogPairing(props: { title: string; host: string; code: () => Promise
}))
const qr = createMemo(() => {
const value = url()
if (!code.isSuccess) return
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
return renderSVG(JSON.stringify({ code: code.data, urls: props.hosts }), {
border: 4,
blackColor: "currentColor",
whiteColor: "transparent",
})
})
return (
+6 -3
View File
@@ -21,9 +21,12 @@ export function connectionURLs(value: string, requestedHostname?: string) {
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
if (family === undefined) return [value]
const loopback = new URL(value)
loopback.hostname = family === "IPv6" ? "[::1]" : "127.0.0.1"
return [
...new Set(
Object.values(networkInterfaces())
...new Set([
loopback.toString().replace(/\/$/, ""),
...Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => !entry.internal && entry.family === family)
.map((entry) => {
@@ -31,7 +34,7 @@ export function connectionURLs(value: string, requestedHostname?: string) {
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
return result.toString().replace(/\/$/, "")
}),
),
]),
]
}