Bläddra i källkod

feat(client): add browser host SDK

LukeParkerDev 1 vecka sedan
förälder
incheckning
0ebce16396

+ 12 - 9
bun.lock

@@ -184,12 +184,14 @@
       "dependencies": {
         "@opencode-ai/protocol": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
+        "ws": "8.21.0",
       },
       "devDependencies": {
         "@effect/platform-node": "catalog:",
         "@opencode-ai/httpapi-codegen": "workspace:*",
         "@tsconfig/bun": "catalog:",
         "@types/bun": "catalog:",
+        "@types/ws": "8.18.1",
         "@typescript/native-preview": "catalog:",
         "effect": "catalog:",
       },
@@ -443,6 +445,7 @@
         "@actions/artifact": "4.0.0",
         "@lydell/node-pty": "catalog:",
         "@opencode-ai/app": "workspace:*",
+        "@opencode-ai/client": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@sentry/solid": "catalog:",
         "@sentry/vite-plugin": "catalog:",
@@ -1069,23 +1072,23 @@
     },
   },
   "trustedDependencies": [
+    "web-tree-sitter",
     "esbuild",
-    "protobufjs",
     "electron",
-    "web-tree-sitter",
+    "protobufjs",
   ],
   "patchedDependencies": {
+    "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
-    "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
-    "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
-    "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
-    "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+    "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
+    "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+    "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
     "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
+    "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
+    "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
     "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
-    "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
-    "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
-    "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+    "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
   },
   "overrides": {
     "@opentui/core": "catalog:",

+ 7 - 3
packages/client/package.json

@@ -17,6 +17,7 @@
   ],
   "exports": {
     ".": "./src/promise/index.ts",
+    "./node": "./src/node/index.ts",
     "./promise": "./src/promise/index.ts",
     "./promise/api": "./src/promise/api.ts",
     "./service": "./src/promise/service.ts",
@@ -28,12 +29,14 @@
     "build": "bun run script/build-package.ts",
     "generate": "bun run script/build.ts",
     "check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
-    "test": "bun test --timeout 5000",
-    "typecheck": "tsgo --noEmit"
+    "test": "bun test --timeout 5000 && bun run test:node-package",
+    "test:node-package": "bun test ./test/node/package-smoke.ts --timeout 60000",
+    "typecheck": "tsgo --noEmit && tsgo -p test/types/tsconfig.json --noEmit"
   },
   "dependencies": {
     "@opencode-ai/schema": "workspace:*",
-    "@opencode-ai/protocol": "workspace:*"
+    "@opencode-ai/protocol": "workspace:*",
+    "ws": "8.21.0"
   },
   "peerDependencies": {
     "effect": "4.0.0-beta.101"
@@ -48,6 +51,7 @@
     "@opencode-ai/httpapi-codegen": "workspace:*",
     "@tsconfig/bun": "catalog:",
     "@types/bun": "catalog:",
+    "@types/ws": "8.18.1",
     "@typescript/native-preview": "catalog:",
     "effect": "catalog:"
   }

+ 1 - 0
packages/client/script/build-package.ts

@@ -7,3 +7,4 @@ process.chdir(fileURLToPath(new URL("..", import.meta.url)))
 
 await $`rm -rf dist`
 await $`bun tsc -p tsconfig.build.json`
+await $`bun build src/node/index.ts --outfile dist/node/index.js --target=node --format=esm --packages=external`

+ 360 - 0
packages/client/src/node/browser/client.ts

