Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9a6a4e9
feat(remote): add session pairing and control api
AlexZander85 Aug 8, 2026
db5552d
fix(remote): harden pairing token storage
AlexZander85 Aug 8, 2026
e284c26
feat(remote): enforce single phone access
AlexZander85 Aug 8, 2026
4a79901
feat(remote): add mobile control client
AlexZander85 Aug 8, 2026
d8e6d5e
feat(remote): serve mobile control page
AlexZander85 Aug 8, 2026
8f2511c
feat(desktop): add restricted LAN remote gateway
AlexZander85 Aug 8, 2026
7c4f22b
feat(desktop): manage remote gateway lifecycle
AlexZander85 Aug 8, 2026
6b9123c
feat(desktop): expose remote gateway over IPC
AlexZander85 Aug 8, 2026
821a9b3
feat(app): add mobile remote pairing dialog
AlexZander85 Aug 8, 2026
b23e7d7
fix(desktop): harden remote gateway shutdown
AlexZander85 Aug 8, 2026
1ee0c4f
fix(desktop): keep remote pairing credentials in main process
AlexZander85 Aug 8, 2026
2921ce5
fix(remote): restrict mobile event stream
AlexZander85 Aug 8, 2026
98f21fb
fix(desktop): harden LAN remote gateway lifecycle
AlexZander85 Aug 8, 2026
dedd59b
fix(remote): stabilize mobile browser pairing
AlexZander85 Aug 8, 2026
69082b3
fix(remote): restrict session prompt capability
AlexZander85 Aug 8, 2026
6cc4c23
fix(desktop): pin remote gateway upstream origin
AlexZander85 Aug 8, 2026
12bebe5
fix(remote): minimize mobile session data
AlexZander85 Aug 8, 2026
8131ddb
fix(remote): keep pairing api internal
AlexZander85 Aug 8, 2026
15c16af
chore: merge upstream dev
AlexZander85 Aug 8, 2026
7ac6204
fix(desktop): reject malformed remote targets
AlexZander85 Aug 8, 2026
c14e6bd
fix(desktop): serialize remote pairing lifecycle
AlexZander85 Aug 8, 2026
5ae4ab7
test(remote): cover LAN pairing protocol end to end
AlexZander85 Aug 8, 2026
6495a31
fix(remote): hide internal text parts from mobile
AlexZander85 Aug 8, 2026
6b7c95a
fix(remote): sanitize events before buffering
AlexZander85 Aug 8, 2026
6df8130
fix(desktop): refresh active LAN gateway addresses
AlexZander85 Aug 8, 2026
45a5a71
fix(remote): keep stale actions session-scoped
AlexZander85 Aug 8, 2026
7e8b75d
fix(remote): preserve session model selection
AlexZander85 Aug 8, 2026
f26f661
fix(remote): close event streams after revoke
AlexZander85 Aug 8, 2026
6cf2973
fix(remote): make access revoke idempotent
AlexZander85 Aug 8, 2026
8d1a0ea
fix(remote): revoke access on session deletion
AlexZander85 Aug 8, 2026
d116426
fix(remote): reject non-local workspace pairing
AlexZander85 Aug 8, 2026
c196ad2
fix(desktop): prune stale remote pairing sessions
AlexZander85 Aug 8, 2026
53bcdcb
fix(desktop): guard pairing prune races
AlexZander85 Aug 8, 2026
89f87a7
fix(desktop): strip spoofed forwarding headers
AlexZander85 Aug 8, 2026
e47bcc6
test(remote): add mobile LAN remote E2E flow
AlexZander85 Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions packages/app/e2e/smoke/remote-mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -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<http.ServerResponse>()
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<void>((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<void>((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<http.ServerResponse>
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<Record<string, unknown>> {
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<string, unknown>
}

function json(response: http.ServerResponse, value: unknown) {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
response.end(JSON.stringify(value))
}
3 changes: 3 additions & 0 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -440,6 +441,7 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<SessionRemoteControl />
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
Expand Down Expand Up @@ -531,6 +533,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {

return (
<div class="flex items-center gap-2">
<SessionRemoteControl />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
Expand Down
Loading
Loading