Compare commits

...
Author SHA1 Message Date
Brendan Allan c17d697cc1 fix(app): make QR pairing work across origins 2026-10-05 10:30:12 +08:00
13 changed files with 158 additions and 88 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:web": "VITE_OPENCODE_SERVER_MODE=none bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd services/www dev",
+12 -12
View File
@@ -83,23 +83,23 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
`VITE_OPENCODE_SERVER_MODE` controls whether the web build expects an API on its own origin:
| Mode | Initial server |
| ------------------ | ------------------------------------------------------------------------------------ |
| `none` | No initial server. The beta deployment uses this mode. |
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
| Mode | Same-origin API |
| ------------------ | ------------------------------------------------------------------------------------- |
| `none` | Not expected. The beta deployment and `bun run dev:web` use this mode. |
| `origin` (default) | Expected. CLI builds use this mode because `opencode serve` hosts the API and web UI. |
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
(default: `http://localhost:4096`) instead of the frontend origin. The expected server is registered at startup
only when the URL includes credentials; an unauthenticated visit starts with an empty server list. Both modes
restore user-added servers from storage. Desktop provides the local server it discovers or starts through native
initialization.
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
connection form. Enter a server address and password or scan a pairing QR code to connect. Failed connections
leave the details available to edit and retry with **Connect**. Credentials are checked before saving the server.
Saved offline servers continue to use the normal app UI.
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
+20 -12
View File
@@ -77,23 +77,31 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
const currentServer = web.currentServerUrl ? ServerConnection.Key.make(web.currentServerUrl) : undefined
const server: ServerConnection.Http | undefined =
web.currentServerUrl && auth
? {
type: "http",
authToken: true,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
defaultServer={
web.defaultServerUrl
? ServerConnection.Key.make(web.defaultServerUrl)
: server
? currentServer
: undefined
}
canonicalLocalServer={currentServer}
servers={server ? [server] : []}
>
<KeyboardInsets />
+2
View File
@@ -340,6 +340,8 @@ export const dict = {
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.link.failed":
"Could not use this pairing code. Make sure this device can reach the server, then scan a new code.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
+1 -1
View File
@@ -48,7 +48,7 @@ export function createWebPlatform(version: string) {
return {
platform,
currentServerUrl,
defaultServerUrl: storedServerUrl ?? currentServerUrl,
defaultServerUrl: storedServerUrl,
}
}
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"
import { pairingLink } from "./pairing"
import { pairingLink, redeemPairingLink } from "./pairing"
const mockFetch = (run: () => Promise<Response>) => run as unknown as typeof globalThis.fetch
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
@@ -17,3 +19,42 @@ describe("pairing link", () => {
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
})
})
describe("pairing redemption", () => {
test("returns the session token from a successful redemption", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "fresh" },
mockFetch(() => Promise.resolve(Response.json({ token: "session-token" }))),
)
expect(result).toEqual({
ok: true,
pairing: { url: "https://server.example", password: "session-token" },
})
})
test("reports only an unauthorized response as expired", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "used" },
mockFetch(() =>
Promise.resolve(
Response.json(
{ _tag: "UnauthorizedError", message: "Pairing link expired or already used" },
{ status: 401 },
),
),
),
)
expect(result).toEqual({ ok: false, reason: "expired" })
})
test("reports transport failures as connection errors", async () => {
const result = await redeemPairingLink(
{ url: "https://server.example", code: "fresh" },
mockFetch(() => Promise.reject(new TypeError("Failed to fetch"))),
)
expect(result).toEqual({ ok: false, reason: "connection" })
})
})
+11 -5
View File
@@ -1,4 +1,4 @@
import { OpenCode } from "@opencode/client/promise"
import { isUnauthorizedError, OpenCode } from "@opencode/client/promise"
import { normalizeServerUrl } from "@/runtime/server/registry"
export function serverAddress(value: string) {
@@ -22,12 +22,18 @@ export function pairingLink(value: string) {
}
export type Pairing = { readonly url: string; readonly password: string }
export type PairingRedemption =
| { readonly ok: true; readonly pairing: Pairing }
| { readonly ok: false; readonly reason: "expired" | "connection" }
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
export function redeemPairingLink(link: { url: string; code: string }, fetch?: typeof globalThis.fetch) {
return OpenCode.make({ baseUrl: link.url, fetch })
.server.connect({ code: link.code })
.then(
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
(session): PairingRedemption => ({ ok: true, pairing: { url: link.url, password: session.token } }),
(error): PairingRedemption => ({
ok: false,
reason: isUnauthorizedError(error) ? "expired" : "connection",
}),
)
}
@@ -18,6 +18,12 @@
color: var(--v2-state-fg-danger);
}
.server-connect-scan-alert {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
+50 -21
View File
@@ -4,19 +4,29 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import { isMixedContent } from "./browser"
import "./scanner.css"
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const [state, setState] = createStore({ error: "", ready: false, paused: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
let scanner: QrScanner | undefined
const start = () => {
setState({ error: "", ready: false, paused: false })
void scanner?.start().then(
() => setState("ready", true),
() => setState({ error: language.t("server.connect.camera.error"), paused: true }),
)
}
onMount(() => {
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
const instance = new QrScanner(
video,
(result) => {
const link = pairingLink(result.data)
@@ -24,36 +34,55 @@ export function PairingScanner(props: { onScan: (value: Pairing) => void; onCanc
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
scanner?.stop()
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
if (redeemed.ok) return props.onScan(redeemed.pairing)
setState({
error: language.t(
redeemed.reason === "expired"
? "server.connect.link.expired"
: isMixedContent(location.href, link.url)
? "server.connect.mixedContent"
: "server.connect.link.failed",
),
paused: true,
})
})
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
scanner = instance
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
)
instance.setInversionMode("both")
onCleanup(() => instance.destroy())
start()
})
return (
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
<Show
when={!state.paused}
fallback={
<div class="server-connect-scan-alert" role="alert">
<p class="server-connect-error">{state.error}</p>
<Button variant="contrast" size="large" onClick={start}>
{language.t("common.retry")}
</Button>
</div>
}
>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button variant="neutral" size="large" onClick={props.onCancel}>
{language.t("common.cancel")}
@@ -77,21 +77,4 @@
.server-connect-error {
color: var(--v2-state-fg-danger);
}
footer {
text-align: center;
margin-block-start: 16px;
}
code {
display: inline-block;
margin-block-start: 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
line-height: var(--line-height-base);
background: var(--v2-background-bg-layer-01);
user-select: all;
}
}
+4 -16
View File
@@ -8,7 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import { serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
@@ -30,16 +30,6 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
)
const request = useMutation(() => ({
mutationFn: async () => {
const link = pairingLink(state.url)
if (link) {
const redeemed = await redeemPairingLink(link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
return
}
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
@@ -64,7 +54,9 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
</div>
<header>
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
<p>{language.t("server.connect.description")}</p>
<Show when={!state.scanning}>
<p>{language.t("server.connect.description")}</p>
</Show>
</header>
<Show
when={!state.scanning}
@@ -153,10 +145,6 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
</p>
</Show>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
+5 -1
View File
@@ -1,5 +1,5 @@
import { Duration, Effect } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { PersistentPty } from "@opencode/core/persistent-pty"
import { UnauthorizedError } from "@opencode/protocol/errors"
@@ -31,6 +31,10 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler
"server.connect",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
// The single-use URL is the credential, and scanners may run on a different origin from the advertised server.
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "access-control-allow-origin", "*")),
)
// Browser navigations ask for HTML; everything else is an API client that wants the token.
const browser = request.headers.accept?.includes("text/html") === true
const token = (yield* pairing.consume(ctx.params.code)) ? ServerAuth.issueSession(auth) : undefined
+4 -1
View File
@@ -217,8 +217,11 @@ it.live("pairing links sign in browsers with a cookie and API clients with a tok
expect((yield* request("/api/info", { headers: { cookie: `${cookie}x` } })).status).toBe(401)
const client = yield* pair
const redeemed = yield* request(`/auth/connect/${client.code}`)
const redeemed = yield* request(`/auth/connect/${client.code}`, {
headers: { origin: "https://device.example.ts.net" },
})
expect(redeemed.status).toBe(200)
expect(redeemed.headers.get("access-control-allow-origin")).toBe("*")
const session = (yield* Effect.promise(() => redeemed.json())) as { token: string }
expect(
(yield* request("/api/info", { headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` } }))