feat(app): replace password pairing with one-time links (#50972)

This commit is contained in:
Shoubhit Dash
2026-09-24 18:28:48 +05:30
committed by GitHub
parent eccf0b3b7b
commit 2caba90a63
27 changed files with 201 additions and 448 deletions
+5 -4
View File
@@ -94,9 +94,10 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
(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.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
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.
@@ -106,7 +107,7 @@ When the service is exposed through an HTTPS reverse proxy, advertise its extern
opencode pair --url https://opencode.example.com
```
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
This replaces the addresses in the printed links and QR code.
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
@@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
+16 -24
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
@@ -24,8 +24,6 @@ type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
@@ -141,6 +139,8 @@ type PlatformBase = {
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
/** Single-use code for an `/auth/connect/:code` link. */
code(): Promise<string>
}
}
@@ -29,6 +29,17 @@ describe("checkServerHealth", () => {
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
})
test("reports rejected credentials without retrying", async () => {
let calls = 0
const fetch = (async () => {
calls++
return Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: false, unauthorized: true })
expect(calls).toBe(1)
})
test("returns healthy response with version", async () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
+9 -3
View File
@@ -1,11 +1,17 @@
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { authTokenFromCredentials } from "./api"
import { ClientError, OpenCode } from "@opencode/client"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
export type ServerHealth = {
healthy: boolean
version?: string
incompatible?: boolean
checking?: boolean
unauthorized?: boolean
}
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -100,7 +106,7 @@ export async function checkServerHealth(
.catch((error) => ({ error }))
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
if (isUnauthorizedError(current.error)) return { healthy: false, unauthorized: true }
return next(count, current.error)
}
return attempt(0).finally(() => timeout?.clear?.())
+4 -1
View File
@@ -48,15 +48,18 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
return serverCtx
}
// A server that rejects our credentials would retry its event stream every second with the same
// credentials, so its controller waits until health recovers and then starts with the current ones.
createMemo(() => {
for (const conn of server.list) {
if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxs.delete(key)
+3 -13
View File
@@ -29,7 +29,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import { decodePairingCode } from "./pairing"
import type { Pairing } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -117,16 +117,10 @@ export const DialogServer: Component<{
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
list="dialog-server-addresses"
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<datalist id="dialog-server-addresses">
{form.state.urls().map((url) => (
<option value={url} />
))}
</datalist>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
@@ -213,7 +207,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
urls: [] as string[],
scanning: false,
error: "",
status: undefined as boolean | undefined,
@@ -227,7 +220,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
@@ -333,11 +325,10 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
const pair = (pairing: Pairing) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
values: { ...store.values, url: pairing.url, password: pairing.password },
scanning: false,
error: "",
})
@@ -359,7 +350,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
urls: () => store.urls,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
@@ -1,98 +1,5 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingLink, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://opencode.example.com:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(url.search).toBe("")
expect(url.hash).not.toBe("")
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("keeps accepting query pairing data", () => {
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
urls: info.urls,
password: info.password,
})
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
test("decodes the raw JSON code", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
})
test("decodes a direct /connect URL", () => {
expect(
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
).toEqual({
urls: ["http://192.168.1.2:49374"],
password: info.password,
})
})
test("keeps accepting /connect URLs with query data", () => {
expect(
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
).toEqual({
urls: info.urls,
password: info.password,
})
})
test("falls back to the URL origin when the payload omits server URLs", () => {
const origin = "https://opencode.example.com:49709"
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
urls: [origin],
password: "secret",
})
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
import { pairingLink } from "./pairing"
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
+3 -51
View File
@@ -1,16 +1,6 @@
import { Option, Schema } from "effect"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
@@ -21,46 +11,6 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
}
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
export function decodePairingScan(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
}
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
const url = URL.parse(value.trim())
@@ -71,11 +21,13 @@ export function pairingLink(value: string) {
return { url: 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) => ({ urls: [link.url], password: session.token }),
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
)
}
+2 -11
View File
@@ -3,13 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingScan, pairingLink, redeemPairingLink } from "./pairing"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
@@ -22,12 +19,6 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingScan(result.data)
if (pairing) {
scanner.stop()
props.onScan(pairing)
return
}
const link = pairingLink(result.data)
if (!link) {
setState("error", language.t("server.connect.scan.invalid"))
+3 -25
View File
@@ -9,28 +9,19 @@ 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 type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
export function ConnectServerScreen(props: { url?: string } = {}) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -61,7 +52,6 @@ export function ConnectServerScreen(
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -86,13 +76,7 @@ export function ConnectServerScreen(
void camera.refetch()
}}
onScan={(pairing) => {
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
request.mutate()
}}
/>
@@ -120,18 +104,12 @@ export function ConnectServerScreen(
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
+19 -13
View File
@@ -8,8 +8,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -74,8 +73,8 @@ export function SettingsPairing() {
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()!}
code={pair.code}
/>
))
}
@@ -117,17 +116,19 @@ export function SettingsPairing() {
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
const language = useLanguage()
const platform = usePlatform()
// Codes are single-use, so keep replacing the link while the dialog is open.
const code = useQuery(() => ({
queryKey: ["pairing", "code"],
queryFn: props.code,
gcTime: 0,
refetchInterval: 60_000,
}))
const url = createMemo(() => {
if (!props.info) return
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
if (!code.isSuccess) return
return new URL(`/auth/connect/${code.data}`, props.host).href
})
const copy = useMutation(() => ({
mutationFn: async () => {
@@ -153,7 +154,7 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<Show when={url()}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
@@ -176,12 +177,17 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
{new URL(props.host).origin}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={code.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
+33 -47
View File
@@ -1,5 +1,5 @@
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,7 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
@@ -28,7 +27,6 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -36,48 +34,29 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
)
}
@@ -97,8 +76,15 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
const global = useGlobal()
// A lone server that rejects our credentials (e.g. the web app before pairing) has nothing else to show.
const signedOut = () => {
const only = servers.list.length === 1 ? servers.list[0] : undefined
if (only?.type !== "http") return
return global.servers.health[ServerConnection.key(only)]?.unauthorized ? only : undefined
}
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
+1 -1
View File
@@ -13,7 +13,7 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
// Serve the web shell before API authentication so a signed-out browser gets the app's sign-in screen.
if (
url.pathname === "/api" ||
url.pathname.startsWith("/api/") ||
+1 -1
View File
@@ -35,7 +35,7 @@ describe("web UI", () => {
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/settings",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
@@ -76,6 +76,7 @@ export const appHandlers = AppRpcs.toLayer(
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
AppPairInfo: () => pair(pairing.info),
AppPairCode: () => pair(pairing.code),
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
AppSetKeepScreenActive: ({ enabled }) =>
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
+6 -9
View File
@@ -1,21 +1,18 @@
import { SidecarCredentials } from "./sidecar-credentials"
export function createPairing() {
const requireCredentials = () => {
const client = async () => {
const credentials = SidecarCredentials.get()
if (!credentials) throw new Error("The local desktop server is not ready")
return credentials
}
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
const { OpenCode } = await import("@opencode/client/promise")
const info = await OpenCode.make({
return OpenCode.make({
baseUrl: credentials.url,
headers: credentials.password
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
: undefined,
}).server.info()
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
})
}
const info = () => readInfo(requireCredentials())
return { info }
const info = async () => ({ urls: (await (await client()).server.info()).urls })
const code = async () => (await (await client()).server.pair()).code
return { info, code }
}
@@ -92,6 +92,7 @@ export type ElectronAPI = {
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
pairInfo(): Promise<typeof PairingInfo.Type>
pairCode(): Promise<string>
getKeepScreenActive(): Promise<boolean>
setKeepScreenActive(enabled: boolean): Promise<void>
}
+1
View File
@@ -163,6 +163,7 @@ export const api: ElectronAPI = {
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
pairInfo: () => invoke("AppPairInfo").then(mutable),
pairCode: () => invoke("AppPairCode"),
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
}
@@ -91,6 +91,7 @@ export function createDesktopPlatform(
},
pair: {
info: () => api.pairInfo(),
code: () => api.pairCode(),
},
}
}
+2 -2
View File
@@ -7,8 +7,6 @@ const ServerReadyData = Schema.Struct({
export const PairingInfo = Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
@@ -60,6 +58,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
export const AppPairCode = Rpc.make("AppPairCode", { success: Schema.String, error: Schema.String })
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
payload: { enabled: Schema.Boolean },
@@ -82,6 +81,7 @@ export const AppRpcs = RpcGroup.make(
AppSetNativeTranslations,
AppRelaunch,
AppPairInfo,
AppPairCode,
AppGetKeepScreenActive,
AppSetKeepScreenActive,
)
@@ -37,6 +37,23 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
return Effect.succeed(emptyCredential())
}
const UNAUTHORIZED_MESSAGE = "Authentication required"
// Browsers show a native credentials prompt for a Basic challenge even on fetch, which would stall the web app
// before it can show its own sign-in screen. Only non-browser clients and page navigations get the challenge.
function challengeRequest(request: HttpServerRequest.HttpServerRequest) {
const mode = request.headers["sec-fetch-mode"]
return mode === undefined || mode === "navigate"
}
// Matches what the Authorization middleware encodes, for requests rejected before the HttpApi runs.
export function unauthorizedResponse(request: HttpServerRequest.HttpServerRequest) {
return HttpServerResponse.jsonUnsafe(
{ _tag: "UnauthorizedError", message: UNAUTHORIZED_MESSAGE },
{ status: 401, headers: challengeRequest(request) ? { "www-authenticate": WWW_AUTHENTICATE } : undefined },
)
}
export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
return credentialFromRequest(request).pipe(
Effect.map((credential) => ServerAuth.authorized(credential, config) || authorizedSessionCookie(request, config)),
@@ -66,10 +83,11 @@ export const authorizationLayer = Layer.effect(
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
if (challengeRequest(request))
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: UNAUTHORIZED_MESSAGE })
}),
)
}),
+3 -10
View File
@@ -13,7 +13,7 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
import { authorizedRequest } from "./middleware/authorization"
import { authorizedRequest, unauthorizedResponse } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
@@ -183,7 +183,7 @@ function dispatch(
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if (request.method === "GET" && url.pathname === "/api/info" && !ready) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
if (!(yield* authorizedRequest(request, auth))) return unauthorizedResponse(request)
return yield* infoResponse(status, version, urls, tmp)
}
if (
@@ -191,19 +191,12 @@ function dispatch(
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
return unauthorizedResponse(request)
if (ready) return yield* app.value
return unavailable(state)
})
}
function unauthorized() {
return HttpServerResponse.empty({
status: 401,
headers: { "www-authenticate": 'Basic realm="Secure Area"' },
})
}
const infoResponse = Effect.fnUntraced(function* (
status: Status.Interface,
version: string,
+11 -2
View File
@@ -128,7 +128,10 @@ it.live("authenticates API requests behind the frontend transform while allowing
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, HttpServer.formatAddress(server.address))))
expect(response.status).toBe(401)
expect(yield* Effect.promise(() => response.text())).toBe("")
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "UnauthorizedError",
message: "Authentication required",
})
}),
)
@@ -185,7 +188,13 @@ it.live("pairing links sign in browsers with a cookie and API clients with a tok
return (yield* Effect.promise(() => response.json())) as { code: string; expires_in: number }
})
expect((yield* request("/api/pair", { method: "POST" })).status).toBe(401)
const rejected = yield* request("/api/pair", { method: "POST" })
expect(rejected.status).toBe(401)
expect(rejected.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
// A Basic challenge on fetch makes browsers show a native prompt instead of the app's sign-in screen.
const fetched = yield* request("/api/info", { headers: { "sec-fetch-mode": "cors" } })
expect(fetched.status).toBe(401)
expect(fetched.headers.get("www-authenticate")).toBeNull()
const browser = yield* pair
expect(browser.expires_in).toBe(300)
+4 -13
View File
@@ -63,7 +63,7 @@ import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogPair } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -403,16 +403,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
<App />
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
@@ -465,7 +456,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials }) {
function App() {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -1007,7 +998,7 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Pair device",
slash: { name: "pair", aliases: ["web"] },
run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />)
dialog.replace(() => <DialogPair />)
},
category: "System",
},
+36 -64
View File
@@ -8,95 +8,65 @@ import { useDialog } from "../ui/dialog"
import { Link } from "../ui/link"
import { errorMessage } from "../util/error"
export type DialogPairCredentials = {
readonly username: string
readonly password: string
}
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
export function DialogPair() {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
dialog.setSize("large")
dialog.setCentered(true)
const [server] = createResource(() =>
client.api.server.info().catch((error) => {
setLoadError(error)
return undefined
}),
const [info] = createResource(() =>
Promise.all([client.api.server.info(), client.api.server.pair()])
.then(([server, pairing]) => {
const link = (url: string) => new URL(`/auth/connect/${pairing.code}`, url).href
const local = server.urls[0] ? new URL(server.urls[0]) : undefined
if (local) local.hostname = "localhost"
return {
links: server.urls.map(link),
localhost: local ? link(local.href) : undefined,
minutes: Math.round(pairing.expires_in / 60),
loopback: server.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname)),
}
})
.catch((error) => {
setLoadError(error)
return undefined
}),
)
const info = createMemo(() => {
const current = server()
if (!current) return
return {
urls: current.urls,
username: props.credentials?.username ?? "opencode",
password: props.credentials?.password ?? "",
}
})
const localhost = createMemo(() => {
const value = info()?.urls[0]
if (!value) return ""
const url = new URL(value)
url.hostname = "localhost"
return url.toString().replace(/\/$/, "")
})
const horizontal = createMemo(() => dimensions().width >= 96)
const content = () => {
const value = info()
if (!value) return
const href = (input: string) => {
const url = new URL(input)
url.username = encodeURIComponent(value.username)
url.password = encodeURIComponent(value.password)
return url.toString()
}
return (
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.text.muted}>This device</text>
<Show when={localhost()}>
{(url) => (
<Link href={href(url())} fg={theme.text.base}>
<text fg={theme.text.muted} wrapMode="word">
Open a link to connect. Links work once and expire in {value.minutes} minutes.
</text>
<Show when={value.localhost}>
{(url) => (
<box>
<text fg={theme.text.muted}>This device</text>
<Link href={url()} fg={theme.text.base}>
{url()}
</Link>
)}
</Show>
</box>
</box>
)}
</Show>
<box>
<text fg={theme.text.muted}>URLs</text>
<For each={value.urls}>
<text fg={theme.text.muted}>Links</text>
<For each={value.links}>
{(url) => (
<Link href={href(url)} fg={theme.text.base}>
<Link href={url} fg={theme.text.base}>
{url}
</Link>
)}
</For>
</box>
<box>
<text fg={theme.text.muted}>Username</text>
<text fg={theme.text.base}>{value.username}</text>
</box>
<box>
<text fg={theme.text.muted}>Password</text>
<text
fg={passwordHover() ? theme.text.base : theme.text.muted}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
onMouseUp={() => setShowPassword((current) => !current)}
>
{showPassword() ? value.password : "************"}
</text>
</box>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<Show when={value.loopback}>
<text fg={theme.text.muted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
@@ -108,7 +78,9 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={theme.text.base}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<Show when={value.links[0]}>
{(url) => <text fg={theme.text.base}>{renderUnicodeCompact(url(), { border: 1 })}</text>}
</Show>
</box>
</box>
)