@@ -0,0 +1,360 @@
+import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
+import { Browser } from "@opencode-ai/schema/browser"
+import { BrowserControl } from "@opencode-ai/schema/browser-control"
+import { Session } from "@opencode-ai/schema/session"
+import { Effect, Schema } from "effect"
+import WebSocket from "ws"
+import type { ClientOptions } from "../../promise/generated/client.js"
+import { browserDriverFactory, type BrowserDriver, type BrowserDriverInstance, type BrowserProxy } from "./driver.js"
+import { createBrowserProxy } from "./proxy.js"
+import { openBrowserTunnel, type BrowserTunnelEndpoint } from "./tunnel.js"
+
+export interface BrowserRegisterOptions {
+  readonly sessionID: string
+  readonly open: () => Promise<void> | void
+}
+
+export interface BrowserAttachOptions<Resource> {
+  readonly driver: BrowserDriver<Resource>
+  readonly signal?: AbortSignal
+}
+
+export interface BrowserAttachment<Resource> extends AsyncDisposable {
+  readonly resource: Resource
+  readonly close: () => Promise<void>
+}
+
+export interface BrowserRegistration extends AsyncDisposable {
+  readonly attach: <Resource>(options: BrowserAttachOptions<Resource>) => Promise<BrowserAttachment<Resource>>
+  readonly close: () => Promise<void>
+}
+
+export interface BrowserClient {
+  readonly register: (options: BrowserRegisterOptions) => Promise<BrowserRegistration>
+}
+
+type ProxyServer = Awaited<ReturnType<typeof createBrowserProxy>>
+type Attachment = {
+  readonly leaseID: Browser.LeaseID
+  readonly abort: AbortController
+  readonly attached: Promise<void>
+  readonly resolveAttached: () => void
+  readonly rejectAttached: (error: Error) => void
+  readonly externalSignal?: AbortSignal
+  readonly externalAbort: () => void
+  state?: Browser.State
+  execute?: BrowserDriverInstance<unknown>["execute"]
+  unsubscribe?: () => void
+  dispose?: () => Promise<void> | void
+  proxy?: ProxyServer
+  acknowledged: boolean
+  closed: boolean
+  closing?: Promise<void>
+}
+
+export function createBrowserClient(options: ClientOptions): BrowserClient {
+  const server = endpoint(options)
+  return { register: (input) => BrowserRegistrationControl.create(server, input) }
+}
+
+class BrowserRegistrationControl implements BrowserRegistration {
+  private readonly requests = new Map<BrowserControl.RequestID, AbortController>()
+  private readonly socket: WebSocket
+  private attachment?: Attachment
+  private resolveRegistered!: () => void
+  private rejectRegistered!: (error: Error) => void
+  private readonly registered: Promise<void>
+  private closed = false
+  private closing?: Promise<void>
+
+  static async create(server: BrowserTunnelEndpoint, options: BrowserRegisterOptions) {
+    if (!Schema.is(Session.ID)(options.sessionID))
+      throw new TypeError("Browser registration requires a valid Session ID")
+    if (typeof options.open !== "function") throw new TypeError("Browser registration requires an open callback")
+    if (process.versions.bun) {
+      const response = await (server.fetch ?? globalThis.fetch)(new URL("/api/health", server.url), {
+        headers: server.authorization ? { Authorization: server.authorization } : undefined,
+        signal: AbortSignal.timeout(10_000),
+      })
+      if (response.status === 401 || response.status === 403) {
+        throw new Error(`Browser control connection was rejected with HTTP ${response.status}`)
+      }
+    }
+    const registration = new BrowserRegistrationControl(server, Session.ID.make(options.sessionID), options.open)
+    await registration.registered
+    return registration
+  }
+
+  private constructor(
+    private readonly server: BrowserTunnelEndpoint,
+    private readonly sessionID: Session.ID,
+    private readonly open: BrowserRegisterOptions["open"],
+  ) {
+    this.registered = new Promise<void>((resolve, reject) => {
+      this.resolveRegistered = resolve
+      this.rejectRegistered = reject
+    })
+    this.socket = new WebSocket(controlURL(server), BrowserControlProtocol.Subprotocol, {
+      ...(server.authorization ? { headers: { Authorization: server.authorization } } : {}),
+      handshakeTimeout: 10_000,
+      maxPayload: BrowserControlProtocol.MaxMessageBytes,
+      perMessageDeflate: false,
+      followRedirects: false,
+    })
+    this.socket.once("open", () => this.send({ type: "browser.control.register", sessionID }))
+    this.socket.on("message", (data, binary) => void this.receive(data, binary))
+    this.socket.on("error", (error) => {
+      const status = /^Unexpected server response: (\d+)$/.exec(error.message)?.[1]
+      this.fail(new Error(status ? `Browser control connection was rejected with HTTP ${status}` : error.message))
+    })
+    if (!process.versions.bun) {
+      this.socket.on("unexpected-response", (_request, response) => {
+        response.resume()
+        this.fail(new Error(`Browser control connection was rejected with HTTP ${response.statusCode}`))
+      })
+    }
+    this.socket.on("close", () => this.fail(new Error("Browser control connection closed.")))
+  }
+
+  async attach<Resource>(input: BrowserAttachOptions<Resource>): Promise<BrowserAttachment<Resource>> {
+    if (this.closed) throw new Error("Browser registration is closed")
+    if (this.attachment) throw new Error("A browser is already attached to this registration")
+    if (input.signal?.aborted) throw abortError(input.signal, "Browser attachment was aborted")
+    let resolveAttached!: () => void
+    let rejectAttached!: (error: Error) => void
+    const attached = new Promise<void>((resolve, reject) => {
+      resolveAttached = resolve
+      rejectAttached = reject
+    })
+    const externalAbort = () =>
+      void this.closeAttachment(record, abortError(input.signal, "Browser attachment was aborted"))
+    const record: Attachment = {
+      leaseID: Browser.LeaseID.create(),
+      abort: new AbortController(),
+      attached,
+      resolveAttached,
+      rejectAttached,
+      externalSignal: input.signal,
+      externalAbort,
+      acknowledged: false,
+      closed: false,
+    }
+    this.attachment = record
+    input.signal?.addEventListener("abort", record.externalAbort, { once: true })
+    try {
+      record.proxy = await createBrowserProxy({
+        connect: async (target, signal) => {
+          await abortable(record.attached, signal)
+          return openBrowserTunnel({
+            endpoint: this.server,
+            sessionID: this.sessionID,
+            leaseID: record.leaseID,
+            target,
+            signal: AbortSignal.any([signal, record.abort.signal]),
+          })
+        },
+      })
+      const instance = await Promise.resolve(
+        browserDriverFactory(input.driver)({ proxy: exposedProxy(record.proxy), signal: record.abort.signal }),
+      )
+      if (!validDriver(instance)) throw new TypeError("Browser driver factory returned an invalid driver instance")
+      record.dispose = () => instance.dispose()
+      record.execute = (command, options) => instance.execute(command, options)
+      record.state = contractState(instance.state())
+      record.unsubscribe = instance.subscribe((state) => {
+        if (record.closed) return
+        record.state = contractState(state)
+        if (record.acknowledged)
+          this.send({ type: "browser.control.state", leaseID: record.leaseID, state: record.state })
+      })
+      this.send({ type: "browser.control.attach", leaseID: record.leaseID, state: record.state })
+      await abortable(record.attached, record.abort.signal)
+      record.acknowledged = true
+      this.send({ type: "browser.control.state", leaseID: record.leaseID, state: record.state })
+      const close = () => this.closeAttachment(record)
+      return Object.freeze({ resource: instance.resource, close, [Symbol.asyncDispose]: close })
+    } catch (error) {
+      await this.closeAttachment(record).catch(() => undefined)
+      throw error
+    }
+  }
+
+  close() {
+    if (this.closing) return this.closing
+    this.closed = true
+    const attachment = this.attachment
+    this.closing = (attachment ? this.closeAttachment(attachment) : Promise.resolve()).finally(() => {
+      this.requests.forEach((abort) => abort.abort())
+      this.requests.clear()
+      if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
+      else if (this.socket.readyState !== WebSocket.CLOSED) this.socket.terminate()
+    })
+    return this.closing
+  }
+
+  [Symbol.asyncDispose]() {
+    return this.close()
+  }
+
+  private closeAttachment(record: Attachment, reason = new Error("Browser attachment was closed")) {
+    if (record.closing) return record.closing
+    record.closed = true
+    record.externalSignal?.removeEventListener("abort", record.externalAbort)
+    record.abort.abort(reason)
+    record.rejectAttached(reason)
+    if (this.attachment === record) this.attachment = undefined
+    if (record.acknowledged) this.send({ type: "browser.control.detach", leaseID: record.leaseID })
+    record.closing = Promise.resolve()
+      .then(() => record.unsubscribe?.())
+      .then(() => record.dispose?.())
+      .then(() => record.proxy?.close())
+    return record.closing
+  }
+
+  private async receive(data: WebSocket.RawData, binary: boolean) {
+    if (binary) return this.protocolError()
+    const message = await Effect.runPromise(
+      BrowserControlProtocol.decodeFromServer(Buffer.from(rawData(data)).toString("utf8")),
+    ).catch(() => undefined)
+    if (!message) return this.protocolError()
+    if (message.type === "browser.control.registered") {
+      this.resolveRegistered()
+      return
+    }
+    if (message.type === "browser.control.open") {
+      queueMicrotask(() => void Promise.resolve(this.open()).catch((error) => this.fail(asError(error))))
+      return
+    }
+    if (message.type === "browser.control.attached") {
+      if (this.attachment?.leaseID !== message.leaseID) return this.protocolError()
+      this.attachment.resolveAttached()
+      return
+    }
+    if (message.type === "browser.control.cancel") {
+      this.requests.get(message.requestID)?.abort(new Error("Browser command was cancelled"))
+      this.requests.delete(message.requestID)
+      return
+    }
+    void this.request(message)
+  }
+
+  private async request(message: Extract<BrowserControl.FromServer, { readonly type: "browser.control.request" }>) {
+    const record = this.attachment
+    if (!record?.acknowledged || record.leaseID !== message.leaseID || !record.execute) {
+      this.send({
+        type: "browser.control.response",
+        requestID: message.requestID,
+        leaseID: message.leaseID,
+        outcome: { type: "failure", code: "not_attached", message: "Browser is not attached." },
+      })
+      return
+    }
+    const abort = new AbortController()
+    this.requests.set(message.requestID, abort)
+    const outcome = await record.execute(message.command, { signal: abort.signal }).then(
+      (result): Browser.Outcome =>
+        Schema.is(Browser.Result)(result) && result.type === message.command.type
+          ? { type: "success", result }
+          : { type: "failure", code: "protocol", message: "Browser driver returned an invalid result." },
+      (error): Browser.Outcome => driverFailure(error),
+    )
+    if (this.requests.get(message.requestID) !== abort) return
+    this.requests.delete(message.requestID)
+    this.send({ type: "browser.control.response", requestID: message.requestID, leaseID: message.leaseID, outcome })
+  }
+
+  private send(message: BrowserControl.FromClient) {
+    if (this.socket.readyState !== WebSocket.OPEN) return
+    this.socket.send(BrowserControlProtocol.encodeFromClient(message), (error) => {
+      if (error) this.fail(error)
+    })
+  }
+
+  private protocolError() {
+    this.fail(new Error("Invalid browser control message."))
+  }
+
+  private fail(error: Error) {
+    if (this.closed) return
+    this.rejectRegistered(error)
+    this.attachment?.rejectAttached(error)
+    void this.close()
+  }
+}
+
+function validDriver<Resource>(input: BrowserDriverInstance<Resource>): input is BrowserDriverInstance<Resource> {
+  return (
+    input !== null &&
+    typeof input === "object" &&
+    typeof input.state === "function" &&
+    typeof input.subscribe === "function" &&
+    typeof input.execute === "function" &&
+    typeof input.dispose === "function"
+  )
+}
+
+function exposedProxy(proxy: ProxyServer): BrowserProxy {
+  return Object.freeze({
+    url: proxy.url,
+    host: proxy.host,
+    port: proxy.port,
+    credentials: Object.freeze({ ...proxy.credentials }),
+  })
+}
+
+function contractState(state: Browser.State) {
+  if (!Schema.is(Browser.State)(state)) throw new TypeError("Browser driver returned an invalid state")
+  return Object.freeze({ ...state })
+}
+
+function driverFailure(error: unknown): Extract<Browser.Outcome, { readonly type: "failure" }> {
+  return {
+    type: "failure",
+    code:
+      error !== null && typeof error === "object" && "code" in error && Schema.is(Browser.ErrorCode)(error.code)
+        ? error.code
+        : "internal",
+    message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
+  }
+}
+
+function endpoint(options: ClientOptions): BrowserTunnelEndpoint {
+  const url = new URL(options.baseUrl)
+  if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
+    throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
+  }
+  const authorization = new Headers(options.headers).get("authorization") ?? undefined
+  return Object.freeze({ url: url.href, ...(authorization ? { authorization } : {}), fetch: options.fetch })
+}
+
+function controlURL(endpoint: BrowserTunnelEndpoint) {
+  const url = new URL(endpoint.url)
+  url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+  url.pathname = BrowserControlProtocol.Path
+  url.search = ""
+  url.hash = ""
+  return url
+}
+
+function abortable<Result>(promise: Promise<Result>, signal: AbortSignal) {
+  if (signal.aborted) return Promise.reject(abortError(signal, "Browser operation was aborted"))
+  return new Promise<Result>((resolve, reject) => {
+    const abort = () => reject(abortError(signal, "Browser operation was aborted"))
+    signal.addEventListener("abort", abort, { once: true })
+    promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
+  })
+}
+
+function abortError(signal: AbortSignal | undefined, message: string) {
+  return signal?.reason instanceof Error ? signal.reason : new Error(message)
+}
+
+function asError(error: unknown) {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+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)
+}

