opencode/packages/server/test/browser.test.ts
2026-07-28 16:55:52 +10:00

468 lines
19 KiB
TypeScript

import { NodeSocket } from "@effect/platform-node"
import { Browser } from "@opencode-ai/schema/browser"
import { BrowserControl } from "@opencode-ai/schema/browser-control"
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
import { Session } from "@opencode-ai/schema/session"
import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
import { expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Queue, Schema, Scope } from "effect"
import { TestConsole } from "effect/testing"
import { HttpServer } from "effect/unstable/http"
import { randomBytes } from "node:crypto"
import { request } from "node:http"
import { createServer } from "node:net"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
const encodeControl = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromDesktop))
const decodeControl = Schema.decodeUnknownSync(Schema.fromJsonString(BrowserControl.FromServer))
const startServer = Effect.fn("BrowserServerTest.startServer")(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
})
const url = new URL(HttpServer.formatAddress(server.address))
url.protocol = "ws:"
return url
})
const open = Effect.fn("BrowserServerTest.open")(function* (
url: URL,
protocol: string,
headers: Record<string, string> = { authorization },
) {
const messages = yield* Queue.unbounded<{ readonly data: string | Uint8Array; readonly binary: boolean }, Error>()
const closed = yield* Deferred.make<{ readonly code: number; readonly reason: string }>()
const webSocket = yield* Effect.acquireRelease(
Effect.callback<NodeSocket.NodeWS.WebSocket, Error>((resume) => {
const webSocket = new NodeSocket.NodeWS.WebSocket(url, protocol, { headers })
webSocket.on("message", (data, binary) =>
Queue.offerUnsafe(messages, { data: binary ? bytes(data) : Buffer.from(bytes(data)).toString(), binary }),
)
webSocket.once("close", (code, reason) =>
Deferred.doneUnsafe(closed, Effect.succeed({ code, reason: reason.toString() })),
)
const onOpen = () => {
webSocket.off("error", onError)
resume(Effect.succeed(webSocket))
}
const onError = (error: Error) => {
webSocket.off("open", onOpen)
resume(Effect.fail(error))
}
webSocket.once("open", onOpen)
webSocket.once("error", onError)
return Effect.sync(() => webSocket.terminate())
}),
(webSocket) => Effect.sync(() => webSocket.terminate()),
)
return { webSocket, messages, closed }
})
const upgradeStatus = (url: URL, protocol: string, headers: Record<string, string> = { authorization }) =>
Effect.callback<number, Error>((resume) => {
const target = new URL(url)
target.protocol = "http:"
const upgrade = request(target, {
headers: {
...headers,
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-key": randomBytes(16).toString("base64"),
"sec-websocket-protocol": protocol,
"sec-websocket-version": "13",
},
})
upgrade.once("response", (response) => {
resume(Effect.succeed(response.statusCode ?? 0))
response.destroy()
})
upgrade.once("upgrade", (response, socket) => {
resume(Effect.succeed(response.statusCode ?? 101))
socket.destroy()
})
upgrade.once("error", (error) => resume(Effect.fail(error)))
upgrade.end()
return Effect.sync(() => upgrade.destroy())
})
const next = (webSocket: Effect.Success<ReturnType<typeof open>>) => Queue.take(webSocket.messages)
const exchange = Effect.fn("BrowserServerTest.exchange")(function* (
webSocket: Effect.Success<ReturnType<typeof open>>,
message: string | Uint8Array,
) {
yield* Effect.callback<void, Error>((resume) =>
webSocket.webSocket.send(message, { binary: typeof message !== "string" }, (error) =>
resume(error ? Effect.fail(error) : Effect.void),
),
)
return yield* next(webSocket)
})
function bytes(data: NodeSocket.NodeWS.RawData) {
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
const tunnelControl = Effect.fn("BrowserServerTest.tunnelControl")(function* (frame: {
readonly data: string | Uint8Array
}) {
const decoded = yield* BrowserTunnelProtocol.decodeFromServer(frame.data)
if (decoded.type !== "control") throw new Error("expected browser tunnel control frame")
return decoded.message
})
const echoServer = Effect.gen(function* () {
const connected = yield* Deferred.make<void>()
const ended = yield* Deferred.make<void>()
const sentEnd = yield* Deferred.make<void>()
const sockets = new Set<import("node:net").Socket>()
const server = yield* Effect.acquireRelease(
Effect.callback<ReturnType<typeof createServer>, Error>((resume) => {
const server = createServer({ allowHalfOpen: true }, (socket) => {
sockets.add(socket)
Deferred.doneUnsafe(connected, Effect.void)
socket.on("data", (data) => socket.write(data))
socket.on("end", () => {
Deferred.doneUnsafe(ended, Effect.void)
socket.end(() => Deferred.doneUnsafe(sentEnd, Effect.void))
})
socket.once("close", () => sockets.delete(socket))
})
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)))
return Effect.sync(() => server.close())
}),
(server) =>
Effect.sync(() => {
for (const socket of sockets) socket.destroy()
server.close()
}),
)
return { server, connected, ended, sentEnd }
})
const pushServer = Effect.acquireRelease(
Effect.callback<ReturnType<typeof createServer>, Error>((resume) => {
const server = createServer((socket) => socket.end(Buffer.alloc(512 * 1_024, 7)))
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)))
return Effect.sync(() => server.close())
}),
(server) => Effect.sync(() => server.close()),
)
const collectData = (
tunnel: Effect.Success<ReturnType<typeof open>>,
bytes = 0,
frames = 0,
): Effect.Effect<{ readonly bytes: number; readonly frames: number }, Error | BrowserTunnelProtocol.FrameError> =>
Effect.gen(function* () {
const decoded = yield* BrowserTunnelProtocol.decodeFromServer((yield* next(tunnel)).data)
if (decoded.type === "data") {
tunnel.webSocket.send(
BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.window",
bytes: BrowserTunnel.WindowBytes.make(decoded.data.byteLength),
frames: BrowserTunnel.FrameWindow.make(1),
}),
{ binary: true },
)
return yield* collectData(tunnel, bytes + decoded.data.byteLength, frames + 1)
}
if (decoded.message.type === "browser.tunnel.window") return yield* collectData(tunnel, bytes, frames)
if (decoded.message.type === "browser.tunnel.end") return { bytes, frames }
return yield* Effect.fail(new Error(`Unexpected browser tunnel message: ${decoded.message.type}`))
})
it.live(
"authenticates browser upgrades and tunnels TCP through an attached lease",
() =>
Effect.gen(function* () {
const base = yield* startServer()
const controlURL = new URL("/api/browser/control", base)
const tunnelURL = new URL("/api/browser/tunnel", base)
expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, {})).toBe(401)
const queryAuth = new URL(controlURL)
queryAuth.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64"))
expect(yield* upgradeStatus(queryAuth, BROWSER_CONTROL_PROTOCOL, {})).toBe(401)
for (const [path, protocol] of [
["/api/browser/control/", BROWSER_CONTROL_PROTOCOL],
["/%61pi/%62rowser/%63ontrol", BROWSER_CONTROL_PROTOCOL],
["/API//browser/control;jsessionid=ignored", BROWSER_CONTROL_PROTOCOL],
["/api/browser/tunnel/", BROWSER_TUNNEL_PROTOCOL],
["/%61pi/%62rowser/%74unnel", BROWSER_TUNNEL_PROTOCOL],
] as const) {
const alternate = new URL(path, base)
alternate.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64"))
expect(yield* upgradeStatus(alternate, protocol, {})).toBe(401)
}
expect(yield* upgradeStatus(controlURL, "opencode.browser.control.invalid")).toBe(426)
expect(
yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, {
authorization,
origin: "https://malicious.example",
}),
).toBe(403)
const control = yield* open(controlURL, BROWSER_CONTROL_PROTOCOL)
expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" })
expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL)).toBe(409)
const sessionID = Session.ID.make("ses_browser_server")
const leaseID = Browser.LeaseID.make("brl_browserserver")
const sessionURL = new URL("/api/session", base)
sessionURL.protocol = "http:"
const created = yield* Effect.promise(() =>
fetch(sessionURL, {
method: "POST",
headers: { authorization, "content-type": "application/json" },
body: JSON.stringify({ id: sessionID }),
}),
)
expect(created.status).toBe(200)
const acknowledged = yield* exchange(
control,
encodeControl({
type: "browser.control.sync",
revision: 1,
attachments: [
{
sessionID,
leaseID,
state: {
url: "http://localhost/",
title: "Local",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 1,
},
},
],
}),
)
expect(acknowledged.binary).toBe(false)
expect(decodeControl(acknowledged.data)).toEqual({ type: "browser.control.synced", revision: 1 })
const stale = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
expect(yield* tunnelControl(yield* next(stale))).toEqual({ type: "browser.tunnel.ready" })
const rejected = yield* exchange(
stale,
BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.open",
sessionID,
leaseID: Browser.LeaseID.make("brl_stale"),
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(1) },
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
}),
)
expect(yield* tunnelControl(rejected)).toMatchObject({
type: "browser.tunnel.rejected",
code: "stale_lease",
})
stale.webSocket.terminate()
const target = yield* echoServer
const address = target.server.address()
if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP")
const tunnel = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
const openFrame = BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.open",
sessionID,
leaseID,
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) },
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
})
yield* Effect.callback<void, Error>((resume) =>
tunnel.webSocket.send(openFrame, { binary: true }, (error) => resume(error ? Effect.fail(error) : Effect.void)),
)
yield* Deferred.await(target.connected)
const opened = yield* next(tunnel)
expect(opened.binary).toBe(true)
expect(yield* tunnelControl(opened)).toEqual({
type: "browser.tunnel.opened",
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
})
yield* Effect.callback<void, Error>((resume) =>
tunnel.webSocket.send(BrowserTunnelProtocol.data(Buffer.from("browser tunnel")), { binary: true }, (error) =>
resume(error ? Effect.fail(error) : Effect.void),
),
)
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({
type: "browser.tunnel.window",
bytes: BrowserTunnel.WindowBytes.make(Buffer.byteLength("browser tunnel")),
frames: BrowserTunnel.FrameWindow.make(1),
})
const echoed = yield* next(tunnel)
expect(echoed.binary).toBe(true)
const echoedFrame = yield* BrowserTunnelProtocol.decodeFromServer(echoed.data)
if (echoedFrame.type !== "data") throw new Error("expected browser tunnel data frame")
expect(Buffer.from(echoedFrame.data).toString()).toBe("browser tunnel")
tunnel.webSocket.send(
BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.window",
bytes: BrowserTunnel.WindowBytes.make(echoedFrame.data.byteLength),
frames: BrowserTunnel.FrameWindow.make(1),
}),
{ binary: true },
)
tunnel.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true })
yield* Deferred.await(target.ended)
yield* Deferred.await(target.sentEnd)
const ended = yield* next(tunnel)
expect(ended.binary).toBe(true)
expect(yield* tunnelControl(ended)).toEqual({ type: "browser.tunnel.end" })
tunnel.webSocket.terminate()
const pushing = yield* pushServer
const pushingAddress = pushing.address()
if (pushingAddress === null || typeof pushingAddress === "string") throw new Error("push server did not bind TCP")
const large = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
yield* tunnelControl(yield* next(large))
yield* exchange(
large,
BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.open",
sessionID,
leaseID,
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(pushingAddress.port) },
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.MaxDataBytes),
receiveFrames: BrowserTunnel.FrameWindow.make(1),
}),
)
const pushed = yield* collectData(large)
expect(pushed.bytes).toBe(512 * 1_024)
expect(pushed.frames).toBeGreaterThanOrEqual(8)
large.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true })
large.webSocket.terminate()
control.webSocket.terminate()
}),
15_000,
)
it.live(
"rejects an oversized raw tunnel frame before opening a tunnel",
() =>
Effect.gen(function* () {
const base = yield* startServer()
const control = new Uint8Array(BrowserTunnelProtocol.MaxControlBytes + 2)
control[0] = BrowserTunnelProtocol.FrameType.Control
for (const frame of [new Uint8Array(BrowserTunnelProtocol.MaxDataBytes + 2), control]) {
const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL)
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
const rejected = yield* exchange(tunnel, frame)
expect(yield* tunnelControl(rejected)).toMatchObject({
type: "browser.tunnel.rejected",
code: "invalid_open",
})
expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1009 })
}
}),
10_000,
)
it.live(
"closes active browser transports with the service-restart code",
() =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
}).pipe(Effect.provideService(Scope.Scope, scope))
const base = new URL(HttpServer.formatAddress(server.address))
base.protocol = "ws:"
const control = yield* open(new URL("/api/browser/control", base), BROWSER_CONTROL_PROTOCOL)
expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" })
const sessionID = Session.ID.make("ses_browser_shutdown")
const leaseID = Browser.LeaseID.make("brl_browsershutdown")
const sessionURL = new URL("/api/session", base)
sessionURL.protocol = "http:"
const created = yield* Effect.promise(() =>
fetch(sessionURL, {
method: "POST",
headers: { authorization, "content-type": "application/json" },
body: JSON.stringify({ id: sessionID }),
}),
)
expect(created.status).toBe(200)
expect(
decodeControl(
(yield* exchange(
control,
encodeControl({
type: "browser.control.sync",
revision: 1,
attachments: [
{
sessionID,
leaseID,
state: {
url: "http://localhost/",
title: "Local",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 1,
},
},
],
}),
)).data,
),
).toEqual({ type: "browser.control.synced", revision: 1 })
const target = yield* echoServer
const address = target.server.address()
if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP")
const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL)
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
expect(
yield* tunnelControl(
yield* exchange(
tunnel,
BrowserTunnelProtocol.encodeFromDesktop({
type: "browser.tunnel.open",
sessionID,
leaseID,
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) },
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
}),
),
),
).toMatchObject({ type: "browser.tunnel.opened" })
const closing = yield* Effect.forkChild(Scope.close(scope, Exit.void), { startImmediately: true })
expect(yield* Deferred.await(control.closed)).toMatchObject({ code: 1012 })
expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1012 })
yield* Fiber.join(closing)
expect((yield* TestConsole.logLines).filter((line) => String(line).includes("Socket already assigned"))).toEqual(
[],
)
}),
10_000,
)