mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 07:26:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17d697cc1 |
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)}` } }))
|
||||
|
||||
Reference in new issue
Block a user