+ 47 - 0
packages/client/src/node/browser/driver.ts

@@ -0,0 +1,47 @@
+import type { Browser } from "@opencode-ai/schema/browser"
+export interface BrowserProxy {
+  readonly url: string
+  readonly host: string
+  readonly port: number
+  readonly credentials: {
+    readonly username: string
+    readonly password: string
+  }
+}
+export interface BrowserDriverContext {
+  readonly proxy: BrowserProxy
+  readonly signal: AbortSignal
+}
+export interface BrowserDriverInstance<Resource> {
+  readonly resource: Resource
+  readonly state: () => Browser.State
+  readonly subscribe: (listener: (state: Browser.State) => void) => () => void
+  readonly execute: (command: Browser.Command, options: { readonly signal: AbortSignal }) => Promise<Browser.Result>
+  readonly dispose: () => Promise<void> | void
+}
+export type BrowserDriverFactory<Resource> = (
+  context: BrowserDriverContext,
+) => Promise<BrowserDriverInstance<Resource>> | BrowserDriverInstance<Resource>
+export class BrowserDriverError extends Error {
+  override readonly name = "BrowserDriverError"
+
+  constructor(
+    readonly code: Browser.ErrorCode,
+    message: string,
+    options?: ErrorOptions,
+  ) {
+    super(message, options)
+  }
+}
+
+export type BrowserDriver<Resource> = BrowserDriverFactory<Resource>
+
+export const BrowserDriver = {
+  define<Resource>(create: BrowserDriverFactory<Resource>): BrowserDriver<Resource> {
+    return create
+  },
+}
+
+export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {
+  return driver
+}

