feat(client): add browser host SDK
This commit is contained in:
parent
57c939f188
commit
f814e4758c
26 changed files with 3503 additions and 29 deletions
856
packages/client/test/node/browser-client.test.ts
Normal file
856
packages/client/test/node/browser-client.test.ts
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/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 { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { once } from "node:events"
|
||||
import { createServer } from "node:http"
|
||||
import { connect, type TLSSocket } from "node:tls"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import {
|
||||
Browser,
|
||||
BrowserDriver,
|
||||
BrowserDriverError,
|
||||
OpenCode,
|
||||
type BrowserDriverContext,
|
||||
type BrowserDriverInstance,
|
||||
type BrowserProxy,
|
||||
} from "@opencode-ai/client/node"
|
||||
|
||||
const initialState: Browser.State = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
}
|
||||
|
||||
describe("Node browser client", () => {
|
||||
test("rejects authentication failures on Bun without unsupported ws response events", async () => {
|
||||
const server = await controlServer("Basic expected")
|
||||
const adapter = driver("unauthorized")
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
rejected(client.browser.attach({ sessionID: "ses_node_unauthorized", driver: adapter.descriptor })),
|
||||
Bun.sleep(1_000).then(() => new Error("Authentication rejection timed out")),
|
||||
])
|
||||
expect(result.message).toContain("HTTP 401")
|
||||
expect(server.authorizations).toEqual([undefined])
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels a pending Bun authentication probe when attachment setup is aborted", async () => {
|
||||
const abort = new AbortController()
|
||||
const adapter = driver("preflight")
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
let cancelled = false
|
||||
const fetch: typeof globalThis.fetch = (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const signal = init?.signal
|
||||
if (!signal) return reject(new Error("Missing preflight signal"))
|
||||
const stop = () => {
|
||||
cancelled = true
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener("abort", stop, { once: true })
|
||||
if (signal.aborted) stop()
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: "http://127.0.0.1:1", fetch })
|
||||
const attaching = client.browser.attach({
|
||||
sessionID: "ses_node_preflight_abort",
|
||||
driver: adapter.descriptor,
|
||||
signal: abort.signal,
|
||||
})
|
||||
|
||||
await started
|
||||
const reason = new Error("stop preflight")
|
||||
abort.abort(reason)
|
||||
expect(await rejected(attaching)).toBe(reason)
|
||||
expect(cancelled).toBe(true)
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps constructor authentication immutable and waits for the lease sync acknowledgement", async () => {
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const server = await controlServer(authorization)
|
||||
const headers = new Headers({ authorization })
|
||||
const adapter = driver("first")
|
||||
const client = OpenCode.make({ baseUrl: server.url, headers })
|
||||
headers.set("authorization", "Basic changed")
|
||||
|
||||
try {
|
||||
expect(client.session).toBeDefined()
|
||||
expect(client.browser.attach).toBeFunction()
|
||||
let settled = false
|
||||
expect(adapter.descriptor).toBe(adapter.factory)
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_barrier", driver: adapter.factory })
|
||||
void attaching.then(() => {
|
||||
settled = true
|
||||
})
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
|
||||
expect(server.authorizations).toEqual([authorization])
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await next()
|
||||
if (sync.type !== "browser.control.sync") throw new Error("Expected browser control sync")
|
||||
expect(sync.attachments).toHaveLength(1)
|
||||
expect(sync.attachments[0]?.sessionID).toBe("ses_node_barrier")
|
||||
expect(sync.attachments[0]?.leaseID.startsWith("brl_")).toBe(true)
|
||||
expect(adapter.proxy?.url.startsWith("https://")).toBe(true)
|
||||
expect(adapter.proxy?.credentials.username).toBeTruthy()
|
||||
await Bun.sleep(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
if (!adapter.proxy) throw new Error("Browser proxy is unavailable")
|
||||
const proxyConnection = connectProxy(adapter.proxy, "target.example:443")
|
||||
await Bun.sleep(10)
|
||||
expect(server.tunnelConnections).toBe(0)
|
||||
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
expect(attachment.resource).toEqual({ name: "first" })
|
||||
|
||||
const tunnel = await server.nextTunnelConnection()
|
||||
const nextTunnel = tunnelReader(tunnel)
|
||||
tunnel.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
const open = await nextTunnel()
|
||||
expect(open).toMatchObject({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.open",
|
||||
sessionID: "ses_node_barrier",
|
||||
leaseID: sync.attachments[0]?.leaseID,
|
||||
target: { host: "target.example", port: 443 },
|
||||
},
|
||||
})
|
||||
tunnel.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const connected = await proxyConnection
|
||||
expect(connected.response.startsWith("HTTP/1.1 200 Connection Established")).toBe(true)
|
||||
connected.socket.destroy()
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await attachment.close()
|
||||
await closed
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.unsubscribeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("multiplexes Sessions, coalesces state, routes requests and cancellation, and preserves siblings", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const first = driver("first")
|
||||
const second = driver("second")
|
||||
|
||||
try {
|
||||
const attachingFirst = client.browser.attach({ sessionID: "ses_node_first", driver: first.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const firstSync = await nextSync(next)
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: firstSync.revision }),
|
||||
)
|
||||
const firstAttachment = await attachingFirst
|
||||
|
||||
const attachingSecond = client.browser.attach({ sessionID: "ses_node_second", driver: second.descriptor })
|
||||
const secondSync = await nextSync(next)
|
||||
expect(secondSync.attachments.map((attachment) => attachment.sessionID)).toEqual([
|
||||
"ses_node_first",
|
||||
"ses_node_second",
|
||||
])
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: secondSync.revision }),
|
||||
)
|
||||
const secondAttachment = await attachingSecond
|
||||
expect(server.connections).toBe(1)
|
||||
|
||||
second.setState({ ...initialState, title: "Intermediate" })
|
||||
const pendingState = await nextSync(next)
|
||||
second.setState({ ...initialState, title: "Latest" })
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: pendingState.revision }),
|
||||
)
|
||||
const latestState = await nextSync(next)
|
||||
expect(
|
||||
latestState.attachments.find((attachment) => attachment.sessionID === "ses_node_second")?.state.title,
|
||||
).toBe("Latest")
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: latestState.revision }),
|
||||
)
|
||||
|
||||
const secondLease = secondSync.attachments.find(
|
||||
(attachment) => attachment.sessionID === "ses_node_second",
|
||||
)?.leaseID
|
||||
if (!secondLease) throw new Error("Second browser lease is unavailable")
|
||||
const requestID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID,
|
||||
outcome: { type: "success", result: { type: "snapshot", content: "second" } },
|
||||
})
|
||||
|
||||
const commandState = { ...initialState, title: "Updated by command", generation: 2 }
|
||||
second.setExecute(async () => ({ type: "navigate", state: commandState }))
|
||||
const stateRequestID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: stateRequestID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "navigate", url: "https://updated.example/", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: stateRequestID,
|
||||
outcome: { type: "success", result: { state: { title: "Updated by command" } } },
|
||||
})
|
||||
const commandStateSync = await nextSync(next)
|
||||
expect(
|
||||
commandStateSync.attachments.find((attachment) => attachment.sessionID === "ses_node_second")?.state,
|
||||
).toEqual(commandState)
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.synced",
|
||||
revision: commandStateSync.revision,
|
||||
}),
|
||||
)
|
||||
|
||||
second.setExecute(async () => ({ type: "click", state: initialState }))
|
||||
const invalidID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: invalidID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: invalidID,
|
||||
outcome: { type: "failure", code: "protocol" },
|
||||
})
|
||||
|
||||
second.setExecute(async () => {
|
||||
throw new BrowserDriverError("stale_ref", "The reference is stale")
|
||||
})
|
||||
const errorID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: errorID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: errorID,
|
||||
outcome: { type: "failure", code: "stale_ref", message: "The reference is stale" },
|
||||
})
|
||||
|
||||
second.setExecute(async () => {
|
||||
throw Object.assign(new Error("Invalid adapter code"), { code: "not_a_browser_error" })
|
||||
})
|
||||
const unsafeErrorID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: unsafeErrorID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 2 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: unsafeErrorID,
|
||||
outcome: { type: "failure", code: "internal", message: "Invalid adapter code" },
|
||||
})
|
||||
|
||||
let resolveStarted!: () => void
|
||||
let resolveCancelled!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const cancelled = new Promise<void>((resolve) => {
|
||||
resolveCancelled = resolve
|
||||
})
|
||||
second.setExecute(
|
||||
(_command, options) =>
|
||||
new Promise<Browser.Result>((_, reject) => {
|
||||
resolveStarted()
|
||||
options.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolveCancelled()
|
||||
reject(Object.assign(new Error("cancelled"), { code: "aborted" }))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
const cancelID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: cancelID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
|
||||
}),
|
||||
)
|
||||
await started
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.cancel",
|
||||
requestID: cancelID,
|
||||
leaseID: secondLease,
|
||||
}),
|
||||
)
|
||||
await cancelled
|
||||
|
||||
await firstAttachment.close()
|
||||
const detached = await nextSync(next)
|
||||
expect(detached.attachments).toHaveLength(1)
|
||||
expect(detached.attachments[0]?.sessionID).toBe("ses_node_second")
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: detached.revision }),
|
||||
)
|
||||
expect(server.connections).toBe(1)
|
||||
expect(second.disposeCount).toBe(0)
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await secondAttachment.close()
|
||||
await closed
|
||||
expect(first.disposeCount).toBe(1)
|
||||
expect(second.disposeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("reconnects and republishes the complete attachment snapshot", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("reconnect")
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_reconnect", driver: adapter.descriptor })
|
||||
const original = await server.nextConnection()
|
||||
const nextOriginal = controlReader(original)
|
||||
original.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const initial = await nextSync(nextOriginal)
|
||||
original.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: initial.revision }),
|
||||
)
|
||||
const attachment = await attaching
|
||||
original.terminate()
|
||||
|
||||
const replacement = await server.nextConnection()
|
||||
const nextReplacement = controlReader(replacement)
|
||||
replacement.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const republished = await nextSync(nextReplacement)
|
||||
expect(republished.attachments).toEqual(initial.attachments)
|
||||
replacement.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: republished.revision }),
|
||||
)
|
||||
expect(server.connections).toBe(2)
|
||||
|
||||
const closed = once(replacement, "close")
|
||||
await attachment.close()
|
||||
await closed
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects duplicate Sessions and aborts setup while close remains idempotent", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("owner")
|
||||
|
||||
try {
|
||||
const invalid = driver("invalid")
|
||||
expect(
|
||||
(await rejected(client.browser.attach({ sessionID: "invalid", driver: invalid.descriptor }))).message,
|
||||
).toContain("valid Session ID")
|
||||
expect(invalid.createCount).toBe(0)
|
||||
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_owner", driver: adapter.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await nextSync(next)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
|
||||
const duplicate = driver("duplicate")
|
||||
expect(
|
||||
(await rejected(client.browser.attach({ sessionID: "ses_node_owner", driver: duplicate.descriptor }))).message,
|
||||
).toContain("already attached")
|
||||
expect(duplicate.createCount).toBe(0)
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await Promise.all([attachment.close(), attachment.close(), attachment[Symbol.asyncDispose]()])
|
||||
await closed
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.unsubscribeCount).toBe(1)
|
||||
|
||||
const abort = new AbortController()
|
||||
const abortedAdapter = driver("aborted")
|
||||
const aborted = client.browser.attach({
|
||||
sessionID: "ses_node_aborted",
|
||||
driver: abortedAdapter.descriptor,
|
||||
signal: abort.signal,
|
||||
})
|
||||
const abortSocket = await server.nextConnection()
|
||||
const nextAbort = controlReader(abortSocket)
|
||||
abortSocket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
await nextSync(nextAbort)
|
||||
const reason = new Error("stop attaching")
|
||||
const abortClosed = once(abortSocket, "close")
|
||||
abort.abort(reason)
|
||||
expect(await rejected(aborted)).toBe(reason)
|
||||
await abortClosed
|
||||
expect(abortedAdapter.disposeCount).toBe(1)
|
||||
expect(abortedAdapter.signal?.aborted).toBe(true)
|
||||
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort(reason)
|
||||
const unused = driver("unused")
|
||||
expect(
|
||||
await rejected(
|
||||
client.browser.attach({
|
||||
sessionID: "ses_node_unused",
|
||||
driver: unused.descriptor,
|
||||
signal: alreadyAborted.signal,
|
||||
}),
|
||||
),
|
||||
).toBe(reason)
|
||||
expect(unused.createCount).toBe(0)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("fails pending attachments on fatal control closes without reconnecting", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("fatal")
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_fatal", driver: adapter.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
await nextSync(next)
|
||||
const closed = once(socket, "close")
|
||||
socket.close(1002, "invalid control state")
|
||||
|
||||
expect((await rejected(attaching)).message).toContain("fatal code 1002")
|
||||
await closed
|
||||
await Bun.sleep(250)
|
||||
expect(server.connections).toBe(1)
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.signal?.aborted).toBe(true)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts non-cooperative driver creation and disposes a late instance", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const abort = new AbortController()
|
||||
let resolveStarted!: () => void
|
||||
let resolveFactory!: (instance: BrowserDriverInstance<{ readonly name: string }>) => void
|
||||
let resolveDisposed!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const disposed = new Promise<void>((resolve) => {
|
||||
resolveDisposed = resolve
|
||||
})
|
||||
const factory = () => {
|
||||
resolveStarted()
|
||||
return new Promise<BrowserDriverInstance<{ readonly name: string }>>((resolve) => {
|
||||
resolveFactory = resolve
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({
|
||||
sessionID: "ses_node_blocked_factory",
|
||||
driver: factory,
|
||||
signal: abort.signal,
|
||||
})
|
||||
await started
|
||||
const reason = new Error("abort blocked factory")
|
||||
abort.abort(reason)
|
||||
expect(
|
||||
await rejected(
|
||||
Promise.race([
|
||||
attaching,
|
||||
Bun.sleep(250).then(() => {
|
||||
throw new Error("Browser attachment did not abort promptly")
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toBe(reason)
|
||||
|
||||
resolveFactory({
|
||||
resource: { name: "late" },
|
||||
state: () => initialState,
|
||||
subscribe: () => () => undefined,
|
||||
execute: async () => ({
|
||||
type: "snapshot",
|
||||
state: initialState,
|
||||
format: "opencode.semantic.v1",
|
||||
content: "late",
|
||||
}),
|
||||
dispose: () => resolveDisposed(),
|
||||
})
|
||||
await disposed
|
||||
expect(server.authorizations).toEqual([])
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("cleans up in reverse acquisition order and surfaces the first error", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const first = new Error("unsubscribe failed")
|
||||
const second = new Error("dispose failed")
|
||||
const order: string[] = []
|
||||
let proxy: BrowserProxy | undefined
|
||||
let proxyOpenDuringDispose = false
|
||||
const descriptor = BrowserDriver.define((context) => {
|
||||
proxy = context.proxy
|
||||
return {
|
||||
resource: context.proxy,
|
||||
state: () => initialState,
|
||||
subscribe: () => () => {
|
||||
order.push("unsubscribe")
|
||||
throw first
|
||||
},
|
||||
execute: async () => ({
|
||||
type: "snapshot",
|
||||
state: initialState,
|
||||
format: "opencode.semantic.v1",
|
||||
content: "cleanup",
|
||||
}),
|
||||
dispose: async () => {
|
||||
order.push("dispose")
|
||||
proxyOpenDuringDispose = await proxyAvailable(context.proxy)
|
||||
throw second
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_cleanup", driver: descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await nextSync(next)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
|
||||
expect(await rejected(attachment.close())).toBe(first)
|
||||
expect(await rejected(attachment.close())).toBe(first)
|
||||
expect(order).toEqual(["unsubscribe", "dispose"])
|
||||
expect(proxyOpenDuringDispose).toBe(true)
|
||||
if (!proxy) throw new Error("Browser proxy is unavailable")
|
||||
expect(await proxyAvailable(proxy)).toBe(false)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
type Execute = BrowserDriverInstance<unknown>["execute"]
|
||||
|
||||
function driver(name: string) {
|
||||
let current = initialState
|
||||
let execute: Execute = async () => ({
|
||||
type: "snapshot",
|
||||
state: current,
|
||||
format: "opencode.semantic.v1",
|
||||
content: name,
|
||||
})
|
||||
let proxy: BrowserProxy | undefined
|
||||
let signal: AbortSignal | undefined
|
||||
let createCount = 0
|
||||
let disposeCount = 0
|
||||
let unsubscribeCount = 0
|
||||
const listeners = new Set<(state: Browser.State) => void>()
|
||||
const factory = ({ proxy: nextProxy, signal: nextSignal }: BrowserDriverContext) => {
|
||||
createCount++
|
||||
proxy = nextProxy
|
||||
signal = nextSignal
|
||||
return {
|
||||
resource: { name },
|
||||
state: () => current,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
if (listeners.delete(listener)) unsubscribeCount++
|
||||
}
|
||||
},
|
||||
execute: (command, options) => execute(command, options),
|
||||
dispose() {
|
||||
disposeCount++
|
||||
},
|
||||
}
|
||||
}
|
||||
const descriptor = BrowserDriver.define(factory)
|
||||
return {
|
||||
factory,
|
||||
descriptor,
|
||||
get proxy() {
|
||||
return proxy
|
||||
},
|
||||
get signal() {
|
||||
return signal
|
||||
},
|
||||
get createCount() {
|
||||
return createCount
|
||||
},
|
||||
get disposeCount() {
|
||||
return disposeCount
|
||||
},
|
||||
get unsubscribeCount() {
|
||||
return unsubscribeCount
|
||||
},
|
||||
setState(state: Browser.State) {
|
||||
current = state
|
||||
listeners.forEach((listener) => listener(state))
|
||||
},
|
||||
setExecute(next: Execute) {
|
||||
execute = next
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function rejected(promise: Promise<unknown>) {
|
||||
return promise.then(
|
||||
() => new Error("Expected operation to reject"),
|
||||
(error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
)
|
||||
}
|
||||
|
||||
async function nextSync(next: () => Promise<BrowserControl.FromDesktop>) {
|
||||
const message = await next()
|
||||
if (message.type !== "browser.control.sync") throw new Error("Expected browser control sync")
|
||||
return message
|
||||
}
|
||||
|
||||
async function controlServer(authorization?: string) {
|
||||
const http = createServer((request, response) => {
|
||||
response.statusCode = request.headers.authorization === authorization ? 200 : 401
|
||||
response.end()
|
||||
})
|
||||
const webSockets = new WebSocketServer({ noServer: true })
|
||||
const tunnels = new WebSocketServer({ noServer: true })
|
||||
const waiting: Array<(socket: WebSocket) => void> = []
|
||||
const queued: WebSocket[] = []
|
||||
const tunnelWaiting: Array<(socket: WebSocket) => void> = []
|
||||
const tunnelQueued: WebSocket[] = []
|
||||
const authorizations: Array<string | undefined> = []
|
||||
let connections = 0
|
||||
let tunnelConnections = 0
|
||||
webSockets.on("connection", (socket) => {
|
||||
connections++
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve(socket)
|
||||
else queued.push(socket)
|
||||
})
|
||||
tunnels.on("connection", (socket) => {
|
||||
tunnelConnections++
|
||||
const resolve = tunnelWaiting.shift()
|
||||
if (resolve) resolve(socket)
|
||||
else tunnelQueued.push(socket)
|
||||
})
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
authorizations.push(request.headers.authorization)
|
||||
if (request.headers.authorization !== authorization) {
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
return
|
||||
}
|
||||
if (
|
||||
request.url === "/api/browser/control" &&
|
||||
request.headers["sec-websocket-protocol"] === BROWSER_CONTROL_PROTOCOL
|
||||
) {
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
|
||||
return
|
||||
}
|
||||
if (
|
||||
request.url === "/api/browser/tunnel" &&
|
||||
request.headers["sec-websocket-protocol"] === BROWSER_TUNNEL_PROTOCOL
|
||||
) {
|
||||
tunnels.handleUpgrade(request, socket, head, (webSocket) => tunnels.emit("connection", webSocket, request))
|
||||
return
|
||||
}
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
})
|
||||
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === "string") throw new Error("Browser control server did not bind TCP")
|
||||
return {
|
||||
authorizations,
|
||||
get connections() {
|
||||
return connections
|
||||
},
|
||||
get tunnelConnections() {
|
||||
return tunnelConnections
|
||||
},
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
nextConnection() {
|
||||
const socket = queued.shift()
|
||||
if (socket) return Promise.resolve(socket)
|
||||
return new Promise<WebSocket>((resolve) => waiting.push(resolve))
|
||||
},
|
||||
nextTunnelConnection() {
|
||||
const socket = tunnelQueued.shift()
|
||||
if (socket) return Promise.resolve(socket)
|
||||
return new Promise<WebSocket>((resolve) => tunnelWaiting.push(resolve))
|
||||
},
|
||||
async close() {
|
||||
webSockets.clients.forEach((socket) => socket.terminate())
|
||||
tunnels.clients.forEach((socket) => socket.terminate())
|
||||
webSockets.close()
|
||||
tunnels.close()
|
||||
http.closeAllConnections()
|
||||
http.close()
|
||||
await Bun.sleep(10)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function proxyAvailable(proxy: BrowserProxy) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = connect({
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host,
|
||||
rejectUnauthorized: false,
|
||||
})
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
socket.destroy()
|
||||
resolve(available)
|
||||
}
|
||||
const timeout = setTimeout(() => finish(false), 250)
|
||||
timeout.unref()
|
||||
socket.once("secureConnect", () => finish(true))
|
||||
socket.once("error", () => finish(false))
|
||||
})
|
||||
}
|
||||
|
||||
function connectProxy(proxy: BrowserProxy, authority: string) {
|
||||
return new Promise<{ readonly socket: TLSSocket; readonly response: string }>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
socket.write(
|
||||
`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}\r\n\r\n`,
|
||||
)
|
||||
},
|
||||
)
|
||||
let response = ""
|
||||
socket.on("error", () => undefined)
|
||||
socket.once("error", reject)
|
||||
socket.on("data", (data) => {
|
||||
response += data.toString()
|
||||
if (!response.includes("\r\n\r\n")) return
|
||||
resolve({ socket, response })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function tunnelReader(socket: WebSocket) {
|
||||
const queued: WebSocket.RawData[] = []
|
||||
const waiting: Array<(data: WebSocket.RawData) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
if (!binary) throw new Error("Expected a binary browser tunnel frame")
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve(data)
|
||||
else queued.push(data)
|
||||
})
|
||||
return async () => {
|
||||
const data = queued.shift() ?? (await new Promise<WebSocket.RawData>((resolve) => waiting.push(resolve)))
|
||||
return Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(rawData(data)))
|
||||
}
|
||||
}
|
||||
|
||||
function controlReader(socket: WebSocket) {
|
||||
const queued: Array<{ readonly data: WebSocket.RawData; readonly binary: boolean }> = []
|
||||
const waiting: Array<(message: { readonly data: WebSocket.RawData; readonly binary: boolean }) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve({ data, binary })
|
||||
else queued.push({ data, binary })
|
||||
})
|
||||
return async () => {
|
||||
const message =
|
||||
queued.shift() ??
|
||||
(await new Promise<{ readonly data: WebSocket.RawData; readonly binary: boolean }>((resolve) =>
|
||||
waiting.push(resolve),
|
||||
))
|
||||
if (message.binary) throw new Error("Expected a text browser control message")
|
||||
return Effect.runPromise(BrowserControlProtocol.decodeFromDesktop(rawData(message.data)))
|
||||
}
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.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)
|
||||
}
|
||||
164
packages/client/test/node/package-smoke.ts
Normal file
164
packages/client/test/node/package-smoke.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { join, relative, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const directory = resolve(import.meta.dir, "../..")
|
||||
|
||||
test("built Node entrypoint imports in Node and treats HTTP authentication rejection as fatal", async () => {
|
||||
await buildClient()
|
||||
const output = await Bun.file(join(directory, "dist/node/index.js")).text()
|
||||
expect(output).not.toMatch(/(?:from\s+|import\s*)["']\.\.?\//)
|
||||
|
||||
const temporary = await mkdtemp(join(import.meta.dir, ".node-package-"))
|
||||
try {
|
||||
await Bun.write(join(temporary, "index.mjs"), output)
|
||||
await stageWorkspaceDependencies(temporary)
|
||||
const child = Bun.spawn(
|
||||
["node", "--input-type=module", "-e", nodeScenario(pathToFileURL(join(temporary, "index.mjs")).href)],
|
||||
{ cwd: temporary, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stderr || stdout)
|
||||
expect(stdout.trim()).toBe("ok")
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
async function buildClient() {
|
||||
const child = Bun.spawn([process.execPath, "run", "build"], {
|
||||
cwd: directory,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
}
|
||||
|
||||
async function stageWorkspaceDependencies(temporary: string) {
|
||||
const schema = join(temporary, "node_modules/@opencode-ai/schema")
|
||||
const protocol = join(temporary, "node_modules/@opencode-ai/protocol")
|
||||
await Promise.all([mkdir(schema, { recursive: true }), mkdir(protocol, { recursive: true })])
|
||||
|
||||
const schemaEntry = join(temporary, "schema.ts")
|
||||
const protocolEntry = join(temporary, "protocol.ts")
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
schemaEntry,
|
||||
[
|
||||
`export { Browser } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser.ts")))}`,
|
||||
`export { BrowserControl } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnel } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-tunnel.ts")))}`,
|
||||
`export { Session } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/session.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
Bun.write(
|
||||
protocolEntry,
|
||||
[
|
||||
`export { BrowserControlProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnelProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-tunnel.ts")))}`,
|
||||
`export { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/groups/browser.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
])
|
||||
const [schemaBuild, protocolBuild] = await Promise.all([
|
||||
Bun.build({
|
||||
entrypoints: [schemaEntry],
|
||||
outdir: schema,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
Bun.build({
|
||||
entrypoints: [protocolEntry],
|
||||
outdir: protocol,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
])
|
||||
if (!schemaBuild.success) throw new Error(schemaBuild.logs.map((log) => log.message).join("\n"))
|
||||
if (!protocolBuild.success) throw new Error(protocolBuild.logs.map((log) => log.message).join("\n"))
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
join(schema, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser": "./index.js",
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
"./session": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
Bun.write(
|
||||
join(protocol, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
"./groups/browser": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function importPath(from: string, to: string) {
|
||||
const path = relative(from, to).replaceAll("\\", "/")
|
||||
return path.startsWith(".") ? path : `./${path}`
|
||||
}
|
||||
|
||||
function nodeScenario(moduleURL: string) {
|
||||
return `import { createServer } from "node:http"
|
||||
const sdk = await import(${JSON.stringify(moduleURL)})
|
||||
if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make")
|
||||
if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define")
|
||||
if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError")
|
||||
if (!sdk.Browser.State) throw new Error("Missing canonical Browser export")
|
||||
|
||||
let upgrades = 0
|
||||
const server = createServer()
|
||||
server.on("upgrade", (_request, socket) => {
|
||||
upgrades++
|
||||
socket.end("HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n")
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("Server did not bind")
|
||||
let disposed = 0
|
||||
const driver = () => ({
|
||||
resource: undefined,
|
||||
state: () => ({ url: "about:blank", title: "", loading: false, canGoBack: false, canGoForward: false, generation: 0 }),
|
||||
subscribe: () => () => undefined,
|
||||
execute: async () => { throw new sdk.BrowserDriverError("internal", "unavailable") },
|
||||
dispose: () => { disposed++ },
|
||||
})
|
||||
const client = sdk.OpenCode.make({ baseUrl: \`http://127.0.0.1:\${address.port}\` })
|
||||
const error = await Promise.race([
|
||||
client.browser.attach({ sessionID: "ses_node_auth_rejection", driver }).then(
|
||||
() => new Error("Expected authentication rejection"),
|
||||
(cause) => cause,
|
||||
),
|
||||
new Promise((resolve) => setTimeout(() => resolve(new Error("Authentication rejection timed out")), 2_000)),
|
||||
])
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
if (!(error instanceof Error) || !error.message.includes("HTTP 401")) throw error
|
||||
if (upgrades !== 1) throw new Error(\`Expected one upgrade, received \${upgrades}\`)
|
||||
if (disposed !== 1) throw new Error(\`Expected one driver disposal, received \${disposed}\`)
|
||||
console.log("ok")`
|
||||
}
|
||||
22
packages/client/test/node/proxy-certificate.test.ts
Normal file
22
packages/client/test/node/proxy-certificate.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createHash, X509Certificate } from "node:crypto"
|
||||
import { createSecureContext } from "node:tls"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBrowserProxyCertificate } from "../../src/node/browser/proxy-certificate"
|
||||
|
||||
describe("browser proxy certificate", () => {
|
||||
test("creates a unique valid certificate pinned to its loopback names", () => {
|
||||
const hostname = "opencode-test.localhost"
|
||||
const first = createBrowserProxyCertificate(hostname)
|
||||
const second = createBrowserProxyCertificate(hostname)
|
||||
const certificate = new X509Certificate(first.certificate)
|
||||
const fingerprint = createHash("sha256").update(certificate.raw).digest("base64")
|
||||
|
||||
expect(first.key.startsWith("-----BEGIN PRIVATE KEY-----")).toBe(true)
|
||||
expect(first.certificate.startsWith("-----BEGIN CERTIFICATE-----")).toBe(true)
|
||||
expect(first.fingerprint).toBe(`sha256/${fingerprint}`)
|
||||
expect(certificate.subjectAltName).toContain(`DNS:${hostname}`)
|
||||
expect(certificate.subjectAltName).toContain("IP Address:127.0.0.1")
|
||||
expect(first.fingerprint).not.toBe(second.fingerprint)
|
||||
expect(() => createSecureContext({ key: first.key, cert: first.certificate })).not.toThrow()
|
||||
})
|
||||
})
|
||||
452
packages/client/test/node/proxy.test.ts
Normal file
452
packages/client/test/node/proxy.test.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { createHash } from "node:crypto"
|
||||
import { once } from "node:events"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { Socket } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Duplex } from "node:stream"
|
||||
import { connect, type TLSSocket } from "node:tls"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBrowserProxy } from "../../src/node/browser/proxy"
|
||||
|
||||
describe("browser proxy", () => {
|
||||
test("forwards authenticated CONNECT streams without directly dialing the target", async () => {
|
||||
let directConnections = 0
|
||||
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
|
||||
decoy.on("connection", () => directConnections++)
|
||||
await new Promise<void>((resolve) => decoy.listen(0, "127.0.0.1", resolve))
|
||||
const decoyAddress = decoy.address()
|
||||
if (decoyAddress === null || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
|
||||
const target = createServer((incoming, response) => response.end(`TARGET ${incoming.method} ${incoming.url}`))
|
||||
await new Promise<void>((resolve) => target.listen(0, "127.0.0.1", resolve))
|
||||
const targetAddress = target.address()
|
||||
if (targetAddress === null || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
|
||||
const opened: Array<{ host: string; port: number }> = []
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: async (destination, signal) => {
|
||||
opened.push(destination)
|
||||
const socket = new Socket({ allowHalfOpen: true })
|
||||
const abort = () => socket.destroy(new Error("cancelled"))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
socket.once("close", () => signal.removeEventListener("abort", abort))
|
||||
// Emulate the server-side tunnel dial. The local decoy must never receive a connection.
|
||||
socket.connect(targetAddress.port, "127.0.0.1")
|
||||
await once(socket, "connect")
|
||||
return socket
|
||||
},
|
||||
})
|
||||
try {
|
||||
const tunnel = await proxyConnect(proxy, `127.0.0.1:${decoyAddress.port}`)
|
||||
expect(tunnel.status).toBe(200)
|
||||
tunnel.socket.write(
|
||||
`GET /connected HTTP/1.1\r\nHost: 127.0.0.1:${decoyAddress.port}\r\nConnection: close\r\n\r\n`,
|
||||
)
|
||||
const response = await readAll(tunnel.socket, tunnel.leftover)
|
||||
expect(response).toContain("TARGET GET /connected")
|
||||
expect(opened).toEqual([{ host: "127.0.0.1", port: decoyAddress.port }])
|
||||
|
||||
const certificate = await peerCertificate(proxy)
|
||||
expect(`sha256/${createHash("sha256").update(certificate).digest("base64")}`).toBe(proxy.certificateFingerprint)
|
||||
expect(directConnections).toBe(0)
|
||||
} finally {
|
||||
await proxy.close()
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => target.close(() => resolve())),
|
||||
new Promise<void>((resolve) => decoy.close(() => resolve())),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("forwards absolute-form HTTP with Node framing and closes held-open targets", async () => {
|
||||
expect(await nodeAbsoluteFormScenario()).toEqual({
|
||||
unauthorized: { status: 407, body: "" },
|
||||
forwarded: { status: 200, body: "TARGET GET /absolute?q=1" },
|
||||
posted: { status: 200, body: "TARGET POST /post request body" },
|
||||
chunked: { status: 200, body: "TARGET CHUNKED" },
|
||||
held: { status: 200, body: "ok" },
|
||||
opened: 4,
|
||||
destinationsCorrect: true,
|
||||
directConnections: 0,
|
||||
heldDestroyed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("limits concurrent tunnel setup to six connections", async () => {
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
let started = 0
|
||||
const releases: Array<() => void> = []
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((resolve, reject) => {
|
||||
active++
|
||||
started++
|
||||
maximum = Math.max(maximum, active)
|
||||
const finish = (result: () => void) => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
active--
|
||||
result()
|
||||
}
|
||||
const abort = () => finish(() => reject(new Error("cancelled")))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
releases.push(() =>
|
||||
finish(() =>
|
||||
resolve(
|
||||
new Duplex({
|
||||
read() {},
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback()
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const connections = Array.from({ length: 7 }, () =>
|
||||
proxyConnect(proxy, "target.example:443").then(
|
||||
(result) => result.socket.destroy(),
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
try {
|
||||
await waitFor(() => started === 6)
|
||||
expect(maximum).toBe(6)
|
||||
expect(started).toBe(6)
|
||||
|
||||
releases.shift()?.()
|
||||
await waitFor(() => started === 7)
|
||||
expect(maximum).toBe(6)
|
||||
} finally {
|
||||
await proxy.close()
|
||||
await Promise.all(connections)
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts pending CONNECT setup when the downstream client closes", async () => {
|
||||
let aborted = false
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
reject(new Error("cancelled"))
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
}),
|
||||
})
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`CONNECT target.example:443 HTTP/1.1\r\nHost: target.example:443\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
try {
|
||||
await started
|
||||
socket.destroy()
|
||||
await waitFor(() => aborted)
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts pending absolute-form setup when the downstream client closes", async () => {
|
||||
let aborted = false
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
reject(new Error("cancelled"))
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
}),
|
||||
})
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`GET http://target.example/pending HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
try {
|
||||
await started
|
||||
socket.destroy()
|
||||
await waitFor(() => aborted)
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("closes absolute-form requests when the target tunnel cannot connect", async () => {
|
||||
const proxy = await createBrowserProxy({ connect: () => Promise.reject(new Error("target unavailable")) })
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`GET http://target.example/unavailable HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
socket.on("data", (chunk) => chunks.push(chunk))
|
||||
try {
|
||||
await once(socket, "close")
|
||||
expect(Buffer.concat(chunks).toString()).not.toContain("502 Bad Gateway")
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
type ProxyInfo = Awaited<ReturnType<typeof createBrowserProxy>>
|
||||
|
||||
function authorization(proxy: ProxyInfo) {
|
||||
return `Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function proxyClient(proxy: ProxyInfo, request: string) {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => socket.write(request),
|
||||
)
|
||||
socket.on("error", () => undefined)
|
||||
return socket
|
||||
}
|
||||
|
||||
async function nodeAbsoluteFormScenario() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-browser-proxy-"))
|
||||
try {
|
||||
const built = await Bun.build({
|
||||
entrypoints: [join(import.meta.dir, "../../src/node/browser/proxy.ts")],
|
||||
outdir: directory,
|
||||
naming: "browser-proxy.mjs",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
})
|
||||
if (!built.success) throw new Error(built.logs.map((log) => log.message).join("\n"))
|
||||
const output = built.outputs[0]
|
||||
if (!output) throw new Error("Browser proxy Node bundle was not emitted")
|
||||
const child = Bun.spawn(
|
||||
["node", "--input-type=module", "-e", absoluteFormScenario(pathToFileURL(output.path).href)],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (code !== 0) throw new Error(stderr || stdout)
|
||||
const result: unknown = JSON.parse(stdout)
|
||||
return result
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function absoluteFormScenario(moduleURL: string) {
|
||||
return `import { createBrowserProxy } from ${JSON.stringify(moduleURL)}
|
||||
import { once } from "node:events"
|
||||
import { Agent, createServer, request } from "node:http"
|
||||
import { Socket } from "node:net"
|
||||
import { Duplex } from "node:stream"
|
||||
import { connect } from "node:tls"
|
||||
|
||||
const proxyRequest = async (proxy, path, authenticated, options = {}) => {
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const socket = connect({ host: "127.0.0.1", port: proxy.port, servername: proxy.host, rejectUnauthorized: false }, () => resolve(socket))
|
||||
socket.once("error", reject)
|
||||
})
|
||||
const agent = new Agent({ keepAlive: false, maxSockets: 1, noDelay: false })
|
||||
agent.createConnection = () => socket
|
||||
const body = Buffer.from(options.body ?? "")
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const incoming = request({
|
||||
agent,
|
||||
hostname: proxy.host,
|
||||
path,
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
connection: "close",
|
||||
...(authenticated ? { "proxy-authorization": "Basic " + Buffer.from(proxy.credentials.username + ":" + proxy.credentials.password).toString("base64") } : {}),
|
||||
...(options.chunked ? { "transfer-encoding": "chunked" } : { "content-length": body.byteLength.toString() }),
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = []
|
||||
response.on("data", (chunk) => chunks.push(chunk))
|
||||
response.once("end", () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString() }))
|
||||
})
|
||||
incoming.once("error", reject)
|
||||
incoming.end(body)
|
||||
})
|
||||
} finally {
|
||||
agent.destroy()
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
let directConnections = 0
|
||||
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
|
||||
decoy.on("connection", () => directConnections++)
|
||||
decoy.listen(0, "127.0.0.1")
|
||||
await once(decoy, "listening")
|
||||
const decoyAddress = decoy.address()
|
||||
if (!decoyAddress || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
|
||||
|
||||
const targetServer = createServer((incoming, response) => {
|
||||
const chunks = []
|
||||
incoming.on("data", (chunk) => chunks.push(chunk))
|
||||
incoming.on("end", () => {
|
||||
response.writeHead(200, { "content-type": "text/plain" })
|
||||
if (incoming.url === "/chunked") {
|
||||
response.write("TARGET ")
|
||||
response.end("CHUNKED")
|
||||
return
|
||||
}
|
||||
const body = Buffer.concat(chunks).toString()
|
||||
response.end("TARGET " + incoming.method + " " + incoming.url + (body ? " " + body : ""))
|
||||
})
|
||||
})
|
||||
targetServer.listen(0, "127.0.0.1")
|
||||
await once(targetServer, "listening")
|
||||
const targetAddress = targetServer.address()
|
||||
if (!targetAddress || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
|
||||
|
||||
const opened = []
|
||||
let held
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: async (destination, signal) => {
|
||||
opened.push(destination)
|
||||
if (destination.host === "held.example") {
|
||||
let responded = false
|
||||
held = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
if (!responded && Buffer.from(chunk).includes(Buffer.from("\\r\\n\\r\\n"))) {
|
||||
responded = true
|
||||
this.push(Buffer.from("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok"))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
})
|
||||
return held
|
||||
}
|
||||
const socket = new Socket({ allowHalfOpen: true })
|
||||
const abort = () => socket.destroy(new Error("cancelled"))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
socket.once("close", () => signal.removeEventListener("abort", abort))
|
||||
socket.connect(targetAddress.port, "127.0.0.1")
|
||||
await once(socket, "connect")
|
||||
return socket
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const base = "http://127.0.0.1:" + decoyAddress.port
|
||||
const unauthorized = await proxyRequest(proxy, base + "/private", false)
|
||||
const forwarded = await proxyRequest(proxy, base + "/absolute?q=1", true)
|
||||
const posted = await proxyRequest(proxy, base + "/post", true, { method: "POST", body: "request body", chunked: true })
|
||||
const chunked = await proxyRequest(proxy, base + "/chunked", true)
|
||||
const heldResult = await proxyRequest(proxy, "http://held.example/held-open", true)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
console.log(JSON.stringify({
|
||||
unauthorized,
|
||||
forwarded,
|
||||
posted,
|
||||
chunked,
|
||||
held: heldResult,
|
||||
opened: opened.length,
|
||||
destinationsCorrect: opened.slice(0, 3).every((item) => item.host === "127.0.0.1" && item.port === decoyAddress.port) && opened[3]?.host === "held.example" && opened[3]?.port === 80,
|
||||
directConnections,
|
||||
heldDestroyed: held?.destroyed === true,
|
||||
}))
|
||||
} finally {
|
||||
await proxy.close()
|
||||
targetServer.closeAllConnections()
|
||||
decoy.closeAllConnections()
|
||||
await Promise.all([new Promise((resolve) => targetServer.close(resolve)), new Promise((resolve) => decoy.close(resolve))])
|
||||
}`
|
||||
}
|
||||
|
||||
function proxyConnect(proxy: ProxyInfo, destination: string) {
|
||||
return new Promise<{ status: number; socket: TLSSocket; leftover: Buffer }>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
socket.write(
|
||||
`CONNECT ${destination} HTTP/1.1\r\nHost: ${destination}\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
},
|
||||
)
|
||||
let buffered = Buffer.alloc(0)
|
||||
let settled = false
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
const end = buffered.indexOf("\r\n\r\n")
|
||||
if (end < 0) return
|
||||
socket.off("data", onData)
|
||||
const status = Number(buffered.toString("ascii", 0, end).split(" ", 2)[1])
|
||||
settled = true
|
||||
resolve({ status, socket, leftover: buffered.subarray(end + 4) })
|
||||
}
|
||||
socket.on("data", onData)
|
||||
socket.once("error", reject)
|
||||
socket.once("close", () => {
|
||||
if (!settled) reject(new Error("Browser proxy connection closed before responding"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function readAll(socket: TLSSocket, initial: Buffer) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const chunks = [initial]
|
||||
socket.on("data", (chunk) => chunks.push(chunk))
|
||||
socket.on("end", () => resolve(Buffer.concat(chunks).toString()))
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function peerCertificate(proxy: ProxyInfo) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
resolve(socket.getPeerCertificate().raw)
|
||||
socket.end()
|
||||
},
|
||||
)
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (check()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error("Timed out waiting for browser proxy test condition")
|
||||
}
|
||||
208
packages/client/test/node/tunnel.test.ts
Normal file
208
packages/client/test/node/tunnel.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { once } from "node:events"
|
||||
import { createServer } from "node:http"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import { BrowserTunnelError, openBrowserTunnel } from "../../src/node/browser/tunnel"
|
||||
|
||||
const sessionID = Session.ID.make("ses_client_tunnel")
|
||||
const leaseID = Browser.LeaseID.make("brl_clienttunnel")
|
||||
|
||||
describe("browser tunnel", () => {
|
||||
test("enforces byte and frame windows and preserves both half-closes", async () => {
|
||||
const server = await tunnelServer()
|
||||
try {
|
||||
const opening = openBrowserTunnel({
|
||||
endpoint: server.endpoint,
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(443) },
|
||||
})
|
||||
const socket = await server.connected
|
||||
const next = frameReader(socket)
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
const open = await next()
|
||||
expect(open).toMatchObject({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: "target.example", port: 443 },
|
||||
},
|
||||
})
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const tunnel = await opening
|
||||
|
||||
let writeSettled = false
|
||||
const writing = new Promise<void>((resolve, reject) => {
|
||||
tunnel.write(Buffer.alloc(BrowserTunnelProtocol.InitialWindowBytes + 1, 7), (error) => {
|
||||
writeSettled = true
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
const sent = []
|
||||
for (let index = 0; index < 4; index++) sent.push(await next())
|
||||
expect(sent.every((frame) => frame.type === "data" && frame.data.byteLength === 64 * 1_024)).toBe(true)
|
||||
await Bun.sleep(25)
|
||||
expect(writeSettled).toBe(false)
|
||||
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(64 * 1_024),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const final = await next()
|
||||
expect(final.type === "data" && final.data.byteLength).toBe(1)
|
||||
await writing
|
||||
|
||||
const received = once(tunnel, "data")
|
||||
socket.send(BrowserTunnelProtocol.data(Buffer.from("from server")), { binary: true })
|
||||
expect(Buffer.from((await received)[0]).toString()).toBe("from server")
|
||||
expect(await next()).toEqual({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.window",
|
||||
bytes: Buffer.byteLength("from server"),
|
||||
frames: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const localEnd = next()
|
||||
tunnel.end()
|
||||
expect(await localEnd).toEqual({ type: "control", message: { type: "browser.tunnel.end" } })
|
||||
const remoteEnd = once(tunnel, "end")
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.end" }), { binary: true })
|
||||
await remoteEnd
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects window credit that does not exactly match complete frames", async () => {
|
||||
const server = await tunnelServer()
|
||||
try {
|
||||
const opening = openBrowserTunnel({
|
||||
endpoint: server.endpoint,
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(80) },
|
||||
})
|
||||
const socket = await server.connected
|
||||
const next = frameReader(socket)
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
await next()
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const tunnel = await opening
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
tunnel.write(Buffer.from("frame"), (error) => (error ? reject(error) : resolve())),
|
||||
)
|
||||
await next()
|
||||
const failed = once(tunnel, "error")
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(4),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const error = (await failed)[0]
|
||||
if (!(error instanceof BrowserTunnelError)) throw new Error("expected browser tunnel error")
|
||||
expect(error.code).toBe("protocol_error")
|
||||
expect(await next()).toEqual({
|
||||
type: "control",
|
||||
message: { type: "browser.tunnel.reset", code: "protocol_error" },
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function tunnelServer() {
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const http = createServer()
|
||||
const webSockets = new WebSocketServer({ noServer: true })
|
||||
let resolveConnected!: (socket: WebSocket) => void
|
||||
const connected = new Promise<WebSocket>((resolve) => {
|
||||
resolveConnected = resolve
|
||||
})
|
||||
webSockets.once("connection", resolveConnected)
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
if (
|
||||
request.url !== "/api/browser/tunnel" ||
|
||||
request.headers.authorization !== authorization ||
|
||||
request.headers["sec-websocket-protocol"] !== BROWSER_TUNNEL_PROTOCOL
|
||||
) {
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
return
|
||||
}
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
|
||||
})
|
||||
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === "string") throw new Error("browser tunnel server did not bind TCP")
|
||||
return {
|
||||
connected,
|
||||
endpoint: {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
authorization,
|
||||
},
|
||||
async close() {
|
||||
webSockets.clients.forEach((socket) => socket.terminate())
|
||||
webSockets.close()
|
||||
http.closeAllConnections()
|
||||
http.close()
|
||||
await Bun.sleep(10)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function frameReader(socket: WebSocket) {
|
||||
const queued: Array<{ readonly data: WebSocket.RawData; readonly binary: boolean }> = []
|
||||
const waiting: Array<(message: { readonly data: WebSocket.RawData; readonly binary: boolean }) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve({ data, binary })
|
||||
else queued.push({ data, binary })
|
||||
})
|
||||
return async () => {
|
||||
const message =
|
||||
queued.shift() ??
|
||||
(await new Promise<{ readonly data: WebSocket.RawData; readonly binary: boolean }>((resolve) =>
|
||||
waiting.push(resolve),
|
||||
))
|
||||
if (!message.binary) throw new Error("Expected a binary browser tunnel frame")
|
||||
return Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(rawData(message.data)))
|
||||
}
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue