diff --git a/packages/app/e2e/smoke/remote-mobile.spec.ts b/packages/app/e2e/smoke/remote-mobile.spec.ts new file mode 100644 index 000000000000..a337f22d19de --- /dev/null +++ b/packages/app/e2e/smoke/remote-mobile.spec.ts @@ -0,0 +1,310 @@ +import { expect, test } from "@playwright/test" +import * as http from "node:http" +import type { AddressInfo } from "node:net" +import { createRemoteGateway } from "../../../desktop/src/main/remote-gateway" +import { markup as remoteMobileMarkup } from "../../../opencode/src/remote/mobile" + +const SESSION_ID = "ses_remote_e2e" +const TICKET = "ticket-remote-e2e" +const TOKEN = "grant-remote-e2e" + +type RemoteMessage = { + info: { role: "user" | "assistant" } + parts: Array<{ type: "text"; text: string }> +} + +type RemotePermission = { + id: string + permission: string + patterns: string[] +} + +type RemoteQuestion = { + id: string + questions: Array<{ + question: string + header: string + multiple: boolean + custom: boolean + options: Array<{ label: string; description?: string }> + }> +} + +type RemoteFixtureState = { + ticketRedeemed: boolean + messages: RemoteMessage[] + status: "idle" | "busy" + permissions: RemotePermission[] + questions: RemoteQuestion[] + prompt?: string + aborted: boolean + permissionReply?: string + questionAnswers?: string[][] +} + +test.use({ + viewport: { width: 390, height: 844 }, + video: "on", +}) + +test.describe("smoke: mobile remote control", () => { + test("pairs and controls a desktop session through the LAN gateway", async ({ page, request }, testInfo) => { + const upstream = await startRemoteUpstream() + const gateway = createRemoteGateway({ upstreamUrl: upstream.origin }) + + try { + const info = await gateway.start() + const gatewayOrigin = `http://127.0.0.1:${info.port}` + + const blocked = await request.get(`${gatewayOrigin}/session/${SESSION_ID}`) + expect(blocked.status()).toBe(404) + + await page.goto(`${gatewayOrigin}/remote/mobile#ticket=${TICKET}`) + + await expect(page).toHaveURL(`${gatewayOrigin}/remote/mobile`) + await expect(page.getByText("Remote E2E Session", { exact: true })).toBeVisible() + await expect(page.getByText("Desktop session is ready.", { exact: true })).toBeVisible() + await expect(page.getByText("idle · live", { exact: true })).toBeVisible() + await expect(page.getByRole("heading", { name: "Permission: bash" })).toBeVisible() + await expect(page.getByRole("heading", { name: "OpenCode needs your answer" })).toBeVisible() + + await testInfo.attach("remote-mobile-paired", { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }) + + await page.getByRole("button", { name: "Allow once" }).click() + await expect.poll(() => upstream.state.permissionReply).toBe("once") + await expect(page.getByRole("heading", { name: "Permission: bash" })).toBeHidden() + + const question = page.getByRole("group", { name: "Continue?" }) + await question.getByRole("radio", { name: /Yes/ }).check() + await page.getByRole("button", { name: "Submit" }).click() + await expect.poll(() => upstream.state.questionAnswers).toEqual([["Yes"]]) + await expect(page.getByRole("heading", { name: "OpenCode needs your answer" })).toBeHidden() + + const prompt = page.getByPlaceholder("Send an instruction to OpenCode…") + await prompt.fill("Summarize the latest changes") + await page.getByRole("button", { name: "Send" }).click() + + await expect.poll(() => upstream.state.prompt).toBe("Summarize the latest changes") + await expect(prompt).toHaveValue("") + await expect(page.getByText("Summarize the latest changes", { exact: true })).toBeVisible() + await expect(page.getByText("Acknowledged from desktop.", { exact: true })).toBeVisible() + await expect(page.getByText("busy · live", { exact: true })).toBeVisible() + + await testInfo.attach("remote-mobile-active", { + body: await page.screenshot({ fullPage: true }), + contentType: "image/png", + }) + + await page.getByRole("button", { name: "Stop" }).click() + await expect.poll(() => upstream.state.aborted).toBe(true) + await expect(page.getByText("idle · live", { exact: true })).toBeVisible() + } finally { + await gateway.stop() + await upstream.close() + } + }) +}) + +async function startRemoteUpstream() { + const streams = new Set() + const state: RemoteFixtureState = { + ticketRedeemed: false, + messages: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "Desktop session is ready." }], + }, + ], + status: "idle", + permissions: [{ id: "per_remote_e2e", permission: "bash", patterns: ["git status"] }], + questions: [ + { + id: "que_remote_e2e", + questions: [ + { + question: "Continue?", + header: "Continue?", + multiple: false, + custom: false, + options: [ + { label: "Yes", description: "Proceed with the task" }, + { label: "No" }, + ], + }, + ], + }, + ], + aborted: false, + } + + const emit = (type: string) => { + const data = `event: message\ndata: ${JSON.stringify({ id: `evt_${type}`, type, properties: { sessionID: SESSION_ID } })}\n\n` + for (const stream of streams) { + if (stream.destroyed || stream.writableEnded) continue + stream.write(data) + } + } + + const server = http.createServer((request, response) => { + void handleRemoteRequest({ request, response, state, streams, emit }).catch((error) => { + if (!response.headersSent) response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }) + response.end(error instanceof Error ? error.message : String(error)) + }) + }) + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening) + reject(error) + } + const onListening = () => { + server.off("error", onError) + resolve() + } + server.once("error", onError) + server.once("listening", onListening) + server.listen(0, "127.0.0.1") + }) + + const address = server.address() as AddressInfo + return { + origin: `http://127.0.0.1:${address.port}`, + state, + close: async () => { + for (const stream of streams) stream.end() + streams.clear() + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + server.closeIdleConnections() + server.closeAllConnections() + }) + }, + } +} + +async function handleRemoteRequest(input: { + request: http.IncomingMessage + response: http.ServerResponse + state: RemoteFixtureState + streams: Set + emit: (type: string) => void +}) { + const { request, response, state, streams, emit } = input + const method = request.method ?? "GET" + const url = new URL(request.url ?? "/", "http://remote.test") + + if (method === "GET" && url.pathname === "/remote/mobile") { + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; connect-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }) + response.end(remoteMobileMarkup()) + return + } + + if (method === "POST" && url.pathname === "/remote/pair") { + const payload = await readJson(request) + if (state.ticketRedeemed || payload.ticket !== TICKET) { + response.writeHead(403).end() + return + } + state.ticketRedeemed = true + return json(response, { token: TOKEN, sessionID: SESSION_ID, expires_in: 3600 }) + } + + if (url.pathname.startsWith(`/remote/session/${SESSION_ID}`)) { + if (request.headers.authorization !== `Bearer ${TOKEN}`) { + response.writeHead(403).end() + return + } + } + + const sessionRoot = `/remote/session/${SESSION_ID}` + if (method === "GET" && url.pathname === sessionRoot) { + return json(response, { + session: { title: "Remote E2E Session" }, + messages: state.messages, + status: { type: state.status }, + permissions: state.permissions, + questions: state.questions, + }) + } + + if (method === "GET" && url.pathname === `${sessionRoot}/events`) { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + "x-accel-buffering": "no", + }) + streams.add(response) + response.write( + `event: message\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", properties: { sessionID: SESSION_ID } })}\n\n`, + ) + request.once("close", () => streams.delete(response)) + return + } + + if (method === "POST" && url.pathname === `${sessionRoot}/permission/per_remote_e2e`) { + const payload = await readJson(request) + state.permissionReply = typeof payload.reply === "string" ? payload.reply : undefined + state.permissions = [] + json(response, true) + emit("permission.replied") + return + } + + if (method === "POST" && url.pathname === `${sessionRoot}/question/que_remote_e2e`) { + const payload = await readJson(request) + state.questionAnswers = Array.isArray(payload.answers) ? (payload.answers as string[][]) : undefined + state.questions = [] + json(response, true) + emit("question.replied") + return + } + + if (method === "POST" && url.pathname === `${sessionRoot}/message`) { + const payload = await readJson(request) + const parts = Array.isArray(payload.parts) ? payload.parts : [] + const first = parts[0] as { type?: unknown; text?: unknown } | undefined + const text = first?.type === "text" && typeof first.text === "string" ? first.text : "" + state.prompt = text + state.status = "busy" + state.messages.push( + { info: { role: "user" }, parts: [{ type: "text", text }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "Acknowledged from desktop." }] }, + ) + response.writeHead(204).end() + emit("message.updated") + return + } + + if (method === "POST" && url.pathname === `${sessionRoot}/abort`) { + state.aborted = true + state.status = "idle" + json(response, true) + emit("session.status") + return + } + + response.writeHead(404).end() +} + +async function readJson(request: http.IncomingMessage): Promise> { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + if (chunks.length === 0) return {} + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record +} + +function json(response: http.ServerResponse, value: unknown) { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }) + response.end(JSON.stringify(value)) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index dc6cdaa6f3fd..2111434008a4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -33,6 +33,7 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { reviewTooltipKeybind } from "../command-tooltip-keybind" import { useTitlebarRightMount } from "../titlebar" +import { SessionRemoteControl } from "./session-remote-control" const OPEN_APPS = [ "vscode", @@ -440,6 +441,7 @@ export function SessionHeader() {
+ @@ -531,6 +533,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { return (
+ diff --git a/packages/app/src/components/session/session-remote-control.tsx b/packages/app/src/components/session/session-remote-control.tsx new file mode 100644 index 000000000000..1cf4451864a9 --- /dev/null +++ b/packages/app/src/components/session/session-remote-control.tsx @@ -0,0 +1,207 @@ +import { Button } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { useSessionLayout } from "@/pages/session/session-layout" +import { decode64 } from "@/utils/base64" +import { encodeRemoteQr, REMOTE_QR_QUIET_ZONE, REMOTE_QR_SIZE, remoteQrPath } from "@/utils/remote-qr" +import { showToast } from "@/utils/toast" + +type RemoteDialogProps = { + urls: string[] + onDisconnect: () => Promise +} + +export function SessionRemoteControl() { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + const dialog = useDialog() + const { params } = useSessionLayout() + const [state, setState] = createStore({ loading: false }) + + const directory = createMemo(() => decode64(params.dir) ?? "") + const localSidecar = createMemo(() => { + const current = server.current + return current?.type === "sidecar" && current.variant === "base" + }) + const available = createMemo( + () => + platform.platform === "desktop" && + !!platform.createRemotePairing && + !!platform.revokeRemotePairing && + !!params.id && + !!directory() && + localSidecar(), + ) + + const fail = (reason?: "no-lan") => { + showToast({ + variant: "error", + title: language.t("session.remote.error.title"), + description: language.t( + reason === "no-lan" ? "session.remote.error.noLan" : "session.remote.error.description", + ), + }) + } + + const open = async () => { + const sessionID = params.id + const cwd = directory() + if (state.loading || !available() || !platform.createRemotePairing || !sessionID || !cwd) return + + setState("loading", true) + try { + const pairing = await platform.createRemotePairing(sessionID, cwd) + const urls = pairing.urls.length > 0 ? pairing.urls : [pairing.url] + dialog.show(() => ( + { + if (!platform.revokeRemotePairing) throw new Error("remote_revoke_unavailable") + await platform.revokeRemotePairing(sessionID, cwd) + }} + /> + )) + } catch (error) { + fail(error instanceof Error && error.message.includes("No local network address") ? "no-lan" : undefined) + } finally { + setState("loading", false) + } + } + + return ( + + + + + + ) +} + +function RemoteControlDialog(props: RemoteDialogProps) { + const language = useLanguage() + const dialog = useDialog() + const [state, setState] = createStore({ copying: false, disconnecting: false, index: 0 }) + const size = REMOTE_QR_SIZE + REMOTE_QR_QUIET_ZONE * 2 + const url = createMemo(() => props.urls[state.index] ?? props.urls[0]!) + const modules = createMemo(() => encodeRemoteQr(url())) + const path = createMemo(() => remoteQrPath(modules(), REMOTE_QR_QUIET_ZONE)) + + const copy = async () => { + if (state.copying) return + setState("copying", true) + try { + await navigator.clipboard.writeText(url()) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.remote.linkCopied"), + }) + } catch { + showToast({ + variant: "error", + title: language.t("session.remote.error.title"), + description: language.t("session.remote.error.description"), + }) + } finally { + setState("copying", false) + } + } + + const disconnect = async () => { + if (state.disconnecting) return + setState("disconnecting", true) + try { + await props.onDisconnect() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.remote.disconnected"), + }) + dialog.close() + } catch { + showToast({ + variant: "error", + title: language.t("session.remote.error.title"), + description: language.t("session.remote.error.description"), + }) + } finally { + setState("disconnecting", false) + } + } + + return ( + +
+
+ + + + +
+ +

{language.t("session.remote.networkNote")}

+ 1}> +
+ + {(item, index) => ( + + )} + +
+
+ + {url()} + + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index e387391f68bf..0221d93a265b 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -5,6 +5,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" +import { dict as remoteEn } from "@/i18n/remote" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { createDesktopNativeBundle, @@ -26,7 +27,7 @@ function localeDirection(locale: Locale): Direction { return RTL_LOCALES.has(locale) ? "rtl" : "ltr" } -type RawDictionary = typeof en & typeof uiEn +type RawDictionary = typeof en & typeof remoteEn & typeof uiEn type Dictionary = i18n.Flatten type PluralKey = | UiI18nPluralKey @@ -43,7 +44,7 @@ const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES const INTL = DESKTOP_NATIVE_LOCALE_TAGS -const base = i18n.flatten({ ...en, ...uiEn }) +const base = i18n.flatten({ ...en, ...remoteEn, ...uiEn }) const dicts = new Map([["en", base]]) const merge = (app: Promise, ui: Promise) => @@ -239,4 +240,4 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont }, } }, -}) +}) \ No newline at end of file diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 0408b233ffa8..38a35b4a8243 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -20,6 +20,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string } type PlatformName = "web" | "desktop" type DesktopOS = "macos" | "windows" | "linux" +export type RemotePairingInfo = { + url: string + urls: string[] + expiresIn: number +} + export type FatalRendererErrorLog = { error: string url: string @@ -83,6 +89,12 @@ type PlatformBase = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServer?(url: ServerConnection.Key | null): Promise | void + /** Create a one-time mobile remote-control pairing for a local desktop session. */ + createRemotePairing?(sessionID: string, directory: string): Promise + + /** Revoke mobile remote access for a local desktop session. */ + revokeRemotePairing?(sessionID: string, directory: string): Promise + /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform diff --git a/packages/app/src/i18n/remote.ts b/packages/app/src/i18n/remote.ts new file mode 100644 index 000000000000..14462d9800c6 --- /dev/null +++ b/packages/app/src/i18n/remote.ts @@ -0,0 +1,13 @@ +export const dict = { + "session.remote.title": "Remote control", + "session.remote.open": "Open remote control", + "session.remote.description": "Scan this QR code with a phone on the same local network to control this session.", + "session.remote.networkNote": "The phone and this computer must be on the same local network.", + "session.remote.copyLink": "Copy link", + "session.remote.linkCopied": "Remote link copied", + "session.remote.disconnect": "Disconnect phone", + "session.remote.disconnected": "Remote access disconnected", + "session.remote.error.title": "Remote control unavailable", + "session.remote.error.description": "Could not create a local remote-control link for this session.", + "session.remote.error.noLan": "No local network address is available for remote control.", +} as const diff --git a/packages/app/src/utils/remote-qr.test.ts b/packages/app/src/utils/remote-qr.test.ts new file mode 100644 index 000000000000..4f30e9c2adff --- /dev/null +++ b/packages/app/src/utils/remote-qr.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { encodeRemoteQr, REMOTE_QR_SIZE, remoteQrPath } from "./remote-qr" + +describe("remote qr", () => { + test("encodes a representative LAN pairing URL", () => { + const value = `http://192.168.1.10:4123/remote/mobile#ticket=${"a".repeat(43)}` + const modules = encodeRemoteQr(value) + + expect(modules).toHaveLength(REMOTE_QR_SIZE) + expect(modules.every((row) => row.length === REMOTE_QR_SIZE)).toBe(true) + expect(modules.flat().filter(Boolean)).toHaveLength(878) + expect(remoteQrPath(modules)).toContain("M4 4h1v1h-1z") + }) + + test("is deterministic", () => { + const value = `http://10.0.0.5:4096/remote/mobile#ticket=${"b".repeat(43)}` + expect(encodeRemoteQr(value)).toEqual(encodeRemoteQr(value)) + }) + + test("rejects values larger than the version 6 byte capacity", () => { + expect(() => encodeRemoteQr("x".repeat(135))).toThrow("remote_qr_payload_too_long") + }) +}) diff --git a/packages/app/src/utils/remote-qr.ts b/packages/app/src/utils/remote-qr.ts new file mode 100644 index 000000000000..7e87b4a8e1e1 --- /dev/null +++ b/packages/app/src/utils/remote-qr.ts @@ -0,0 +1,187 @@ +const VERSION = 6 +const SIZE = 17 + VERSION * 4 +const DATA_CODEWORDS = 136 +const BLOCK_DATA_CODEWORDS = 68 +const ECC_CODEWORDS = 18 +const MAX_BYTE_PAYLOAD = 134 +const REMAINDER_BITS = 7 + +export function encodeRemoteQr(value: string) { + const bytes = new TextEncoder().encode(value) + if (bytes.length > MAX_BYTE_PAYLOAD) throw new Error("remote_qr_payload_too_long") + + const data = encodeData(bytes) + const divisor = reedSolomonDivisor(ECC_CODEWORDS) + const blocks = [data.slice(0, BLOCK_DATA_CODEWORDS), data.slice(BLOCK_DATA_CODEWORDS)] + const ecc = blocks.map((block) => reedSolomonRemainder(block, divisor)) + const codewords: number[] = [] + + for (let i = 0; i < BLOCK_DATA_CODEWORDS; i++) { + codewords.push(blocks[0]![i]!, blocks[1]![i]!) + } + for (let i = 0; i < ECC_CODEWORDS; i++) { + codewords.push(ecc[0]![i]!, ecc[1]![i]!) + } + + const dataBits = codewords.flatMap((byte) => byteBits(byte)) + dataBits.push(...Array(REMAINDER_BITS).fill(0)) + + const modules = Array.from({ length: SIZE }, () => Array(SIZE).fill(false)) + const functionModules = Array.from({ length: SIZE }, () => Array(SIZE).fill(false)) + + const setFunction = (x: number, y: number, dark: boolean) => { + if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) return + modules[y]![x] = dark + functionModules[y]![x] = true + } + + for (let i = 0; i < SIZE; i++) { + setFunction(6, i, i % 2 === 0) + setFunction(i, 6, i % 2 === 0) + } + + drawFinder(setFunction, 3, 3) + drawFinder(setFunction, SIZE - 4, 3) + drawFinder(setFunction, 3, SIZE - 4) + drawAlignment(setFunction, 34, 34) + drawFormat(setFunction, 0) + + let bitIndex = 0 + let upward = true + for (let right = SIZE - 1; right >= 1; right -= 2) { + if (right === 6) right -= 1 + for (let vertical = 0; vertical < SIZE; vertical++) { + const y = upward ? SIZE - 1 - vertical : vertical + for (let offset = 0; offset < 2; offset++) { + const x = right - offset + if (functionModules[y]![x]) continue + let bit = dataBits[bitIndex++] ?? 0 + if ((x + y) % 2 === 0) bit ^= 1 + modules[y]![x] = bit !== 0 + } + } + upward = !upward + } + + if (bitIndex !== dataBits.length) throw new Error("remote_qr_layout_mismatch") + return modules +} + +export function remoteQrPath(modules: boolean[][], quietZone = 4) { + const path: string[] = [] + for (let y = 0; y < modules.length; y++) { + const row = modules[y]! + for (let x = 0; x < row.length; x++) { + if (!row[x]) continue + path.push(`M${x + quietZone} ${y + quietZone}h1v1h-1z`) + } + } + return path.join("") +} + +export const REMOTE_QR_SIZE = SIZE +export const REMOTE_QR_QUIET_ZONE = 4 + +function encodeData(bytes: Uint8Array) { + const bits: number[] = [] + appendBits(bits, 0b0100, 4) + appendBits(bits, bytes.length, 8) + for (const byte of bytes) appendBits(bits, byte, 8) + + const capacity = DATA_CODEWORDS * 8 + const terminator = Math.min(4, capacity - bits.length) + bits.push(...Array(Math.max(0, terminator)).fill(0)) + while (bits.length % 8 !== 0) bits.push(0) + + const result: number[] = [] + for (let i = 0; i < bits.length; i += 8) { + let value = 0 + for (let j = 0; j < 8; j++) value = (value << 1) | bits[i + j]! + result.push(value) + } + + for (let pad = 0; result.length < DATA_CODEWORDS; pad++) result.push(pad % 2 === 0 ? 0xec : 0x11) + return result +} + +function appendBits(target: number[], value: number, count: number) { + for (let i = count - 1; i >= 0; i--) target.push((value >>> i) & 1) +} + +function byteBits(value: number) { + return Array.from({ length: 8 }, (_, i) => (value >>> (7 - i)) & 1) +} + +function drawFinder(setFunction: (x: number, y: number, dark: boolean) => void, centerX: number, centerY: number) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const distance = Math.max(Math.abs(dx), Math.abs(dy)) + setFunction(centerX + dx, centerY + dy, distance !== 2 && distance !== 4) + } + } +} + +function drawAlignment( + setFunction: (x: number, y: number, dark: boolean) => void, + centerX: number, + centerY: number, +) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + setFunction(centerX + dx, centerY + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1) + } + } +} + +function drawFormat(setFunction: (x: number, y: number, dark: boolean) => void, mask: number) { + const data = (1 << 3) | mask + let remainder = data + for (let i = 0; i < 10; i++) remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537) + const bits = ((data << 10) | remainder) ^ 0x5412 + const dark = (index: number) => ((bits >>> index) & 1) !== 0 + + for (let i = 0; i <= 5; i++) setFunction(8, i, dark(i)) + setFunction(8, 7, dark(6)) + setFunction(8, 8, dark(7)) + setFunction(7, 8, dark(8)) + for (let i = 9; i < 15; i++) setFunction(14 - i, 8, dark(i)) + + for (let i = 0; i < 8; i++) setFunction(SIZE - 1 - i, 8, dark(i)) + for (let i = 8; i < 15; i++) setFunction(8, SIZE - 15 + i, dark(i)) + setFunction(8, SIZE - 8, true) +} + +function reedSolomonDivisor(degree: number) { + const result = new Uint8Array(degree) + result[degree - 1] = 1 + let root = 1 + + for (let i = 0; i < degree; i++) { + for (let j = 0; j < degree; j++) { + result[j] = finiteFieldMultiply(result[j]!, root) + if (j + 1 < degree) result[j] ^= result[j + 1]! + } + root = finiteFieldMultiply(root, 0x02) + } + return result +} + +function reedSolomonRemainder(data: number[], divisor: Uint8Array) { + const result = new Uint8Array(divisor.length) + for (const byte of data) { + const factor = byte ^ result[0]! + result.copyWithin(0, 1) + result[result.length - 1] = 0 + for (let i = 0; i < divisor.length; i++) result[i] ^= finiteFieldMultiply(divisor[i]!, factor) + } + return result +} + +function finiteFieldMultiply(x: number, y: number) { + let z = 0 + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d) + if (((y >>> i) & 1) !== 0) z ^= x + } + return z & 0xff +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index d8abfc1ceb3b..2188c95599de 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -24,6 +24,8 @@ import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" import { createDesktopDraftStore } from "./draft-store" import { nativeT } from "./native-translations" +import { createRemoteGatewayController } from "./remote-gateway-controller" +import { createRemotePairingController } from "./remote-pairing-controller" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -54,16 +56,41 @@ type Deps = { setNativeTranslations: (bundle: DesktopNativeBundle) => void } +function remotePairingTarget(sessionID: unknown, directory: unknown) { + if (typeof sessionID !== "string" || sessionID.length === 0) throw new Error("Invalid remote session ID") + if (typeof directory !== "string" || directory.length === 0) throw new Error("Invalid remote session directory") + return { sessionID, directory } +} + export function registerIpcHandlers(deps: Deps) { const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) const updaterSubscriptions = createUpdaterSubscriptions() + const remoteGateway = createRemoteGatewayController({ + getUpstreamUrl: () => deps.awaitInitialization().then((data) => data.url), + }) + const remotePairing = createRemotePairingController({ + getSidecar: deps.awaitInitialization, + gateway: remoteGateway, + }) app.once("will-quit", updaterSubscriptions.clear) app.on("before-quit", () => drafts.flush()) app.once("will-quit", () => drafts.close()) + app.once("will-quit", () => void remoteGateway.stop()) app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush())) - ipcMain.handle("kill-sidecar", () => deps.killSidecar()) + ipcMain.handle("kill-sidecar", async () => { + await remoteGateway.stop() + return deps.killSidecar() + }) ipcMain.handle("await-initialization", () => deps.awaitInitialization()) + ipcMain.handle("remote-pairing-create", (_event: IpcMainInvokeEvent, sessionID: unknown, directory: unknown) => { + const target = remotePairingTarget(sessionID, directory) + return remotePairing.create(target.sessionID, target.directory) + }) + ipcMain.handle("remote-pairing-revoke", (_event: IpcMainInvokeEvent, sessionID: unknown, directory: unknown) => { + const target = remotePairingTarget(sessionID, directory) + return remotePairing.revoke(target.sessionID, target.directory) + }) ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop/src/main/remote-gateway-controller.test.ts b/packages/desktop/src/main/remote-gateway-controller.test.ts new file mode 100644 index 000000000000..6f272d27075b --- /dev/null +++ b/packages/desktop/src/main/remote-gateway-controller.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test" +import type { RemoteGatewayInfo } from "./remote-gateway" +import { createRemoteGatewayController } from "./remote-gateway-controller" + +function fakeGateway(info: RemoteGatewayInfo, beforeStop?: Promise) { + let running: RemoteGatewayInfo | undefined + let starts = 0 + let stops = 0 + return { + gateway: { + start: async () => { + starts += 1 + running = info + return info + }, + stop: async () => { + stops += 1 + await beforeStop + running = undefined + }, + status: () => running, + }, + starts: () => starts, + stops: () => stops, + } +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe("remote gateway controller", () => { + test("starts lazily and reuses the active gateway", async () => { + const fake = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + let upstreamCalls = 0 + let factoryCalls = 0 + const controller = createRemoteGatewayController({ + getUpstreamUrl: async () => { + upstreamCalls += 1 + return "http://127.0.0.1:4096" + }, + createGateway: (url) => { + factoryCalls += 1 + expect(url).toBe("http://127.0.0.1:4096") + return fake.gateway + }, + }) + + const [first, second] = await Promise.all([controller.start(), controller.start()]) + expect(first).toEqual(second) + expect(controller.status()).toEqual(first) + expect(upstreamCalls).toBe(1) + expect(factoryCalls).toBe(1) + expect(fake.starts()).toBe(1) + }) + + test("stop is idempotent and the controller can restart", async () => { + const created: ReturnType[] = [] + const controller = createRemoteGatewayController({ + getUpstreamUrl: async () => "http://127.0.0.1:4096", + createGateway: () => { + const fake = fakeGateway({ port: 5000 + created.length, urls: [] }) + created.push(fake) + return fake.gateway + }, + }) + + await controller.start() + await Promise.all([controller.stop(), controller.stop()]) + expect(controller.status()).toBeUndefined() + expect(created[0]?.stops()).toBe(1) + + await controller.start() + expect(created).toHaveLength(2) + expect(created[1]?.starts()).toBe(1) + }) + + test("start waits for an in-flight stop before replacing the gateway", async () => { + const gate = deferred() + const created: ReturnType[] = [] + const controller = createRemoteGatewayController({ + getUpstreamUrl: async () => "http://127.0.0.1:4096", + createGateway: () => { + const fake = fakeGateway( + { port: 7000 + created.length, urls: [] }, + created.length === 0 ? gate.promise : undefined, + ) + created.push(fake) + return fake.gateway + }, + }) + + await controller.start() + const stopping = controller.stop() + const restarting = controller.start() + await Promise.resolve() + + expect(created).toHaveLength(1) + gate.resolve() + await stopping + + expect(await restarting).toEqual({ port: 7001, urls: [] }) + expect(created).toHaveLength(2) + expect(created[0]?.stops()).toBe(1) + expect(created[1]?.starts()).toBe(1) + }) + + test("failed start can be retried", async () => { + let attempts = 0 + const fake = fakeGateway({ port: 6000, urls: [] }) + const controller = createRemoteGatewayController({ + getUpstreamUrl: async () => { + attempts += 1 + if (attempts === 1) throw new Error("sidecar unavailable") + return "http://127.0.0.1:4096" + }, + createGateway: () => fake.gateway, + }) + + await expect(controller.start()).rejects.toThrow("sidecar unavailable") + expect(controller.status()).toBeUndefined() + expect(await controller.start()).toEqual({ port: 6000, urls: [] }) + expect(attempts).toBe(2) + }) +}) diff --git a/packages/desktop/src/main/remote-gateway-controller.ts b/packages/desktop/src/main/remote-gateway-controller.ts new file mode 100644 index 000000000000..0af8c7be9c15 --- /dev/null +++ b/packages/desktop/src/main/remote-gateway-controller.ts @@ -0,0 +1,58 @@ +import { createRemoteGateway, type RemoteGatewayInfo } from "./remote-gateway" + +type Gateway = ReturnType + +type RemoteGatewayControllerOptions = { + getUpstreamUrl: () => Promise + createGateway?: (upstreamUrl: string) => Gateway +} + +export function createRemoteGatewayController(options: RemoteGatewayControllerOptions) { + let gateway: Gateway | undefined + let starting: Promise | undefined + let stopping: Promise | undefined + + const start = async () => { + if (stopping) await stopping + if (gateway?.status()) return gateway.status()! + if (starting) return starting + + starting = options + .getUpstreamUrl() + .then((upstreamUrl) => { + const next = options.createGateway?.(upstreamUrl) ?? createRemoteGateway({ upstreamUrl }) + gateway = next + return next.start() + }) + .catch((error) => { + gateway = undefined + throw error + }) + .finally(() => { + starting = undefined + }) + + return starting + } + + const stop = async () => { + if (stopping) return stopping + + stopping = (async () => { + await starting?.catch(() => undefined) + const current = gateway + gateway = undefined + if (current) await current.stop() + })().finally(() => { + stopping = undefined + }) + + return stopping + } + + return { + start, + stop, + status: () => gateway?.status(), + } +} diff --git a/packages/desktop/src/main/remote-gateway-forwarding.test.ts b/packages/desktop/src/main/remote-gateway-forwarding.test.ts new file mode 100644 index 000000000000..e562fb9b96ac --- /dev/null +++ b/packages/desktop/src/main/remote-gateway-forwarding.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test" +import * as http from "node:http" +import type { AddressInfo } from "node:net" +import { createRemoteGateway } from "./remote-gateway" + +const servers: http.Server[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + server.closeIdleConnections() + server.closeAllConnections() + }), + ), + ) +}) + +describe("remote gateway forwarding identity", () => { + test("strips client-supplied forwarding identity before proxying", async () => { + let seen: http.IncomingHttpHeaders = {} + const upstream = await listen((request, response) => { + seen = request.headers + response.end("ok") + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + await new Promise((resolve, reject) => { + const request = http.request( + { + host: "127.0.0.1", + port: info.port, + path: "/remote/mobile", + headers: { + forwarded: "for=203.0.113.10;proto=https", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-host": "attacker.invalid", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-real-ip": "203.0.113.10", + }, + }, + (response) => { + response.resume() + response.on("end", resolve) + }, + ) + request.on("error", reject) + request.end() + }) + + expect(seen.forwarded).toBeUndefined() + expect(seen["x-forwarded-for"]).toBeUndefined() + expect(seen["x-forwarded-port"]).toBeUndefined() + expect(seen["x-real-ip"]).toBeUndefined() + expect(seen["x-forwarded-host"]).toBe(`127.0.0.1:${info.port}`) + expect(seen["x-forwarded-proto"]).toBe("http") + + await gateway.stop() + }) +}) + +async function listen(handler: http.RequestListener) { + const server = http.createServer(handler) + servers.push(server) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + return server +} + +function origin(server: http.Server) { + const address = server.address() as AddressInfo + return `http://127.0.0.1:${address.port}` +} diff --git a/packages/desktop/src/main/remote-gateway.test.ts b/packages/desktop/src/main/remote-gateway.test.ts new file mode 100644 index 000000000000..4df81ea9775f --- /dev/null +++ b/packages/desktop/src/main/remote-gateway.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, test } from "bun:test" +import * as http from "node:http" +import { connect, type AddressInfo } from "node:net" +import type { NetworkInterfaceInfo } from "node:os" +import { createRemoteGateway } from "./remote-gateway" + +const servers: http.Server[] = [] + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + server.closeIdleConnections() + server.closeAllConnections() + }), + ), + ) +}) + +describe("remote gateway", () => { + test("proxies only /remote routes", async () => { + const upstream = await listen((request, response) => { + response.setHeader("content-type", "application/json") + response.end(JSON.stringify({ url: request.url, authorization: request.headers.authorization ?? null })) + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + const remote = await fetch(`http://127.0.0.1:${info.port}/remote/session/test`, { + headers: { authorization: "Bearer remote-token" }, + }) + expect(remote.status).toBe(200) + expect(await remote.json()).toEqual({ + url: "/remote/session/test", + authorization: "Bearer remote-token", + }) + + const blocked = await fetch(`http://127.0.0.1:${info.port}/session/test`) + expect(blocked.status).toBe(404) + + await gateway.stop() + }) + + test("pins absolute-form request targets to the configured upstream", async () => { + let upstreamHits = 0 + let foreignHits = 0 + const upstream = await listen((request, response) => { + upstreamHits += 1 + response.end(request.url) + }) + const foreign = await listen((_request, response) => { + foreignHits += 1 + response.end("foreign") + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + const body = await rawGet(info.port, `${origin(foreign)}/remote/probe?x=1`) + expect(body).toBe("/remote/probe?x=1") + expect(upstreamHits).toBe(1) + expect(foreignHits).toBe(0) + + await gateway.stop() + }) + + test("rejects malformed request targets without crashing", async () => { + const upstream = await listen((_request, response) => response.end("ok")) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + for (const target of ["http://[::1", "http://%zz/remote", "//[::1", "http://example.com:99999/remote"]) { + expect(await rawTarget(info.port, target)).toContain(" 400 ") + } + + expect((await fetch(`http://127.0.0.1:${info.port}/remote/mobile`)).status).toBe(200) + await gateway.stop() + }) + + test("forwards request bodies and response headers", async () => { + const upstream = await listen((request, response) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + response.setHeader("x-remote-test", "ok") + response.end(Buffer.concat(chunks)) + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + const response = await fetch(`http://127.0.0.1:${info.port}/remote/pair/redeem`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ticket: "one-time" }), + }) + + expect(response.status).toBe(200) + expect(response.headers.get("x-remote-test")).toBe("ok") + expect(await response.json()).toEqual({ ticket: "one-time" }) + + await gateway.stop() + }) + + test("publishes only private IPv4 LAN addresses", async () => { + const upstream = await listen((_request, response) => response.end("ok")) + const gateway = createRemoteGateway({ + upstreamUrl: origin(upstream), + networkInterfaces: () => ({ + wan: [networkAddress("203.0.113.8")], + docker: [networkAddress("172.17.0.1")], + corporate: [networkAddress("10.20.30.40")], + wifi: [networkAddress("192.168.50.12")], + loopback: [networkAddress("127.0.0.1", true)], + }), + }) + const info = await gateway.start() + + expect(info.urls).toEqual([ + `http://192.168.50.12:${info.port}`, + `http://10.20.30.40:${info.port}`, + `http://172.17.0.1:${info.port}`, + ]) + + await gateway.stop() + }) + + test("refreshes advertised LAN addresses while keeping the gateway port", async () => { + const upstream = await listen((_request, response) => response.end("ok")) + let address = "192.168.50.12" + const gateway = createRemoteGateway({ + upstreamUrl: origin(upstream), + networkInterfaces: () => ({ wifi: [networkAddress(address)] }), + }) + + const first = await gateway.start() + expect(first.urls).toEqual([`http://192.168.50.12:${first.port}`]) + + address = "192.168.60.24" + expect(gateway.status()).toEqual({ + port: first.port, + urls: [`http://192.168.60.24:${first.port}`], + }) + + const second = await gateway.start() + expect(second).toEqual({ + port: first.port, + urls: [`http://192.168.60.24:${first.port}`], + }) + + await gateway.stop() + }) + + test("strips fixed and Connection-declared hop-by-hop request headers", async () => { + let seen: http.IncomingHttpHeaders = {} + const upstream = await listen((request, response) => { + seen = request.headers + response.end("ok") + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + await rawRequest(info.port, { + connection: "keep-alive, x-remote-hop", + "keep-alive": "timeout=5", + "proxy-connection": "keep-alive", + "x-remote-hop": "secret", + "x-end-to-end": "keep", + }) + + expect(seen["keep-alive"]).toBeUndefined() + expect(seen["proxy-connection"]).toBeUndefined() + expect(seen["x-remote-hop"]).toBeUndefined() + expect(seen["x-end-to-end"]).toBe("keep") + + await gateway.stop() + }) + + test("strips fixed and Connection-declared hop-by-hop response headers", async () => { + const upstream = await listen((_request, response) => { + response.setHeader("connection", "x-remote-hop") + response.setHeader("proxy-connection", "keep-alive") + response.setHeader("x-remote-hop", "secret") + response.setHeader("x-end-to-end", "keep") + response.end("ok") + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + const response = await fetch(`http://127.0.0.1:${info.port}/remote/mobile`) + expect(response.headers.get("proxy-connection")).toBeNull() + expect(response.headers.get("x-remote-hop")).toBeNull() + expect(response.headers.get("x-end-to-end")).toBe("keep") + + await gateway.stop() + }) + + test("stop closes active streaming connections", async () => { + const upstream = await listen((_request, response) => { + response.writeHead(200, { "content-type": "text/event-stream" }) + response.write("data: connected\n\n") + }) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + const info = await gateway.start() + + const response = await fetch(`http://127.0.0.1:${info.port}/remote/session/test/events`) + expect(response.status).toBe(200) + expect(response.body).not.toBeNull() + + await gateway.stop() + await response.body?.cancel().catch(() => undefined) + }) + + test("start and stop are idempotent", async () => { + const upstream = await listen((_request, response) => response.end("ok")) + const gateway = createRemoteGateway({ upstreamUrl: origin(upstream) }) + + const first = await gateway.start() + const second = await gateway.start() + expect(second).toEqual(first) + + await gateway.stop() + await gateway.stop() + }) +}) + +async function listen(handler: http.RequestListener) { + const server = http.createServer(handler) + servers.push(server) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + return server +} + +function origin(server: http.Server) { + const address = server.address() as AddressInfo + return `http://127.0.0.1:${address.port}` +} + +function networkAddress(address: string, internal = false): NetworkInterfaceInfo { + return { + address, + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal, + cidr: `${address}/24`, + } +} + +function rawRequest(port: number, headers: http.OutgoingHttpHeaders) { + return new Promise((resolve, reject) => { + const request = http.request({ host: "127.0.0.1", port, path: "/remote/mobile", headers }, (response) => { + response.resume() + response.on("end", resolve) + }) + request.on("error", reject) + request.end() + }) +} + +function rawGet(port: number, path: string) { + return new Promise((resolve, reject) => { + const request = http.request({ host: "127.0.0.1", port, path }, (response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + response.on("end", () => resolve(Buffer.concat(chunks).toString())) + }) + request.on("error", reject) + request.end() + }) +} + +function rawTarget(port: number, target: string) { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => + socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`), + ) + let body = "" + socket.setEncoding("utf8") + socket.on("data", (chunk) => (body += chunk)) + socket.on("close", () => resolve(body.split("\r\n")[0] ?? "")) + socket.on("error", reject) + }) +} diff --git a/packages/desktop/src/main/remote-gateway.ts b/packages/desktop/src/main/remote-gateway.ts new file mode 100644 index 000000000000..20c0817747aa --- /dev/null +++ b/packages/desktop/src/main/remote-gateway.ts @@ -0,0 +1,253 @@ +import * as http from "node:http" +import * as https from "node:https" +import { networkInterfaces } from "node:os" + +export type RemoteGatewayInfo = { + port: number + urls: string[] +} + +type Logger = { + log(message: string, meta?: Record): void + warn(message: string, meta?: Record): void +} + +type RemoteGatewayOptions = { + upstreamUrl: string + logger?: Logger + networkInterfaces?: typeof networkInterfaces +} + +const blockedRequestHeaders = new Set([ + "connection", + "forwarded", + "host", + "keep-alive", + "proxy-authorization", + "proxy-authenticate", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "x-real-ip", +]) + +const blockedResponseHeaders = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-connection", + "trailer", + "transfer-encoding", + "upgrade", +]) + +export function createRemoteGateway(options: RemoteGatewayOptions) { + const upstream = new URL(options.upstreamUrl) + if (upstream.protocol !== "http:" && upstream.protocol !== "https:") { + throw new Error(`Unsupported remote gateway upstream protocol: ${upstream.protocol}`) + } + + const getNetworkInterfaces = options.networkInterfaces ?? networkInterfaces + let server: http.Server | undefined + let info: RemoteGatewayInfo | undefined + + const currentInfo = () => { + if (!server || !info) return + info = { + port: info.port, + urls: lanUrls(info.port, getNetworkInterfaces()), + } + return info + } + + const start = async (): Promise => { + const current = currentInfo() + if (current) return current + + const next = http.createServer((request, response) => { + if (!isAllowedNetworkRequest(request)) { + response.writeHead(403).end() + return + } + const incoming = requestURL(request.url) + if (!incoming) { + response.writeHead(400).end() + return + } + if (!isRemotePath(incoming.pathname)) { + response.writeHead(404).end() + return + } + + proxyRequest(upstream, incoming, request, response, options.logger) + }) + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + next.off("listening", onListening) + reject(error) + } + const onListening = () => { + next.off("error", onError) + resolve() + } + next.once("error", onError) + next.once("listening", onListening) + next.listen(0, "0.0.0.0") + }) + + const address = next.address() + if (!address || typeof address === "string") { + await closeServer(next) + throw new Error("Remote gateway did not expose a TCP address") + } + + server = next + info = { + port: address.port, + urls: lanUrls(address.port, getNetworkInterfaces()), + } + options.logger?.log("remote gateway started", { port: info.port, urls: info.urls }) + return info + } + + const stop = async () => { + const current = server + server = undefined + info = undefined + if (!current) return + await closeServer(current) + options.logger?.log("remote gateway stopped") + } + + return { + start, + stop, + status: currentInfo, + } +} + +function requestURL(rawUrl: string | undefined) { + try { + return new URL(rawUrl ?? "/", "http://remote.invalid") + } catch { + return + } +} + +function isRemotePath(pathname: string) { + return pathname === "/remote" || pathname.startsWith("/remote/") +} + +function normalizeIPv4(address: string | undefined) { + if (!address) return + if (address.startsWith("::ffff:")) return address.slice("::ffff:".length) + return address +} + +function isAllowedNetworkAddress(address: string | undefined) { + const value = normalizeIPv4(address) + if (!value) return false + if (value === "::1" || value.startsWith("127.")) return true + return isPrivateIPv4(value) +} + +function isAllowedNetworkRequest(request: http.IncomingMessage) { + return isAllowedNetworkAddress(request.socket.localAddress) && isAllowedNetworkAddress(request.socket.remoteAddress) +} + +function hopByHop(headers: http.IncomingHttpHeaders, fixed: Set) { + const blocked = new Set(fixed) + for (const token of headers.connection?.split(",") ?? []) { + const name = token.trim().toLowerCase() + if (name) blocked.add(name) + } + return Object.fromEntries(Object.entries(headers).filter(([name]) => !blocked.has(name.toLowerCase()))) +} + +function proxyRequest( + upstream: URL, + incoming: URL, + request: http.IncomingMessage, + response: http.ServerResponse, + logger?: Logger, +) { + const target = new URL(upstream) + target.pathname = incoming.pathname + target.search = incoming.search + target.hash = "" + const requestImpl = target.protocol === "https:" ? https.request : http.request + const headers = hopByHop(request.headers, blockedRequestHeaders) + + if (request.headers.host) headers["x-forwarded-host"] = request.headers.host + headers["x-forwarded-proto"] = "http" + + const proxy = requestImpl( + target, + { + method: request.method, + headers, + }, + (upstreamResponse) => { + const responseHeaders = hopByHop(upstreamResponse.headers, blockedResponseHeaders) + response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders) + upstreamResponse.pipe(response) + }, + ) + + proxy.on("error", (error) => { + logger?.warn("remote gateway proxy failed", { error: error.message }) + if (!response.headersSent) response.writeHead(502) + response.end() + }) + + request.on("aborted", () => proxy.destroy()) + response.on("close", () => { + if (!response.writableEnded) proxy.destroy() + }) + request.pipe(proxy) +} + +function lanUrls(port: number, interfaces: ReturnType) { + const addresses = new Set() + for (const entries of Object.values(interfaces)) { + for (const entry of entries ?? []) { + if (entry.internal || entry.family !== "IPv4" || !isPrivateIPv4(entry.address)) continue + addresses.add(entry.address) + } + } + return [...addresses].sort(privateAddressRank).map((address) => `http://${address}:${port}`) +} + +function privateAddressRank(a: string, b: string) { + return privateRangeRank(a) - privateRangeRank(b) || a.localeCompare(b) +} + +function privateRangeRank(address: string) { + if (address.startsWith("192.168.")) return 0 + if (address.startsWith("10.")) return 1 + return 2 +} + +function isPrivateIPv4(address: string) { + if (address.startsWith("10.")) return true + if (address.startsWith("192.168.")) return true + const match = /^172\.(\d+)\./.exec(address) + if (!match) return false + const second = Number(match[1]) + return second >= 16 && second <= 31 +} + +function closeServer(server: http.Server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + server.closeIdleConnections() + server.closeAllConnections() + }) +} diff --git a/packages/desktop/src/main/remote-pairing-controller-race.test.ts b/packages/desktop/src/main/remote-pairing-controller-race.test.ts new file mode 100644 index 000000000000..de8b6fa885fc --- /dev/null +++ b/packages/desktop/src/main/remote-pairing-controller-race.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import type { RemoteGatewayInfo } from "./remote-gateway" +import { createRemotePairingController } from "./remote-pairing-controller" + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function fakeGateway(info: RemoteGatewayInfo) { + let current: RemoteGatewayInfo | undefined + let stops = 0 + return { + gateway: { + start: async () => { + current = info + return info + }, + stop: async () => { + stops += 1 + current = undefined + }, + status: () => current, + }, + stops: () => stops, + } +} + +describe("remote pairing prune races", () => { + test("a revoke cannot stop the gateway while create is pruning stale sessions", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const pruneStarted = deferred() + const pruneResponse = deferred() + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (input, init) => { + const request = new Request(input, init) + if (request.method === "GET") { + pruneStarted.resolve() + return pruneResponse.promise + } + if (request.method === "DELETE") return Response.json(true) + return Response.json({ ticket: "ticket", expires_in: 300 }) + }, + }) + + await controller.create("session-a", "/tmp/a") + const creating = controller.create("session-b", "/tmp/b") + await pruneStarted.promise + + await controller.revoke("session-a", "/tmp/a") + expect(gateway.stops()).toBe(0) + + pruneResponse.resolve(new Response(null, { status: 404 })) + await expect(creating).resolves.toMatchObject({ expiresIn: 300 }) + expect(gateway.stops()).toBe(0) + + await controller.revoke("session-b", "/tmp/b") + expect(gateway.stops()).toBe(1) + }) +}) diff --git a/packages/desktop/src/main/remote-pairing-controller.test.ts b/packages/desktop/src/main/remote-pairing-controller.test.ts new file mode 100644 index 000000000000..85a2bb616e33 --- /dev/null +++ b/packages/desktop/src/main/remote-pairing-controller.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test" +import type { RemoteGatewayInfo } from "./remote-gateway" +import { createRemotePairingController } from "./remote-pairing-controller" + +function fakeGateway(info: RemoteGatewayInfo) { + let current: RemoteGatewayInfo | undefined + let starts = 0 + let stops = 0 + return { + gateway: { + start: async () => { + starts += 1 + current = info + return info + }, + stop: async () => { + stops += 1 + current = undefined + }, + status: () => current, + }, + starts: () => starts, + stops: () => stops, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe("remote pairing controller", () => { + test("creates a mobile URL while keeping Basic auth in the main-process request", async () => { + const gateway = fakeGateway({ + port: 4123, + urls: ["http://192.168.1.10:4123", "http://10.0.0.5:4123"], + }) + let request: Request | undefined + const controller = createRemotePairingController({ + getSidecar: async () => ({ + url: "http://127.0.0.1:4096", + username: "opencode", + password: "server-secret", + }), + gateway: gateway.gateway, + fetch: async (input, init) => { + request = new Request(input, init) + return Response.json({ ticket: "one-time-ticket", expires_in: 300 }) + }, + }) + + const result = await controller.create("session 1", "/tmp/project") + + expect(gateway.starts()).toBe(1) + expect(request?.method).toBe("POST") + expect(request?.url).toBe( + "http://127.0.0.1:4096/session/session%201/remote?directory=%2Ftmp%2Fproject", + ) + expect(request?.headers.get("authorization")).toBe(`Basic ${btoa("opencode:server-secret")}`) + expect(result).toEqual({ + url: "http://192.168.1.10:4123/remote/mobile#ticket=one-time-ticket", + urls: [ + "http://192.168.1.10:4123/remote/mobile#ticket=one-time-ticket", + "http://10.0.0.5:4123/remote/mobile#ticket=one-time-ticket", + ], + expiresIn: 300, + }) + }) + + test("stops a newly-created gateway when no LAN address is available", async () => { + const gateway = fakeGateway({ port: 4123, urls: [] }) + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + }) + + await expect(controller.create("session", "/tmp/project")).rejects.toThrow("No local network address") + expect(gateway.starts()).toBe(1) + expect(gateway.stops()).toBe(1) + }) + + test("revoke sends an authenticated DELETE without stopping an untracked shared gateway", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + await gateway.gateway.start() + let request: Request | undefined + const controller = createRemotePairingController({ + getSidecar: async () => ({ + url: "http://127.0.0.1:4096", + username: null, + password: "server-secret", + }), + gateway: gateway.gateway, + fetch: async (input, init) => { + request = new Request(input, init) + return Response.json(true) + }, + }) + + await controller.revoke("session", "/tmp/project") + + expect(request?.method).toBe("DELETE") + expect(request?.headers.get("authorization")).toBe(`Basic ${btoa("opencode:server-secret")}`) + expect(gateway.stops()).toBe(0) + }) + + test("does not stop a gateway it did not start after a paired session disconnects", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + await gateway.gateway.start() + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (_input, init) => + init?.method === "DELETE" ? Response.json(true) : Response.json({ ticket: "ticket", expires_in: 300 }), + }) + + await controller.create("session", "/tmp/project") + await controller.revoke("session", "/tmp/project") + + expect(gateway.starts()).toBe(1) + expect(gateway.stops()).toBe(0) + }) + + test("stops the gateway when the last tracked remote session disconnects", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (_input, init) => + init?.method === "DELETE" ? Response.json(true) : Response.json({ ticket: "ticket", expires_in: 300 }), + }) + + await controller.create("session", "/tmp/project") + await controller.revoke("session", "/tmp/project") + + expect(gateway.stops()).toBe(1) + }) + + test("keeps the shared gateway until all tracked remote sessions disconnect", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (_input, init) => + init?.method === "DELETE" ? Response.json(true) : Response.json({ ticket: "ticket", expires_in: 300 }), + }) + + await controller.create("session-a", "/tmp/a") + await controller.create("session-b", "/tmp/b") + await controller.revoke("session-a", "/tmp/a") + expect(gateway.stops()).toBe(0) + + await controller.revoke("session-b", "/tmp/b") + expect(gateway.stops()).toBe(1) + }) + + test("prunes deleted tracked sessions before deciding whether the gateway is idle", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const deleted = new Set() + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (input, init) => { + const request = new Request(input, init) + if (request.method === "GET") { + const sessionID = request.url.match(/\/session\/([^/?]+)/)?.[1] + return new Response(null, { status: sessionID && deleted.has(decodeURIComponent(sessionID)) ? 404 : 200 }) + } + if (request.method === "DELETE") return Response.json(true) + return Response.json({ ticket: "ticket", expires_in: 300 }) + }, + }) + + await controller.create("session-a", "/tmp/a") + await controller.create("session-b", "/tmp/b") + deleted.add("session-a") + + await controller.revoke("session-b", "/tmp/b") + expect(gateway.stops()).toBe(1) + }) + + test("keeps a successful pairing alive when a concurrent pairing fails", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const successRequest = deferred() + const failedRequest = deferred() + const successResponse = deferred() + const failedResponse = deferred() + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (input, init) => { + const request = new Request(input, init) + if (request.method === "DELETE") return Response.json(true) + if (request.method === "GET") return new Response(null, { status: 200 }) + if (request.url.includes("session-success")) { + successRequest.resolve() + return successResponse.promise + } + failedRequest.resolve() + return failedResponse.promise + }, + }) + + const success = controller.create("session-success", "/tmp/success") + const failed = controller.create("session-failed", "/tmp/failed") + await Promise.all([successRequest.promise, failedRequest.promise]) + + failedResponse.resolve(new Response(null, { status: 500 })) + await expect(failed).rejects.toThrow("status 500") + expect(gateway.stops()).toBe(0) + + successResponse.resolve(Response.json({ ticket: "ticket", expires_in: 300 })) + await expect(success).resolves.toMatchObject({ expiresIn: 300 }) + expect(gateway.stops()).toBe(0) + + await controller.revoke("session-success", "/tmp/success") + expect(gateway.stops()).toBe(1) + }) + + test("does not stop the gateway while another pairing is in flight", async () => { + const gateway = fakeGateway({ port: 4123, urls: ["http://192.168.1.10:4123"] }) + const pendingRequest = deferred() + const pendingResponse = deferred() + const controller = createRemotePairingController({ + getSidecar: async () => ({ url: "http://127.0.0.1:4096", username: null, password: null }), + gateway: gateway.gateway, + fetch: async (input, init) => { + const request = new Request(input, init) + if (request.method === "DELETE") return Response.json(true) + if (request.method === "GET") return new Response(null, { status: 200 }) + if (request.url.includes("session-pending")) { + pendingRequest.resolve() + return pendingResponse.promise + } + return Response.json({ ticket: "ticket", expires_in: 300 }) + }, + }) + + await controller.create("session-active", "/tmp/active") + const pending = controller.create("session-pending", "/tmp/pending") + await pendingRequest.promise + + await controller.revoke("session-active", "/tmp/active") + expect(gateway.stops()).toBe(0) + + pendingResponse.resolve(new Response(null, { status: 500 })) + await expect(pending).rejects.toThrow("status 500") + expect(gateway.stops()).toBe(1) + }) +}) diff --git a/packages/desktop/src/main/remote-pairing-controller.ts b/packages/desktop/src/main/remote-pairing-controller.ts new file mode 100644 index 000000000000..829fdaefd9c3 --- /dev/null +++ b/packages/desktop/src/main/remote-pairing-controller.ts @@ -0,0 +1,128 @@ +import { Buffer } from "node:buffer" +import type { RemotePairingInfo, ServerReadyData } from "../preload/types" +import type { RemoteGatewayInfo } from "./remote-gateway" + +type Gateway = { + start(): Promise + stop(): Promise + status(): RemoteGatewayInfo | undefined +} + +type RemotePairingControllerOptions = { + getSidecar: () => Promise + gateway: Gateway + fetch?: typeof globalThis.fetch +} + +type PairingPayload = { + ticket?: unknown + expires_in?: unknown +} + +export function createRemotePairingController(options: RemotePairingControllerOptions) { + const sessions = new Map() + let creating = 0 + let ownsGateway = false + let stopping: Promise | undefined + + const request = async (method: "GET" | "POST" | "DELETE", path: string, directory: string) => { + const sidecar = await options.getSidecar() + const url = new URL(path, sidecar.url) + url.searchParams.set("directory", directory) + + const headers = new Headers() + if (sidecar.password) { + const username = sidecar.username ?? "opencode" + headers.set("authorization", `Basic ${Buffer.from(`${username}:${sidecar.password}`).toString("base64")}`) + } + + return (options.fetch ?? globalThis.fetch)(url, { method, headers }) + } + + const adminRequest = (method: "POST" | "DELETE", sessionID: string, directory: string) => + request(method, `/session/${encodeURIComponent(sessionID)}/remote`, directory) + + const pruneSessions = async () => { + if (!ownsGateway || sessions.size === 0) return + const snapshot = [...sessions.entries()] + await Promise.all( + snapshot.map(async ([sessionID, directory]) => { + const response = await request("GET", `/session/${encodeURIComponent(sessionID)}`, directory).catch(() => undefined) + if (response?.status === 404 && sessions.get(sessionID) === directory) sessions.delete(sessionID) + }), + ) + } + + const stopIfIdle = async (prune = false) => { + if (!ownsGateway || creating > 0) return + if (prune) await pruneSessions() + if (!ownsGateway || creating > 0 || sessions.size > 0) return + if (stopping) return stopping + + stopping = options.gateway + .stop() + .then(() => { + ownsGateway = false + }) + .finally(() => { + stopping = undefined + }) + return stopping + } + + const create = async (sessionID: string, directory: string): Promise => { + if (stopping) await stopping + + creating += 1 + try { + const initial = options.gateway.status() + if (!initial && creating === 1) { + sessions.clear() + ownsGateway = false + } else if (initial && creating === 1) { + await pruneSessions() + } + + const existing = options.gateway.status() + const gateway = existing ?? (await options.gateway.start()) + if (!existing) ownsGateway = true + if (gateway.urls.length === 0) { + throw new Error("No local network address is available for remote control") + } + + const response = await adminRequest("POST", sessionID, directory) + if (!response.ok) throw new Error(`Remote pairing failed with status ${response.status}`) + + const payload = (await response.json()) as PairingPayload + if (typeof payload.ticket !== "string" || typeof payload.expires_in !== "number") { + throw new Error("Remote pairing returned an invalid response") + } + + const urls = gateway.urls.map((base) => { + const mobile = new URL("/remote/mobile", base) + return `${mobile.toString()}#ticket=${encodeURIComponent(payload.ticket as string)}` + }) + + sessions.set(sessionID, directory) + return { + url: urls[0]!, + urls, + expiresIn: payload.expires_in, + } + } finally { + creating -= 1 + await stopIfIdle().catch(() => undefined) + } + } + + const revoke = async (sessionID: string, directory: string) => { + if (stopping) await stopping + + const response = await adminRequest("DELETE", sessionID, directory) + if (!response.ok) throw new Error(`Remote revoke failed with status ${response.status}`) + if (!sessions.delete(sessionID)) return + await stopIfIdle(true) + } + + return { create, revoke } +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index dae18b0716a4..300fcd7d354e 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -14,6 +14,8 @@ const api: ElectronAPI = { killSidecar: () => ipcRenderer.invoke("kill-sidecar"), installCli: () => ipcRenderer.invoke("install-cli"), awaitInitialization: () => ipcRenderer.invoke("await-initialization"), + createRemotePairing: (sessionID, directory) => ipcRenderer.invoke("remote-pairing-create", sessionID, directory), + revokeRemotePairing: (sessionID, directory) => ipcRenderer.invoke("remote-pairing-revoke", sessionID, directory), wslServers: { getState: () => ipcRenderer.invoke("wsl-servers-get-state"), subscribe: (cb) => { diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 20c39097d31d..a85af802609c 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -22,6 +22,12 @@ export type ServerReadyData = { password: string | null } +export type RemotePairingInfo = { + url: string + urls: string[] + expiresIn: number +} + export type WslServersAPI = WslServersPlatform export type UpdaterAPI = { subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> @@ -46,6 +52,8 @@ export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: () => Promise + createRemotePairing: (sessionID: string, directory: string) => Promise + revokeRemotePairing: (sessionID: string, directory: string) => Promise wslServers: WslServersAPI updater: UpdaterAPI consumeInitialDeepLinks: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 496060e0d665..76f305ddae8b 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -282,6 +282,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { await window.api.setDefaultServerUrl(url) }, + createRemotePairing: (sessionID, directory) => window.api.createRemotePairing(sessionID, directory), + revokeRemotePairing: (sessionID, directory) => window.api.revokeRemotePairing(sessionID, directory), + wslServers: wslServersApi, getDisplayBackend: async () => { diff --git a/packages/opencode/src/remote/access.ts b/packages/opencode/src/remote/access.ts new file mode 100644 index 000000000000..163c6a39bc9d --- /dev/null +++ b/packages/opencode/src/remote/access.ts @@ -0,0 +1,92 @@ +import { createHash, randomBytes } from "node:crypto" +import type { SessionID } from "@/session/schema" + +const PAIR_TTL_MS = 5 * 60 * 1000 +const GRANT_TTL_MS = 12 * 60 * 60 * 1000 + +type Pairing = { + sessionID: SessionID + expiresAt: number +} + +type Grant = { + sessionID: SessionID + expiresAt: number +} + +const pairings = new Map() +const grants = new Map() + +function token() { + return randomBytes(32).toString("base64url") +} + +function key(value: string) { + return createHash("sha256").update(value).digest("base64url") +} + +function prune(now = Date.now()) { + for (const [id, value] of pairings) { + if (value.expiresAt <= now) pairings.delete(id) + } + for (const [id, value] of grants) { + if (value.expiresAt <= now) grants.delete(id) + } +} + +function revokePairings(sessionID: SessionID) { + for (const [id, value] of pairings) { + if (value.sessionID === sessionID) pairings.delete(id) + } +} + +function revokeGrants(sessionID: SessionID) { + for (const [id, value] of grants) { + if (value.sessionID === sessionID) grants.delete(id) + } +} + +export function pair(sessionID: SessionID, now = Date.now()) { + prune(now) + revokePairings(sessionID) + const ticket = token() + pairings.set(key(ticket), { sessionID, expiresAt: now + PAIR_TTL_MS }) + return { ticket, expires_in: Math.floor(PAIR_TTL_MS / 1000) } +} + +export function redeem(ticket: string, now = Date.now()) { + prune(now) + const ticketKey = key(ticket) + const pairing = pairings.get(ticketKey) + if (!pairing) return + pairings.delete(ticketKey) + if (pairing.expiresAt <= now) return + + revokeGrants(pairing.sessionID) + const accessToken = token() + grants.set(key(accessToken), { sessionID: pairing.sessionID, expiresAt: now + GRANT_TTL_MS }) + return { + token: accessToken, + sessionID: pairing.sessionID, + expires_in: Math.floor(GRANT_TTL_MS / 1000), + } +} + +export function authorized(accessToken: string, sessionID: string, now = Date.now()) { + prune(now) + const grant = grants.get(key(accessToken)) + if (!grant || grant.expiresAt <= now) return false + return grant.sessionID === sessionID +} + +export function revoke(sessionID: SessionID) { + revokePairings(sessionID) + revokeGrants(sessionID) +} + +export function resetForTest() { + pairings.clear() + grants.clear() +} + +export * as RemoteAccess from "./access" diff --git a/packages/opencode/src/remote/event.ts b/packages/opencode/src/remote/event.ts new file mode 100644 index 000000000000..0b407e1b2747 --- /dev/null +++ b/packages/opencode/src/remote/event.ts @@ -0,0 +1,45 @@ +const forwardedTypes = new Set([ + "session.created", + "session.updated", + "session.deleted", + "session.error", + "session.status", + "session.idle", + "message.updated", + "message.removed", + "message.part.updated", + "message.part.removed", + "message.part.delta", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", +]) + +type EventData = { + id: string + type: string + data: unknown +} + +function belongsToSession(value: unknown, sessionID: string) { + if (!value || typeof value !== "object") return false + const data = value as Record + if (data.sessionID === sessionID) return true + for (const key of ["info", "part", "message"]) { + const nested = data[key] + if (nested && typeof nested === "object" && (nested as Record).sessionID === sessionID) return true + } + return false +} + +export function shouldForward(event: Pick, sessionID: string) { + return forwardedTypes.has(event.type) && belongsToSession(event.data, sessionID) +} + +export function signal(event: Pick) { + return { id: event.id, type: event.type, properties: {} } +} + +export * as RemoteEvent from "./event" diff --git a/packages/opencode/src/remote/mobile.ts b/packages/opencode/src/remote/mobile.ts new file mode 100644 index 000000000000..9c7d9e81277c --- /dev/null +++ b/packages/opencode/src/remote/mobile.ts @@ -0,0 +1,425 @@ +import { HttpServerResponse } from "effect/unstable/http" + +const html = String.raw` + + + + + + OpenCode Remote + + + +
+
+ OpenCode Remote +
Connecting…
+
+
+
+
Pairing with desktop…
+
+ +
+ + +` + +export function markup() { + return html +} + +export function response() { + return HttpServerResponse.text(html, { + contentType: "text/html; charset=utf-8", + headers: { + "cache-control": "no-store", + "content-security-policy": "default-src 'none'; connect-src 'self'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }, + }) +} + +export * as RemoteMobile from "./mobile" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/remote.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/remote.ts new file mode 100644 index 000000000000..35597d0ff9ba --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/remote.ts @@ -0,0 +1,167 @@ +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { SessionID } from "@/session/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ApiNotFoundError } from "../errors" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { RemoteAuthorization } from "../middleware/remote-authorization" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" + +const remoteRoot = "/remote" + +export const RemotePairToken = Schema.Struct({ + ticket: Schema.String, + expires_in: Schema.Number, +}) + +export const RemoteRedeemInput = Schema.Struct({ ticket: Schema.String }) +export const RemoteRedeemResult = Schema.Struct({ + token: Schema.String, + sessionID: SessionID, + expires_in: Schema.Number, +}) + +const RemoteMessagePart = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("tool"), + tool: Schema.String, + state: Schema.Struct({ + status: Schema.Literals(["pending", "running", "completed", "error"]), + }), + }), +]) + +const RemotePermission = Schema.Struct({ + id: PermissionV1.ID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), +}) + +const RemoteQuestion = Schema.Struct({ + id: QuestionID, + questions: Schema.Array(Question.Info), +}) + +export const RemoteBootstrap = Schema.Struct({ + session: Schema.Struct({ title: Schema.String }), + messages: Schema.Array( + Schema.Struct({ + info: Schema.Struct({ role: Schema.Literals(["user", "assistant"]) }), + parts: Schema.Array(RemoteMessagePart), + }), + ), + status: Schema.Struct({ type: Schema.Literals(["idle", "retry", "busy"]) }), + permissions: Schema.Array(RemotePermission), + questions: Schema.Array(RemoteQuestion), +}) + +export const RemoteMessagePayload = Schema.Struct({ + parts: Schema.Array( + Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + }), + ), +}) + +export const RemotePermissionReply = Schema.Struct({ + reply: PermissionV1.Reply, + message: Schema.optional(Schema.String), +}) + +export const RemoteQuestionReply = Schema.Struct({ + answers: Schema.Array(Question.Answer), +}) + +export const RemoteAdminApi = HttpApi.make("remote-admin").add( + HttpApiGroup.make("remote-admin") + .add( + HttpApiEndpoint.post("pair", "/session/:sessionID/remote", { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: RemotePairToken, + error: [HttpApiError.BadRequest, ApiNotFoundError], + }), + HttpApiEndpoint.delete("revoke", "/session/:sessionID/remote", { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization) + .annotateMerge(OpenApi.annotations({ title: "remote admin", description: "Manage session remote access." })), +) + +export const RemotePairApi = HttpApi.make("remote-pair").add( + HttpApiGroup.make("remote-pair").add( + HttpApiEndpoint.post("redeem", `${remoteRoot}/pair`, { + payload: RemoteRedeemInput, + success: RemoteRedeemResult, + error: HttpApiError.Forbidden, + }), + ), +) + +export const RemoteApi = HttpApi.make("remote").add( + HttpApiGroup.make("remote") + .add( + HttpApiEndpoint.get("bootstrap", `${remoteRoot}/session/:sessionID`, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: RemoteBootstrap, + error: [HttpApiError.Forbidden, ApiNotFoundError], + }), + HttpApiEndpoint.get("events", `${remoteRoot}/session/:sessionID/events`, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), + error: HttpApiError.Forbidden, + }), + HttpApiEndpoint.post("message", `${remoteRoot}/session/:sessionID/message`, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: RemoteMessagePayload, + success: HttpApiSchema.NoContent, + error: [HttpApiError.BadRequest, HttpApiError.Forbidden, ApiNotFoundError], + }), + HttpApiEndpoint.post("abort", `${remoteRoot}/session/:sessionID/abort`, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + error: HttpApiError.Forbidden, + }), + HttpApiEndpoint.post("permission", `${remoteRoot}/session/:sessionID/permission/:requestID`, { + params: { sessionID: SessionID, requestID: PermissionV1.ID }, + query: WorkspaceRoutingQuery, + payload: RemotePermissionReply, + success: Schema.Boolean, + error: HttpApiError.BadRequest, + }), + HttpApiEndpoint.post("question", `${remoteRoot}/session/:sessionID/question/:requestID`, { + params: { sessionID: SessionID, requestID: QuestionID }, + query: WorkspaceRoutingQuery, + payload: RemoteQuestionReply, + success: Schema.Boolean, + error: HttpApiError.BadRequest, + }), + HttpApiEndpoint.post("questionReject", `${remoteRoot}/session/:sessionID/question/:requestID/reject`, { + params: { sessionID: SessionID, requestID: QuestionID }, + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + error: HttpApiError.BadRequest, + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(RemoteAuthorization) + .annotateMerge(OpenApi.annotations({ title: "remote", description: "Session-scoped mobile remote control." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/remote.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/remote.ts new file mode 100644 index 000000000000..836e47237730 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/remote.ts @@ -0,0 +1,224 @@ +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Permission } from "@/permission" +import { Question } from "@/question" +import { RemoteAccess } from "@/remote/access" +import { RemoteEvent } from "@/remote/event" +import { SessionPrompt } from "@/session/prompt" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { EventV2 } from "@opencode-ai/core/event" +import { Cause, Effect, Queue, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { RemoteAdminApi, RemoteApi, RemotePairApi } from "../groups/remote" +import * as SessionError from "./session-errors" + +function eventData(data: unknown): Sse.Event { + return { _tag: "Event", event: "message", id: undefined, data: JSON.stringify(data) } +} + +function eventID() { + return EventV2.ID.create() +} + +function bearer(request: HttpServerRequest.HttpServerRequest) { + const match = /^Bearer\s+(.+)$/i.exec(request.headers.authorization ?? "") + return match?.[1] +} + +function remoteEventResponse(events: EventV2.Interface, sessionID: string) { + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const token = bearer(request) + const queue = yield* Queue.sliding>(64) + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (!RemoteEvent.shouldForward(event, sessionID)) return + Queue.offerUnsafe(queue, RemoteEvent.signal(event)) + }), + ) + yield* Effect.addFinalizer(() => unsubscribe) + + const output = Stream.fromQueue(queue) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })), + ) + + return HttpServerResponse.stream( + Stream.make({ id: eventID(), type: "server.connected", properties: { sessionID } }).pipe( + Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.takeWhile(() => !!token && RemoteAccess.authorized(token, sessionID)), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) + }) +} + +export const remoteAdminHandlers = HttpApiBuilder.group(RemoteAdminApi, "remote-admin", (handlers) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const workspaces = yield* Workspace.Service + const requireSession = (sessionID: Parameters[0]) => + SessionError.mapStorageNotFound(sessions.get(sessionID)) + const requireLocalSession = (sessionID: Parameters[0]) => + Effect.gen(function* () { + const session = yield* requireSession(sessionID) + if (!session.workspaceID) return session + const workspace = yield* workspaces.get(session.workspaceID) + if (!workspace) return yield* new HttpApiError.BadRequest({}) + const target = yield* WorkspaceAdapterRuntime.target(workspace) + if (target.type !== "local") return yield* new HttpApiError.BadRequest({}) + return session + }) + + return handlers + .handle("pair", (ctx) => + Effect.gen(function* () { + yield* requireLocalSession(ctx.params.sessionID) + return RemoteAccess.pair(ctx.params.sessionID) + }), + ) + .handle("revoke", (ctx) => + Effect.sync(() => { + RemoteAccess.revoke(ctx.params.sessionID) + return true + }), + ) + }), +) + +export const remotePairHandlers = HttpApiBuilder.group(RemotePairApi, "remote-pair", (handlers) => + Effect.succeed( + handlers.handle("redeem", (ctx) => { + const grant = RemoteAccess.redeem(ctx.payload.ticket) + return grant ? Effect.succeed(grant) : Effect.fail(new HttpApiError.Forbidden({})) + }), + ), +) + +export const remoteHandlers = HttpApiBuilder.group(RemoteApi, "remote", (handlers) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const status = yield* SessionStatus.Service + const permission = yield* Permission.Service + const question = yield* Question.Service + const events = yield* EventV2Bridge.Service + const scope = yield* Scope.Scope + + const unsubscribeDeleted = yield* events.listen((event) => + Effect.sync(() => { + if (event.type !== "session.deleted") return + const sessionID = (event.data as { sessionID?: unknown }).sessionID + if (typeof sessionID !== "string") return + RemoteAccess.revoke(sessionID as Parameters[0]) + }), + ) + yield* Effect.addFinalizer(() => unsubscribeDeleted) + + const requireSession = (sessionID: Parameters[0]) => + SessionError.mapStorageNotFound(sessions.get(sessionID)) + + return handlers + .handle("bootstrap", (ctx) => + Effect.gen(function* () { + const session = yield* requireSession(ctx.params.sessionID) + const messages = yield* SessionError.mapStorageNotFound(sessions.messages({ sessionID: ctx.params.sessionID })) + const currentStatus = yield* status.get(ctx.params.sessionID) + const pendingPermissions = (yield* permission.list()).filter((item) => item.sessionID === ctx.params.sessionID) + const pendingQuestions = (yield* question.list()).filter((item) => item.sessionID === ctx.params.sessionID) + return { + session: { title: session.title }, + messages: messages.map((message) => ({ + info: { role: message.info.role }, + parts: message.parts.flatMap((part) => { + if (part.type === "text" && !part.synthetic && !part.ignored) { + return [{ type: "text" as const, text: part.text }] + } + if (part.type === "tool") { + return [{ type: "tool" as const, tool: part.tool, state: { status: part.state.status } }] + } + return [] + }), + })), + status: { type: currentStatus.type }, + permissions: pendingPermissions.map((item) => ({ + id: item.id, + permission: item.permission, + patterns: item.patterns, + })), + questions: pendingQuestions.map((item) => ({ id: item.id, questions: item.questions })), + } + }), + ) + .handleRaw("events", (ctx) => remoteEventResponse(events, ctx.params.sessionID)) + .handle("message", (ctx) => + Effect.gen(function* () { + const session = yield* requireSession(ctx.params.sessionID) + const parts = ctx.payload.parts.map((part) => ({ type: "text" as const, text: part.text })) + const model = session.model + ? { providerID: session.model.providerID, modelID: session.model.id } + : undefined + yield* prompt + .prompt({ + sessionID: ctx.params.sessionID, + parts, + agent: session.agent, + model, + variant: session.model?.variant, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("remote prompt failed", { sessionID: ctx.params.sessionID, cause: Cause.pretty(cause) }), + ), + Effect.forkIn(scope, { startImmediately: true }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle("abort", (ctx) => prompt.cancel(ctx.params.sessionID).pipe(Effect.as(true))) + .handle("permission", (ctx) => + Effect.gen(function* () { + const request = (yield* permission.list()).find((item) => item.id === ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* new HttpApiError.BadRequest({}) + yield* permission + .reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return true + }), + ) + .handle("question", (ctx) => + Effect.gen(function* () { + const request = (yield* question.list()).find((item) => item.id === ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* new HttpApiError.BadRequest({}) + yield* question + .reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return true + }), + ) + .handle("questionReject", (ctx) => + Effect.gen(function* () { + const request = (yield* question.list()).find((item) => item.id === ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) return yield* new HttpApiError.BadRequest({}) + yield* question.reject(ctx.params.requestID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return true + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/remote-authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/remote-authorization.ts new file mode 100644 index 000000000000..05d9e6295a5c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/remote-authorization.ts @@ -0,0 +1,35 @@ +import { RemoteAccess } from "@/remote/access" +import { Effect, Layer } from "effect" +import { HttpServerRequest } from "effect/unstable/http" +import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" + +export class RemoteAuthorization extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiRemoteAuthorization", + { error: HttpApiError.UnauthorizedNoContent }, +) {} + +function bearer(request: HttpServerRequest.HttpServerRequest) { + const match = /^Bearer\s+(.+)$/i.exec(request.headers.authorization ?? "") + return match?.[1] +} + +function sessionID(request: HttpServerRequest.HttpServerRequest) { + return new URL(request.url, "http://localhost").pathname.match(/^\/remote\/session\/([^/]+)(?:\/|$)/)?.[1] +} + +export const remoteAuthorizationLayer = Layer.effect( + RemoteAuthorization, + Effect.succeed( + RemoteAuthorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const token = bearer(request) + const session = sessionID(request) + if (!token || !session || !RemoteAccess.authorized(token, session)) { + return yield* new HttpApiError.Unauthorized({}) + } + return yield* effect + }), + ), + ), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fb9d2db65621..f7d25c4bc99d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -28,6 +28,7 @@ import { Vcs } from "@/project/vcs" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" import { Question } from "@/question" +import { RemoteMobile } from "@/remote/mobile" import { SessionCompaction } from "@/session/compaction" import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" @@ -79,8 +80,10 @@ import { ptyConnectAuthorizationLayer, serverAuthorizationLayer, } from "./middleware/authorization" +import { remoteAuthorizationLayer } from "./middleware/remote-authorization" import { EventApi } from "./groups/event" import { PtyConnectApi } from "./groups/pty" +import { RemoteAdminApi, RemoteApi, RemotePairApi } from "./groups/remote" import { eventHandlers } from "./handlers/event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -96,6 +99,7 @@ import { projectCopyHandlers } from "./handlers/project-copy" import { providerHandlers } from "./handlers/provider" import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty" import { questionHandlers } from "./handlers/question" +import { remoteAdminHandlers, remoteHandlers, remotePairHandlers } from "./handlers/remote" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" @@ -131,6 +135,7 @@ const cors = (corsOptions?: CorsOptions) => // - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi. // - eventApiRoutes: typed SSE route with instance routing context and its existing API contract. // - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth. +// - remoteMobileRoute: public shell only; the pairing ticket stays in the URL fragment and API calls require scoped auth. // - instanceApiRoutes: remaining typed instance routes. // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer)) @@ -151,6 +156,15 @@ const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe( Layer.provide(ptyConnectHandlers), Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]), ) +const remoteAdminApiRoutes = HttpApiBuilder.layer(RemoteAdminApi).pipe( + Layer.provide(remoteAdminHandlers), + Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]), +) +const remotePairApiRoutes = HttpApiBuilder.layer(RemotePairApi).pipe(Layer.provide(remotePairHandlers)) +const remoteApiRoutes = HttpApiBuilder.layer(RemoteApi).pipe( + Layer.provide(remoteHandlers), + Layer.provide([remoteAuthorizationLayer, workspaceRoutingLive, instanceContextLayer]), +) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ configHandlers, @@ -191,6 +205,10 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe Layer.provide(authOnlyRouterLayer), ) +const remoteMobileRoute = HttpRouter.use((router) => + router.add("GET", "/remote/mobile", () => Effect.succeed(RemoteMobile.response())), +) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -277,9 +295,13 @@ export function createRoutes( rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, + remoteAdminApiRoutes, + remotePairApiRoutes, + remoteApiRoutes, instanceRoutes, serverRoutes, docRoute, + remoteMobileRoute, uiRoute, ).pipe( Layer.provide([ diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index de4dc672031e..c9b3afb06835 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -9,6 +9,7 @@ const RULES: Array = [ ] export function isLocalWorkspaceRoute(method: string, path: string) { + if (/^\/session\/[^/]+\/remote$/.test(path)) return true for (const rule of RULES) { if (rule.method && rule.method !== method) continue const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") @@ -22,6 +23,7 @@ export function getWorkspaceRouteSessionID(url: URL) { const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] ?? + url.pathname.match(/^\/remote\/session\/([^/]+)(?:\/|$)/)?.[1] ?? url.pathname.match(/^\/experimental\/session\/([^/]+)\/background$/)?.[1] if (!id) return null diff --git a/packages/opencode/test/remote/access.test.ts b/packages/opencode/test/remote/access.test.ts new file mode 100644 index 000000000000..2cb9e754248e --- /dev/null +++ b/packages/opencode/test/remote/access.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { RemoteAccess } from "@/remote/access" +import { SessionID } from "@/session/schema" + +afterEach(() => RemoteAccess.resetForTest()) + +describe("RemoteAccess", () => { + test("pairing tickets are one-use and create a session-scoped grant", () => { + const session = SessionID.make("ses_remote_a") + const other = SessionID.make("ses_remote_b") + const pair = RemoteAccess.pair(session, 1_000) + + const grant = RemoteAccess.redeem(pair.ticket, 2_000) + expect(grant?.sessionID).toBe(session) + expect(RemoteAccess.redeem(pair.ticket, 2_000)).toBeUndefined() + expect(RemoteAccess.authorized(grant!.token, session, 2_000)).toBe(true) + expect(RemoteAccess.authorized(grant!.token, other, 2_000)).toBe(false) + }) + + test("new pairing invalidates the previous pending ticket for the same session", () => { + const session = SessionID.make("ses_remote_pair_replace") + const first = RemoteAccess.pair(session, 1_000) + const second = RemoteAccess.pair(session, 2_000) + + expect(RemoteAccess.redeem(first.ticket, 3_000)).toBeUndefined() + expect(RemoteAccess.redeem(second.ticket, 3_000)?.sessionID).toBe(session) + }) + + test("new grant invalidates the previous phone grant for the same session", () => { + const session = SessionID.make("ses_remote_grant_replace") + const first = RemoteAccess.redeem(RemoteAccess.pair(session, 1_000).ticket, 2_000)! + const second = RemoteAccess.redeem(RemoteAccess.pair(session, 3_000).ticket, 4_000)! + + expect(RemoteAccess.authorized(first.token, session, 4_000)).toBe(false) + expect(RemoteAccess.authorized(second.token, session, 4_000)).toBe(true) + }) + + test("expired pairing ticket is rejected", () => { + const session = SessionID.make("ses_remote_expired") + const pair = RemoteAccess.pair(session, 1_000) + expect(RemoteAccess.redeem(pair.ticket, 1_000 + pair.expires_in * 1_000 + 1)).toBeUndefined() + }) + + test("revoke invalidates issued grants", () => { + const session = SessionID.make("ses_remote_revoke") + const pair = RemoteAccess.pair(session, 1_000) + const grant = RemoteAccess.redeem(pair.ticket, 2_000)! + + RemoteAccess.revoke(session) + expect(RemoteAccess.authorized(grant.token, session, 2_000)).toBe(false) + }) +}) diff --git a/packages/opencode/test/remote/event.test.ts b/packages/opencode/test/remote/event.test.ts new file mode 100644 index 000000000000..9319bb1b7256 --- /dev/null +++ b/packages/opencode/test/remote/event.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { RemoteEvent } from "@/remote/event" + +describe("remote events", () => { + test("allows only session-scoped UI refresh events", () => { + expect( + RemoteEvent.shouldForward( + { type: "message.part.updated", data: { sessionID: "ses_target", part: { output: "sensitive" } } }, + "ses_target", + ), + ).toBe(true) + expect( + RemoteEvent.shouldForward( + { type: "message.part.updated", data: { sessionID: "ses_other" } }, + "ses_target", + ), + ).toBe(false) + expect( + RemoteEvent.shouldForward( + { type: "session.diff", data: { sessionID: "ses_target", diff: "private patch" } }, + "ses_target", + ), + ).toBe(false) + expect( + RemoteEvent.shouldForward( + { type: "future.internal.event", data: { sessionID: "ses_target", secret: "private" } }, + "ses_target", + ), + ).toBe(false) + }) + + test("recognizes nested session ownership for known event shapes", () => { + expect( + RemoteEvent.shouldForward( + { type: "message.updated", data: { info: { sessionID: "ses_target" } } }, + "ses_target", + ), + ).toBe(true) + }) + + test("strips event payloads from the mobile stream", () => { + const input = { + id: "evt_1", + type: "permission.asked", + data: { sessionID: "ses_target", metadata: { command: "private command" } }, + } + expect(RemoteEvent.signal(input)).toEqual({ id: "evt_1", type: "permission.asked", properties: {} }) + }) +}) diff --git a/packages/opencode/test/remote/message.test.ts b/packages/opencode/test/remote/message.test.ts new file mode 100644 index 000000000000..10ed00c190ac --- /dev/null +++ b/packages/opencode/test/remote/message.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { Exit, Schema } from "effect" +import { RemoteMessagePayload } from "@/server/routes/instance/httpapi/groups/remote" + +const decode = Schema.decodeUnknownExit(RemoteMessagePayload) + +describe("remote message payload", () => { + test("accepts text-only prompts", () => { + expect(Exit.isSuccess(decode({ parts: [{ type: "text", text: "continue" }] }))).toBe(true) + }) + + test("rejects privileged prompt part types", () => { + const payloads = [ + { parts: [{ type: "file", mime: "text/plain", url: "file:///etc/passwd" }] }, + { parts: [{ type: "agent", name: "build" }] }, + { parts: [{ type: "subtask", prompt: "read secrets", description: "test", agent: "build" }] }, + ] + + for (const payload of payloads) expect(Exit.isFailure(decode(payload))).toBe(true) + }) +}) diff --git a/packages/opencode/test/remote/mobile.test.ts b/packages/opencode/test/remote/mobile.test.ts new file mode 100644 index 000000000000..9f3845f560b5 --- /dev/null +++ b/packages/opencode/test/remote/mobile.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { Script } from "node:vm" +import { RemoteMobile } from "@/remote/mobile" + +function clientScript() { + const match = RemoteMobile.markup().match(/