+ 260 - 0
packages/client/src/node/browser/proxy.ts

@@ -0,0 +1,260 @@
+import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
+import { randomBytes, timingSafeEqual } from "node:crypto"
+import {
+  Agent,
+  createServer,
+  request,
+  type IncomingHttpHeaders,
+  type IncomingMessage,
+  type ServerResponse,
+} from "node:http"
+import { Duplex } from "node:stream"
+
+export type BrowserProxyConnector = (target: BrowserTunnel.Target, signal: AbortSignal) => Promise<Duplex>
+
+/** Starts a loopback-only authenticated HTTP proxy backed exclusively by tunnel connections. */
+export async function createBrowserProxy(input: { readonly connect: BrowserProxyConnector }) {
+  const username = randomBytes(16).toString("hex")
+  const password = randomBytes(32).toString("hex")
+  const expected = Buffer.from(`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`)
+  const clients = new Set<Duplex>()
+  const tunnels = new Set<Duplex>()
+  const pending = new Set<AbortController>()
+  let closed = false
+
+  const authorized = (value: string | undefined) => {
+    if (!value) return false
+    const actual = Buffer.from(value)
+    return actual.length === expected.length && timingSafeEqual(actual, expected)
+  }
+  const connect = async (target: BrowserTunnel.Target, signal?: AbortSignal) => {
+    if (closed) throw new Error("Browser proxy is closed")
+    const abort = new AbortController()
+    const cancel = () => abort.abort(signal?.reason)
+    signal?.addEventListener("abort", cancel, { once: true })
+    if (signal?.aborted) cancel()
+    pending.add(abort)
+    try {
+      const tunnel = await input.connect(target, abort.signal)
+      if (closed || abort.signal.aborted) {
+        tunnel.destroy()
+        throw abort.signal.reason ?? new Error("Browser proxy is closed")
+      }
+      tunnels.add(tunnel)
+      tunnel.once("close", () => tunnels.delete(tunnel))
+      tunnel.on("error", () => tunnel.destroy())
+      return tunnel
+    } finally {
+      pending.delete(abort)
+      signal?.removeEventListener("abort", cancel)
+    }
+  }
+
+  const server = createServer({ maxHeaderSize: 64 * 1_024 }, (incoming, response) => {
+    void forward(incoming, response, connect, authorized).catch(() => response.destroy())
+  })
+  server.requestTimeout = 30_000
+  server.headersTimeout = 10_000
+  server.keepAliveTimeout = 5_000
+  server.on("connection", (socket) => {
+    clients.add(socket)
+    socket.once("close", () => clients.delete(socket))
+  })
+  server.on("connect", (incoming, socket, head) => {
+    void (async () => {
+      if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
+        socket.end(
+          'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="OpenCode Browser Proxy"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
+        )
+        return
+      }
+      const destination = authority(incoming.url ?? "", 443)
+      if (!destination) {
+        socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
+        return
+      }
+      const abort = new AbortController()
+      const cancel = () => abort.abort(new Error("Browser proxy client closed"))
+      socket.once("close", cancel)
+      socket.pause()
+      const tunnel = await connect(destination, abort.signal)
+      socket.off("close", cancel)
+      if (socket.destroyed) {
+        tunnel.destroy()
+        return
+      }
+      socket.write("HTTP/1.1 200 Connection Established\r\n\r\n")
+      if (head.byteLength) tunnel.write(head)
+      bridge(socket, tunnel)
+      socket.resume()
+    })().catch(() => {
+      if (!socket.destroyed) socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
+    })
+  })
+  server.on("error", () => undefined)
+  server.on("clientError", (_error, socket) => {
+    if (!socket.destroyed) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n")
+  })
+
+  await new Promise<void>((resolve, reject) => {
+    const onError = (error: Error) => reject(error)
+    server.once("error", onError)
+    server.listen(0, "127.0.0.1", () => {
+      server.off("error", onError)
+      resolve()
+    })
+  })
+  const address = server.address()
+  if (!address || typeof address === "string") throw new Error("Browser proxy did not bind a TCP address")
+  let closing: Promise<void> | undefined
+  return {
+    url: `http://127.0.0.1:${address.port}`,
+    host: "127.0.0.1",
+    port: address.port,
+    credentials: { username, password },
+    close() {
+      if (closing) return closing
+      closed = true
+      pending.forEach((abort) => abort.abort())
+      tunnels.forEach((tunnel) => tunnel.destroy())
+      clients.forEach((client) => client.destroy())
+      closing = new Promise<void>((resolve) => server.close(() => resolve()))
+      return closing
+    },
+  }
+}
+
+async function forward(
+  incoming: IncomingMessage,
+  response: ServerResponse,
+  connect: (target: BrowserTunnel.Target, signal?: AbortSignal) => Promise<Duplex>,
+  authorized: (header: string | undefined) => boolean,
+) {
+  if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
+    response.writeHead(407, { "Proxy-Authenticate": 'Basic realm="OpenCode Browser Proxy"' })
+    response.end()
+    return
+  }
+  const url = parseURL(incoming.url)
+  if (!url || url.protocol !== "http:" || !url.hostname || url.username || url.password) {
+    response.writeHead(400)
+    response.end()
+    return
+  }
+  const port = url.port ? Number(url.port) : 80
+  const abort = new AbortController()
+  let tunnel: Duplex | undefined
+  let agent: Agent | undefined
+  const cancel = () => {
+    abort.abort(new Error("Browser proxy client closed"))
+    tunnel?.destroy()
+  }
+  incoming.once("aborted", cancel)
+  response.once("close", cancel)
+  try {
+    tunnel = await connect(target(normalizeHostname(url.hostname), port), abort.signal)
+    const headers = forwardedHeaders(incoming.headers)
+    headers.host = url.host
+    headers.connection = "close"
+    agent = new Agent({ keepAlive: false, maxSockets: 1 })
+    const connection = tunnel
+    agent.createConnection = () => connection
+    await new Promise<void>((resolve, reject) => {
+      const upstream = request(
+        {
+          agent,
+          hostname: url.hostname,
+          port,
+          path: `${url.pathname}${url.search}`,
+          method: incoming.method,
+          headers,
+          signal: abort.signal,
+        },
+        (result) => {
+          const headers = forwardedHeaders(result.headers)
+          headers.connection = "close"
+          response.writeHead(result.statusCode ?? 502, result.statusMessage, headers)
+          result.once("error", reject)
+          response.once("finish", resolve)
+          result.pipe(response)
+        },
+      )
+      upstream.once("error", reject)
+      incoming.pipe(upstream)
+    })
+  } finally {
+    incoming.off("aborted", cancel)
+    response.off("close", cancel)
+    agent?.destroy()
+    tunnel?.destroy()
+  }
+}
+
+function forwardedHeaders(input: IncomingHttpHeaders) {
+  const headers = { ...input }
+  singleHeader(headers.connection)
+    ?.split(",")
+    .map((value) => value.trim().toLowerCase())
+    .forEach((name) => delete headers[name])
+  ;[
+    "connection",
+    "keep-alive",
+    "proxy-authenticate",
+    "proxy-authorization",
+    "proxy-connection",
+    "te",
+    "trailer",
+    "transfer-encoding",
+    "upgrade",
+  ].forEach((name) => delete headers[name])
+  return headers
+}
+
+function bridge(client: Duplex, tunnel: Duplex) {
+  client.on("error", () => tunnel.destroy())
+  tunnel.on("error", () => client.destroy())
+  client.once("close", () => tunnel.destroy())
+  tunnel.once("close", () => client.destroy())
+  client.pipe(tunnel)
+  tunnel.pipe(client)
+}
+
+function authority(value: string, defaultPort: number) {
+  const bracket = /^\[([^\]]+)](?::([0-9]+))?$/.exec(value)
+  if (bracket) return validAuthority(bracket[1], bracket[2], defaultPort)
+  const separator = value.lastIndexOf(":")
+  if (separator < 0) return validAuthority(value, undefined, defaultPort)
+  if (value.slice(0, separator).includes(":")) return undefined
+  return validAuthority(value.slice(0, separator), value.slice(separator + 1), defaultPort)
+}
+
+function validAuthority(host: string, value: string | undefined, defaultPort: number) {
+  if (!host || (value !== undefined && !/^[0-9]+$/.test(value))) return undefined
+  const port = value === undefined ? defaultPort : Number(value)
+  if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return undefined
+  try {
+    return target(host, port)
+  } catch {
+    return undefined
+  }
+}
+
+function target(host: string, port: number): BrowserTunnel.Target {
+  return { host: BrowserTunnel.Host.make(host), port: BrowserTunnel.Port.make(port) }
+}
+
+function parseURL(value: string | undefined) {
+  try {
+    return new URL(value ?? "")
+  } catch {
+    return undefined
+  }
+}
+
+function normalizeHostname(hostname: string) {
+  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname
+}
+
+function singleHeader(value: string | ReadonlyArray<string> | undefined) {
+  return typeof value === "string" ? value : undefined
+}

+ 208 - 0
packages/client/src/node/browser/tunnel.ts

@@ -0,0 +1,208 @@
+import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
+import { Browser } from "@opencode-ai/schema/browser"
+import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
+import { Session } from "@opencode-ai/schema/session"
+import { Effect } from "effect"
+import { Duplex } from "node:stream"
+import WebSocket from "ws"
+
+export type BrowserTunnelEndpoint = {
+  readonly url: string
+  readonly authorization?: string
+  readonly fetch?: typeof globalThis.fetch
+}
+
+export type BrowserTunnelOpen = {
+  readonly endpoint: BrowserTunnelEndpoint
+  readonly sessionID: Session.ID
+  readonly leaseID: Browser.LeaseID
+  readonly target: BrowserTunnel.Target
+  readonly signal?: AbortSignal
+}
+
+export class BrowserTunnelError extends Error {
+  constructor(
+    readonly code: BrowserTunnel.OpenErrorCode | "transport",
+    message: string,
+  ) {
+    super(message)
+    this.name = "BrowserTunnelError"
+  }
+}
+
+/** Opens one WebSocket whose binary messages are the bytes of one TCP connection. */
+export async function openBrowserTunnel(input: BrowserTunnelOpen): Promise<Duplex> {
+  const tunnel = new BrowserTunnelStream(input)
+  await tunnel.opened
+  return tunnel
+}
+
+class BrowserTunnelStream extends Duplex {
+  readonly connecting = false
+  readonly opened: Promise<void>
+  private resolveOpened!: () => void
+  private rejectOpened!: (error: Error) => void
+  private readonly socket: WebSocket
+  private readonly signal?: AbortSignal
+  private state: "opening" | "open" | "closed" = "opening"
+  private paused = false
+  private timer?: ReturnType<typeof setTimeout>
+
+  constructor(input: BrowserTunnelOpen) {
+    super()
+    this.opened = new Promise<void>((resolve, reject) => {
+      this.resolveOpened = resolve
+      this.rejectOpened = reject
+    })
+    this.on("error", () => undefined)
+    this.signal = input.signal
+    this.socket = new WebSocket(endpointURL(input.endpoint), BrowserTunnelProtocol.Subprotocol, {
+      ...(input.endpoint.authorization ? { headers: { Authorization: input.endpoint.authorization } } : {}),
+      handshakeTimeout: 10_000,
+      maxPayload: BrowserTunnelProtocol.MaxFrameBytes,
+      perMessageDeflate: false,
+      followRedirects: false,
+    })
+    this.timer = setTimeout(
+      () => this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake timed out.")),
+      15_000,
+    )
+    this.timer.unref()
+    this.socket.once("open", () => {
+      this.socket.send(
+        BrowserTunnelProtocol.encodeFromClient({
+          type: "browser.tunnel.open",
+          sessionID: input.sessionID,
+          leaseID: input.leaseID,
+          target: input.target,
+        }),
+      )
+    })
+    this.socket.on("message", (data, binary) => void this.receive(data, binary))
+    this.socket.on("error", (error) => this.fail(new BrowserTunnelError("transport", error.message)))
+    this.socket.on("close", () => {
+      if (this.state === "opening")
+        this.fail(new BrowserTunnelError("transport", "Browser tunnel closed while opening."))
+      if (this.state !== "open") return
+      this.state = "closed"
+      this.push(null)
+      this.destroy()
+    })
+    this.signal?.addEventListener("abort", this.onAbort, { once: true })
+    if (this.signal?.aborted) this.onAbort()
+  }
+
+  override _read() {
+    if (!this.paused) return
+    this.paused = false
+    this.socket.resume()
+  }
+
+  override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
+    if (this.state !== "open") {
+      callback(new BrowserTunnelError("transport", "Browser tunnel is not writable."))
+      return
+    }
+    const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk
+    const frames = Array.from(
+      { length: Math.ceil(data.byteLength / BrowserTunnelProtocol.MaxFrameBytes) },
+      (_, index) =>
+        data.subarray(index * BrowserTunnelProtocol.MaxFrameBytes, (index + 1) * BrowserTunnelProtocol.MaxFrameBytes),
+    )
+    const send = (index: number) => {
+      if (index === frames.length) {
+        callback()
+        return
+      }
+      this.socket.send(frames[index], { binary: true }, (error) => {
+        if (error) callback(error)
+        else send(index + 1)
+      })
+    }
+    send(0)
+  }
+
+  override _final(callback: (error?: Error | null) => void) {
+    if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
+    callback()
+  }
+
+  override _destroy(error: Error | null, callback: (error?: Error | null) => void) {
+    if (this.timer) clearTimeout(this.timer)
+    this.signal?.removeEventListener("abort", this.onAbort)
+    if (this.state === "opening" && error) this.rejectOpened(error)
+    this.state = "closed"
+    if (this.socket.readyState === WebSocket.OPEN) this.socket.close(1000)
+    else if (this.socket.readyState !== WebSocket.CLOSED) this.socket.terminate()
+    callback(error)
+  }
+
+  setKeepAlive() {
+    return this
+  }
+
+  setNoDelay() {
+    return this
+  }
+
+  setTimeout(_timeout: number, callback?: () => void) {
+    if (callback) this.once("timeout", callback)
+    return this
+  }
+
+  ref() {
+    return this
+  }
+
+  unref() {
+    return this
+  }
+
+  private async receive(data: WebSocket.RawData, binary: boolean) {
+    if (this.state === "opening") {
+      if (binary) return this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake must be text."))
+      const message = await Effect.runPromise(
+        BrowserTunnelProtocol.decodeFromServer(Buffer.from(rawData(data)).toString("utf8")),
+      ).catch(() => undefined)
+      if (!message) return this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake is invalid."))
+      if (message.type === "browser.tunnel.rejected")
+        return this.fail(new BrowserTunnelError(message.code, message.message))
+      this.state = "open"
+      if (this.timer) clearTimeout(this.timer)
+      this.resolveOpened()
+      return
+    }
+    if (this.state !== "open" || !binary)
+      return this.fail(new BrowserTunnelError("transport", "Browser tunnel payload is invalid."))
+    if (!this.push(rawData(data))) {
+      this.paused = true
+      this.socket.pause()
+    }
+  }
+
+  private fail(error: BrowserTunnelError) {
+    if (this.state === "closed") return
+    if (this.state === "opening") this.rejectOpened(error)
+    this.destroy(error)
+  }
+
+  private readonly onAbort = () => this.fail(new BrowserTunnelError("transport", "Browser tunnel was cancelled."))
+}
+
+function endpointURL(endpoint: BrowserTunnelEndpoint) {
+  const url = new URL(endpoint.url)
+  if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
+    throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
+  }
+  url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+  url.pathname = BrowserTunnelProtocol.Path
+  url.search = ""
+  url.hash = ""
+  return url
+}
+
+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)
+}

+ 13 - 0
packages/client/src/node/client.ts

@@ -0,0 +1,13 @@
+import { OpenCode } from "../promise/generated/index.js"
+import { createBrowserClient } from "./browser/client.js"
+
+export type ClientOptions = OpenCode.ClientOptions
+export type RequestOptions = OpenCode.RequestOptions
+
+/** Creates the Promise client with Node-only browser attachment support. */
+export function make(options: ClientOptions) {
+  return {
+    ...OpenCode.make(options),
+    browser: createBrowserClient(options),
+  }
+}

+ 34 - 0
packages/client/src/node/index.ts

@@ -0,0 +1,34 @@
+export { ClientError, type ClientErrorReason } from "../promise/generated/client-error.js"
+export * from "../promise/generated/types.js"
+export type {
+  AgentApi,
+  CatalogApi,
+  CommandApi,
+  EventApi,
+  IntegrationApi,
+  ModelApi,
+  PluginApi,
+  ProviderApi,
+  ReferenceApi,
+  WebSearchApi,
+  SessionApi,
+  SkillApi,
+} from "../promise/api.js"
+export * as OpenCode from "./client.js"
+export { Browser } from "@opencode-ai/schema/browser"
+export { BrowserDriver, BrowserDriverError } from "./browser/driver.js"
+export type {
+  BrowserDriverContext,
+  BrowserDriverFactory,
+  BrowserDriverInstance,
+  BrowserProxy,
+} from "./browser/driver.js"
+export type {
+  BrowserAttachment,
+  BrowserAttachOptions,
+  BrowserClient,
+  BrowserRegistration,
+  BrowserRegisterOptions,
+} from "./browser/client.js"
+export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js"
+export type OpenCodeClient = ReturnType<typeof import("./client.js").make>

+ 12 - 1
packages/client/test/import-boundaries.test.ts

@@ -5,6 +5,7 @@ import { join, resolve, sep } from "node:path"
 
 const directory = resolve(import.meta.dir, "..")
 const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
+const ws = realpathSync(resolve(import.meta.dir, "../node_modules/ws"))
 const schema = resolve(import.meta.dir, "../../schema")
 const protocol = resolve(import.meta.dir, "../../protocol")
 const core = resolve(import.meta.dir, "../../core")
@@ -17,6 +18,7 @@ describe("public import boundaries", () => {
     expect(within(root, effect)).toEqual([])
     expect(within(root, schema)).toEqual([])
     expect(within(root, protocol)).toEqual([])
+    expect(within(root, ws)).toEqual([])
     expect(within(root, core)).toEqual([])
     expect(within(root, server)).toEqual([])
 
@@ -28,6 +30,15 @@ describe("public import boundaries", () => {
     expect(within(network, core)).toEqual([])
     expect(within(network, server)).toEqual([])
 
+    const node = await bundleInputs("@opencode-ai/client/node", "node")
+
+    expect(within(node, effect).length).toBeGreaterThan(0)
+    expect(within(node, schema).length).toBeGreaterThan(0)
+    expect(within(node, protocol).length).toBeGreaterThan(0)
+    expect(within(node, ws).length).toBeGreaterThan(0)
+    expect(within(node, core)).toEqual([])
+    expect(within(node, server)).toEqual([])
+
     const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
 
     expect(within(promiseService, effect)).toEqual([])
@@ -45,7 +56,7 @@ describe("public import boundaries", () => {
   })
 })
 
-async function bundleInputs(specifier: string, target: "browser" | "bun") {
+async function bundleInputs(specifier: string, target: "browser" | "bun" | "node") {
   const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
   const entrypoint = join(temporary, "index.ts")
   const metafile = join(temporary, "meta.json")

+ 149 - 0
packages/client/test/node/browser-client.test.ts

@@ -0,0 +1,149 @@
+import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
+import { BrowserControl } from "@opencode-ai/schema/browser-control"
+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 { Browser, BrowserDriver, OpenCode } from "@opencode-ai/client/node"
+
+const state: Browser.State = {
+  url: "https://example.com/",
+  title: "Example",
+  loading: false,
+  canGoBack: false,
+  canGoForward: false,
+  generation: 1,
+}
+
+describe("Node browser client", () => {
+  test("registers one Session and handles attach, command, detach, and open", async () => {
+    const server = await controlServer()
+    let opened = 0
+    let disposed = 0
+    const client = OpenCode.make({ baseUrl: server.url })
+    try {
+      const registering = client.browser.register({ sessionID: "ses_node_browser", open: () => opened++ })
+      const socket = await server.connected
+      const next = reader(socket)
+      expect(await next()).toEqual({ type: "browser.control.register", sessionID: "ses_node_browser" })
+      socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.registered" }))
+      const registration = await registering
+
+      socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.open" }))
+      await waitFor(() => opened === 1)
+      expect(opened).toBe(1)
+
+      const attaching = registration.attach({
+        driver: BrowserDriver.define(({ proxy }) => ({
+          resource: proxy,
+          state: () => state,
+          subscribe: () => () => undefined,
+          execute: async () => ({ type: "snapshot", state, format: "opencode.semantic.v1", content: "snapshot" }),
+          dispose: () => disposed++,
+        })),
+      })
+      const attach = await next()
+      if (attach.type !== "browser.control.attach") throw new Error("expected browser attach")
+      expect(attach.state).toEqual(state)
+      socket.send(
+        BrowserControlProtocol.encodeFromServer({ type: "browser.control.attached", leaseID: attach.leaseID }),
+      )
+      const attachment = await attaching
+      expect(attachment.resource.url.startsWith("http://127.0.0.1:")).toBe(true)
+      expect((await next()).type).toBe("browser.control.state")
+
+      const requestID = BrowserControl.RequestID.create()
+      socket.send(
+        BrowserControlProtocol.encodeFromServer({
+          type: "browser.control.request",
+          requestID,
+          leaseID: attach.leaseID,
+          command: { type: "snapshot", generation: 1 },
+        }),
+      )
+      expect(await next()).toMatchObject({
+        type: "browser.control.response",
+        requestID,
+        outcome: { type: "success", result: { type: "snapshot", content: "snapshot" } },
+      })
+
+      await attachment.close()
+      expect(await next()).toEqual({ type: "browser.control.detach", leaseID: attach.leaseID })
+      expect(socket.readyState).toBe(WebSocket.OPEN)
+      expect(disposed).toBe(1)
+      const closed = once(socket, "close")
+      await registration.close()
+      await closed
+    } finally {
+      await server.close()
+    }
+  })
+
+})
+
+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 })
+  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 !== BrowserControlProtocol.Path ||
+      request.headers.authorization !== authorization ||
+      request.headers["sec-websocket-protocol"] !== BrowserControlProtocol.Subprotocol
+    ) {
+      socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\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 || typeof address === "string") throw new Error("control server did not bind")
+  return {
+    connected,
+    url: `http://127.0.0.1:${address.port}`,
+    async close() {
+      webSockets.clients.forEach((socket) => socket.terminate())
+      webSockets.close()
+      http.closeAllConnections()
+      await new Promise<void>((resolve) => http.close(() => resolve()))
+    },
+  }
+}
+
+function reader(socket: WebSocket) {
+  const queued: WebSocket.RawData[] = []
+  const waiting: Array<(data: WebSocket.RawData) => void> = []
+  socket.on("message", (data, binary) => {
+    if (binary) throw new Error("expected text control message")
+    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(BrowserControlProtocol.decodeFromClient(Buffer.from(rawData(data)).toString("utf8")))
+  }
+}
+
+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)
+}
+
+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 client")
+}

+ 8 - 0
packages/client/test/types/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "noEmit": true
+  },
+  "include": ["node-consumer.ts"]
+}

+ 4 - 0
turbo.json

@@ -28,6 +28,10 @@
       "dependsOn": ["^build"],
       "outputs": []
     },
+    "@opencode-ai/desktop#test": {
+      "dependsOn": ["^build"],
+      "outputs": []
+    },
     "@opencode-ai/ui#test": {
       "dependsOn": ["^build"],
       "outputs": []