diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index b7cc24d709..8432078209 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -160,6 +160,134 @@ "summary": "Get server information" } }, + "/api/browser/control": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.control.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.control.v1 is required." + } + }, + "description": "Establish an authenticated WebSocket carrying Session-scoped browser attachments and semantic browser commands.", + "summary": "Connect desktop browser host", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.control.v1", + "x-websocket-incoming": "BrowserControl.FromDesktop", + "x-websocket-outgoing": "BrowserControl.FromServer" + } + }, + "/api/browser/tunnel": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.tunnel.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.tunnel.v1 is required." + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.", + "summary": "Open browser network tunnel", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.tunnel.v1", + "x-websocket-incoming": "BrowserTunnel.FromDesktop and binary DATA frames", + "x-websocket-outgoing": "BrowserTunnel.FromServer and binary DATA frames" + } + }, "/api/location": { "get": { "tags": [ @@ -12012,6 +12140,64 @@ ], "additionalProperties": false }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConflictError" + ] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServiceUnavailableError" + ] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, "Location.Info": { "type": "object", "properties": { @@ -12838,35 +13024,6 @@ ], "additionalProperties": false }, - "ConflictError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ConflictError" - ] - }, - "message": { - "type": "string" - }, - "resource": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "CommandNotFoundError": { "type": "object", "properties": { @@ -13058,35 +13215,6 @@ ], "additionalProperties": false }, - "ServiceUnavailableError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ServiceUnavailableError" - ] - }, - "message": { - "type": "string" - }, - "service": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "SessionBusyError": { "type": "object", "properties": { @@ -13705,7 +13833,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13850,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13876,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13930,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -17114,6 +17249,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17375,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17498,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -29135,6 +29313,10 @@ { "name": "server" }, + { + "name": "browser", + "description": "Desktop browser host control and server-network tunnel routes." + }, { "name": "location" }, diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 2249797df4..7cf396ea7f 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -14,6 +14,7 @@ import { SkillGroup } from "./groups/skill.js" import { EventGroup, makeEventGroup } from "./groups/event.js" import type { Definition } from "@opencode-ai/schema/event" import { AgentGroup } from "./groups/agent.js" +import { BrowserGroup } from "./groups/browser.js" import { PluginGroup } from "./groups/plugin.js" import { HealthGroup } from "./groups/health.js" import { ServerGroup } from "./groups/server.js" @@ -85,6 +86,7 @@ type ApiGroups< | typeof HealthGroup | typeof ServerGroup | typeof DebugGroup + | typeof BrowserGroup | LocationGroups | FormGroups | SessionGroups @@ -146,6 +148,7 @@ const makeApiFromGroup = < HttpApi.make("server") .add(HealthGroup) .add(ServerGroup) + .add(BrowserGroup) .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(PluginGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 24e68e7a51..ea04d01185 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -38,6 +38,7 @@ export const groupNames = { "server.debug": "debug", "server.location": "location", "server.agent": "agent", + "server.browser": "browser", "server.plugin": "plugin", "server.session": "session", "server.message": "message", @@ -63,5 +64,16 @@ export const groupNames = { "server.vcs": "vcs", } as const -export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) -export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) +export const promiseOmitEndpoints = new Set([ + "browser.control.connect", + "browser.tunnel.connect", + "pty.connect", + "pty.connectToken", +]) +export const effectOmitEndpoints = new Set([ + "browser.control.connect", + "browser.tunnel.connect", + "fs.read", + "pty.connect", + "pty.connectToken", +]) diff --git a/packages/server/src/browser-close.ts b/packages/server/src/browser-close.ts new file mode 100644 index 0000000000..e869f5eb59 --- /dev/null +++ b/packages/server/src/browser-close.ts @@ -0,0 +1,24 @@ +export * as BrowserClose from "./browser-close" + +import type { BrowserHost } from "@opencode-ai/core/browser-host" + +export const Code = { + Normal: 1000, + GoingAway: 1001, + ProtocolError: 1002, + InvalidPayload: 1007, + MessageTooLarge: 1009, + InternalError: 1011, + Restart: 1012, + TryAgainLater: 1013, + UpstreamError: 1014, +} as const + +export function control(reason: BrowserHost.CloseReason) { + if (reason === "disconnected") return Code.GoingAway + if (reason === "protocol_error") return Code.ProtocolError + if (reason === "message_too_large") return Code.MessageTooLarge + if (reason === "overloaded") return Code.TryAgainLater + if (reason === "restart") return Code.Restart + return Code.InternalError +} diff --git a/packages/server/src/browser-control-connection.ts b/packages/server/src/browser-control-connection.ts new file mode 100644 index 0000000000..5eccd31262 --- /dev/null +++ b/packages/server/src/browser-control-connection.ts @@ -0,0 +1,119 @@ +export * as BrowserControlConnection from "./browser-control-connection" + +import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control" +import { BrowserControl } from "@opencode-ai/schema/browser-control" +import { Cause, Effect, Queue, Ref, Stream } from "effect" +import { Socket } from "effect/unstable/socket" +import { BrowserClose } from "./browser-close" + +const InboundCapacity = 64 +const OutboundCapacity = 64 +const InboundBytes = BrowserControlProtocol.MaxMessageBytes * 2 +const encoder = new TextEncoder() + +type Inbound = { + readonly message: BrowserControl.FromDesktop + readonly bytes: number +} + +export const make = Effect.fn("BrowserControlConnection.make")(function* ( + socket: Socket.Socket, + opened: Effect.Effect = Effect.void, +) { + const inbound = yield* Queue.dropping(InboundCapacity) + const outbound = yield* Queue.dropping(OutboundCapacity) + const inboundBytes = yield* Ref.make(0) + const write = yield* socket.writer + + const fail = (kind: BrowserHost.ConnectionError["kind"], message: string, cause?: unknown) => + Effect.sync(() => { + Queue.failCauseUnsafe(inbound, Cause.fail(new BrowserHost.ConnectionError({ kind, message, cause }))) + }) + + yield* socket + .runRaw( + (message) => + Effect.gen(function* () { + const bytes = typeof message === "string" ? encoder.encode(message).byteLength : message.byteLength + const admitted = yield* Ref.modify(inboundBytes, (current) => + current + bytes <= InboundBytes ? [true, current + bytes] : [false, current], + ) + if (!admitted) return yield* fail("overloaded", "Browser control receive byte budget is full.") + return yield* BrowserControlProtocol.decodeFromDesktop(message).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Ref.update(inboundBytes, (current) => Math.max(0, current - bytes)).pipe( + Effect.andThen( + fail( + cause.kind === "too_large" ? "message_too_large" : "invalid_message", + "Browser control message is invalid.", + cause, + ), + ), + ), + onSuccess: (value) => { + if (Queue.offerUnsafe(inbound, { message: value, bytes })) return Effect.void + return Ref.update(inboundBytes, (current) => Math.max(0, current - bytes)).pipe( + Effect.andThen(fail("overloaded", "Browser control receive queue is full.")), + ) + }, + }), + ) + }), + { + onOpen: opened.pipe( + Effect.andThen(write(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))), + Effect.orDie, + ), + }, + ) + .pipe( + Effect.matchCauseEffect({ + onSuccess: () => fail("closed", "Browser control connection closed."), + onFailure: (cause) => fail("transport", "Browser control connection failed.", Cause.squash(cause)), + }), + Effect.forkScoped, + ) + + yield* Effect.gen(function* () { + while (true) yield* write(yield* Queue.take(outbound)) + }).pipe( + Effect.catch((cause) => fail("transport", "Browser control writer failed.", cause)), + Effect.forkScoped, + ) + + yield* Effect.addFinalizer(() => + Effect.all([Queue.shutdown(inbound), Queue.shutdown(outbound)], { concurrency: "unbounded", discard: true }), + ) + + return { + messages: Stream.fromQueue(inbound).pipe( + Stream.mapEffect((item) => + Ref.update(inboundBytes, (current) => Math.max(0, current - item.bytes)).pipe(Effect.as(item.message)), + ), + ), + send: (message) => + Effect.try({ + try: () => BrowserControlProtocol.encodeFromServer(message), + catch: (cause) => + new BrowserHost.ConnectionError({ + kind: "transport", + message: "Failed to encode browser control message.", + cause, + }), + }).pipe( + Effect.flatMap((frame) => Queue.offer(outbound, frame)), + Effect.flatMap((offered) => + offered + ? Effect.void + : new BrowserHost.ConnectionError({ kind: "overloaded", message: "Browser control send queue is full." }), + ), + ), + close: (close, message) => + write(new Socket.CloseEvent(BrowserClose.control(close), message.slice(0, 123))).pipe( + Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }), + Effect.catch(() => Effect.void), + ), + } satisfies BrowserHost.Peer +}) diff --git a/packages/server/src/browser-tunnel.ts b/packages/server/src/browser-tunnel.ts new file mode 100644 index 0000000000..23ca1e05f4 --- /dev/null +++ b/packages/server/src/browser-tunnel.ts @@ -0,0 +1,618 @@ +export * as BrowserTunnelServer from "./browser-tunnel" + +import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel" +import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" +import { + Cause, + Context, + Deferred, + Effect, + Fiber, + Layer, + Option, + Queue, + Ref, + Result, + Schema, + Scope, + Semaphore, + Stream, + SynchronizedRef, +} from "effect" +import { Socket } from "effect/unstable/socket" +import { BrowserClose } from "./browser-close" + +const ActiveLimit = 64 +const InboundCapacity = BrowserTunnelProtocol.InitialFrameWindow * 2 + 4 + +export class CapacityError extends Schema.TaggedErrorClass()("BrowserTunnel.CapacityError", { + limit: Schema.Int, + message: Schema.String, +}) {} + +class TransportError extends Schema.TaggedErrorClass()("BrowserTunnel.TransportError", { + kind: Schema.Literals(["socket_closed", "protocol", "too_large", "target", "lease_revoked"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +class ConnectError extends Schema.TaggedErrorClass()("BrowserTunnel.ConnectError", { + kind: Schema.Literals(["failed", "timeout"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +type Dial = (host: string, port: number) => Effect.Effect + +type Inbound = { + readonly message: string | Uint8Array +} + +type TargetOutput = { readonly type: "data"; readonly data: Uint8Array } | { readonly type: "end" } + +type ServerState = { + readonly active: number + readonly shutdown: boolean +} + +export interface Connection { + readonly run: (socket: Socket.Socket, opened?: Effect.Effect) => Effect.Effect +} + +export interface Interface { + readonly acquire: Effect.Effect + readonly shutdown: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/server/BrowserTunnel") {} + +export function make(dial: Dial = connect) { + return Effect.gen(function* () { + const browser = yield* BrowserHost.Service + const state = yield* SynchronizedRef.make({ active: 0, shutdown: false }) + const connections = new Set>() + + const shutdown = Effect.fn("BrowserTunnel.shutdown")(function* () { + const first = yield* SynchronizedRef.modify(state, (current) => [ + !current.shutdown, + { ...current, shutdown: true }, + ]) + if (!first) return + yield* Effect.all(Array.from(connections), { concurrency: "unbounded", discard: true }) + }) + + yield* Effect.addFinalizer(() => shutdown()) + + const acquire: Interface["acquire"] = Effect.acquireRelease( + SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (current) { + if (current.shutdown) { + return yield* new CapacityError({ + limit: ActiveLimit, + message: "The browser tunnel server is shutting down.", + }) + } + if (current.active >= ActiveLimit) { + return yield* new CapacityError({ + limit: ActiveLimit, + message: "The browser tunnel limit has been reached.", + }) + } + return [undefined, { ...current, active: current.active + 1 }] as const + }), + ), + () => SynchronizedRef.update(state, (current) => ({ ...current, active: Math.max(0, current.active - 1) })), + ).pipe( + Effect.andThen(Ref.make(false)), + Effect.map((started) => ({ + run: (socket: Socket.Socket, opened = Effect.void) => + Effect.gen(function* () { + const write = yield* socket.writer + if (yield* Ref.getAndSet(started, true)) { + yield* close(write, BrowserClose.Code.ProtocolError, "Browser tunnel connection can only run once") + return + } + const restart = write(new Socket.CloseEvent(BrowserClose.Code.Restart, "Server restarting")).pipe( + Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }), + Effect.catch(() => Effect.void), + ) + connections.add(restart) + yield* Effect.gen(function* () { + if ((yield* SynchronizedRef.get(state)).shutdown) { + yield* socket + .runRaw(() => Effect.void, { onOpen: opened.pipe(Effect.andThen(restart)) }) + .pipe( + Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }), + Effect.catch(() => Effect.void), + ) + return + } + yield* serve(browser, socket, write, dial, opened).pipe(Effect.catch(() => Effect.void)) + }).pipe(Effect.ensuring(Effect.sync(() => connections.delete(restart)))) + }), + })), + ) + + return Service.of({ acquire, shutdown: shutdown() }) + }) +} + +export const layer = Layer.effect(Service, make()) + +const serve = Effect.fn("BrowserTunnel.serve")(function* ( + browser: BrowserHost.Interface, + socket: Socket.Socket, + writeSocket: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, + dial: Dial, + opened: Effect.Effect, +) { + const inbound = yield* Queue.dropping(InboundCapacity) + + const reader = yield* socket + .runRaw( + (message) => { + const invalid = rawFrameError(message) + if (invalid) return fail(inbound, invalid) + return Queue.offerUnsafe(inbound, { message }) + ? Effect.void + : fail(inbound, new TransportError({ kind: "protocol", message: "Browser tunnel receive queue is full." })) + }, + { + onOpen: opened.pipe( + Effect.andThen(writeSocket(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }))), + Effect.orDie, + ), + }, + ) + .pipe( + Effect.matchCauseEffect({ + onSuccess: () => + fail(inbound, new TransportError({ kind: "socket_closed", message: "Browser tunnel closed." })), + onFailure: (cause) => + fail( + inbound, + new TransportError({ + kind: "socket_closed", + message: "Browser tunnel failed.", + cause: Cause.squash(cause), + }), + ), + }), + Effect.forkScoped, + ) + + const firstResult = yield* Effect.result( + Queue.take(inbound).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new TransportError({ kind: "protocol", message: "Browser tunnel open timed out." })), + }), + ), + ) + if (Result.isFailure(firstResult)) { + yield* reject( + writeSocket, + "invalid_open", + firstResult.failure.message, + firstResult.failure.kind === "too_large" ? BrowserClose.Code.MessageTooLarge : BrowserClose.Code.ProtocolError, + ) + return + } + const first = firstResult.success + const firstFrame = yield* BrowserTunnelProtocol.decodeFromDesktop(first.message).pipe(Effect.option) + if ( + Option.isNone(firstFrame) || + firstFrame.value.type !== "control" || + firstFrame.value.message.type !== "browser.tunnel.open" + ) { + yield* reject( + writeSocket, + "invalid_open", + "Browser tunnel open message is invalid.", + BrowserClose.Code.InvalidPayload, + ) + return + } + const open = firstFrame.value.message + + const lease = yield* browser.lease(open.sessionID) + if (Option.isNone(lease)) { + yield* reject( + writeSocket, + "not_attached", + "No desktop browser is attached to this Session.", + BrowserClose.Code.Normal, + ) + return + } + if (lease.value.id !== open.leaseID) { + yield* reject(writeSocket, "stale_lease", "The desktop browser lease is stale.", BrowserClose.Code.Normal) + return + } + + const target = yield* Effect.result( + Effect.raceFirst( + dial(open.target.host, open.target.port), + Effect.raceFirst( + Fiber.join(reader).pipe( + Effect.andThen(new TransportError({ kind: "socket_closed", message: "Browser tunnel closed." })), + ), + lease.value.revoked.pipe( + Effect.andThen(new TransportError({ kind: "lease_revoked", message: "Browser attachment was revoked." })), + ), + ), + ), + ) + if (Result.isFailure(target)) { + if (target.failure instanceof TransportError) { + if (target.failure.kind === "lease_revoked") { + yield* reject(writeSocket, "stale_lease", target.failure.message, BrowserClose.Code.Normal) + } + return + } + yield* reject( + writeSocket, + target.failure.kind === "timeout" ? "connect_timeout" : "connect_failed", + target.failure.message, + BrowserClose.Code.Normal, + ) + return + } + const tcp = target.success + + const sending = yield* Semaphore.make(1) + const outboundBytes = yield* Semaphore.make(open.receiveWindow) + const outboundFrames = yield* Semaphore.make(open.receiveFrames) + const outboundOutstanding = yield* Ref.make({ bytes: 0, frames: 0 }) + const inboundRemaining = yield* Ref.make({ + bytes: BrowserTunnelProtocol.InitialWindowBytes, + frames: BrowserTunnelProtocol.InitialFrameWindow, + }) + const targetEnded = yield* Deferred.make() + const send = (message: BrowserTunnel.FromServer) => + sending.withPermits(1)( + writeSocket(BrowserTunnelProtocol.encodeFromServer(message)).pipe( + Effect.mapError( + (cause) => + new TransportError({ kind: "socket_closed", message: "Failed to send tunnel control frame.", cause }), + ), + ), + ) + const sendData = (data: Uint8Array) => + outboundFrames.take(1).pipe( + Effect.andThen(outboundBytes.take(data.byteLength)), + Effect.andThen( + Ref.update(outboundOutstanding, (outstanding) => ({ + bytes: outstanding.bytes + data.byteLength, + frames: outstanding.frames + 1, + })), + ), + Effect.andThen( + sending.withPermits(1)( + writeSocket(BrowserTunnelProtocol.data(data)).pipe( + Effect.mapError( + (cause) => new TransportError({ kind: "socket_closed", message: "Failed to send tunnel data.", cause }), + ), + ), + ), + ), + ) + + yield* send({ + type: "browser.tunnel.opened", + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }) + + const output = yield* Queue.bounded(2) + const onData = (data: Uint8Array) => { + tcp.pause() + if (Queue.offerUnsafe(output, { type: "data", data })) return + Queue.failCauseUnsafe( + output, + Cause.fail(new TransportError({ kind: "target", message: "Browser tunnel target output queue is full." })), + ) + } + const onEnd = () => { + if (Queue.offerUnsafe(output, { type: "end" })) return + Queue.failCauseUnsafe( + output, + Cause.fail(new TransportError({ kind: "target", message: "Browser tunnel target end queue is full." })), + ) + } + const onError = (cause: Error) => + Queue.failCauseUnsafe( + output, + Cause.fail(new TransportError({ kind: "target", message: "Browser tunnel target failed.", cause })), + ) + const onClose = (hadError: boolean) => { + if (!hadError) return + Queue.failCauseUnsafe( + output, + Cause.fail(new TransportError({ kind: "target", message: "Browser tunnel target closed with an error." })), + ) + } + tcp.on("data", onData) + tcp.once("end", onEnd) + tcp.once("error", onError) + tcp.once("close", onClose) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + tcp.off("data", onData) + tcp.off("end", onEnd) + tcp.off("error", onError) + tcp.off("close", onClose) + }).pipe(Effect.andThen(Queue.shutdown(output))), + ) + + const desktop = { ended: false, done: false } + const fromDesktop = Effect.whileLoop({ + while: () => !desktop.done, + body: () => + Effect.gen(function* () { + const frame = desktop.ended + ? yield* Effect.raceFirst( + Queue.take(inbound).pipe(Effect.map(Option.some)), + Deferred.await(targetEnded).pipe(Effect.as(Option.none())), + ) + : Option.some(yield* Queue.take(inbound)) + if (Option.isNone(frame)) { + desktop.done = true + return + } + const decoded = yield* BrowserTunnelProtocol.decodeFromDesktop(frame.value.message).pipe( + Effect.mapError( + (cause) => + new TransportError({ + kind: cause.kind === "too_large" ? "too_large" : "protocol", + message: "Browser tunnel frame is invalid.", + cause, + }), + ), + ) + if (decoded.type === "data") { + if (desktop.ended) { + yield* new TransportError({ kind: "protocol", message: "Browser tunnel received data after end." }) + return + } + const accepted = yield* Ref.modify(inboundRemaining, (window) => + decoded.data.byteLength <= window.bytes && window.frames > 0 + ? [true, { bytes: window.bytes - decoded.data.byteLength, frames: window.frames - 1 }] + : ([false, window] as const), + ) + if (!accepted) { + yield* new TransportError({ kind: "protocol", message: "Browser tunnel receive window exceeded." }) + return + } + yield* write(tcp, decoded.data) + yield* Ref.update(inboundRemaining, (window) => ({ + bytes: window.bytes + decoded.data.byteLength, + frames: window.frames + 1, + })) + yield* send({ + type: "browser.tunnel.window", + bytes: BrowserTunnel.WindowBytes.make(decoded.data.byteLength), + frames: BrowserTunnel.FrameWindow.make(1), + }) + return + } + const control = decoded.message + if (control.type === "browser.tunnel.open") { + yield* new TransportError({ kind: "protocol", message: "Browser tunnel cannot be opened twice." }) + return + } + if (control.type === "browser.tunnel.window") { + const released = yield* Ref.modify(outboundOutstanding, (outstanding) => + control.bytes <= outstanding.bytes && control.frames <= outstanding.frames + ? [ + true, + { + bytes: outstanding.bytes - control.bytes, + frames: outstanding.frames - control.frames, + }, + ] + : ([false, outstanding] as const), + ) + if (!released) { + yield* new TransportError({ kind: "protocol", message: "Browser tunnel window exceeds sent data." }) + return + } + yield* outboundBytes.release(control.bytes) + yield* outboundFrames.release(control.frames) + return + } + if (control.type === "browser.tunnel.reset") { + yield* new TransportError({ + kind: "socket_closed", + message: `Desktop reset browser tunnel: ${control.code}`, + }) + return + } + if (desktop.ended) { + yield* new TransportError({ kind: "protocol", message: "Browser tunnel received duplicate end." }) + return + } + yield* end(tcp) + desktop.ended = true + }), + step: () => undefined, + }) + + const targetState = { done: false } + const fromTarget = Effect.whileLoop({ + while: () => !targetState.done, + body: () => + Effect.gen(function* () { + const item = yield* Queue.take(output) + if (item.type === "end") { + yield* send({ type: "browser.tunnel.end" }) + yield* Deferred.succeed(targetEnded, undefined) + targetState.done = true + return + } + yield* sendTargetData(item.data, sendData).pipe(Effect.ensuring(Effect.sync(() => tcp.resume()))) + }), + step: () => undefined, + }) + + const transfer = Effect.all([fromDesktop, fromTarget], { concurrency: "unbounded", discard: true }) + yield* Effect.raceFirst( + transfer, + Effect.raceFirst( + Fiber.join(reader).pipe( + Effect.andThen(new TransportError({ kind: "socket_closed", message: "Browser tunnel closed." })), + ), + lease.value.revoked.pipe( + Effect.andThen(new TransportError({ kind: "lease_revoked", message: "Browser attachment was revoked." })), + ), + ), + ).pipe( + Effect.matchEffect({ + onSuccess: () => close(writeSocket, BrowserClose.Code.Normal, "Browser tunnel complete"), + onFailure: (error) => + send({ + type: "browser.tunnel.reset", + code: + error.kind === "lease_revoked" + ? "lease_revoked" + : error.kind === "too_large" + ? "message_too_large" + : error.kind === "protocol" + ? "protocol_error" + : error.kind === "target" + ? "target_error" + : "cancelled", + }).pipe( + Effect.catch(() => Effect.void), + Effect.andThen( + close( + writeSocket, + error.kind === "too_large" + ? BrowserClose.Code.MessageTooLarge + : error.kind === "protocol" + ? BrowserClose.Code.ProtocolError + : error.kind === "target" + ? BrowserClose.Code.UpstreamError + : BrowserClose.Code.GoingAway, + error.message, + ), + ), + ), + }), + Effect.ensuring(Effect.sync(() => tcp.destroy())), + ) +}) + +function sendTargetData( + data: Uint8Array, + send: (data: Uint8Array) => Effect.Effect, +): Effect.Effect { + return Stream.fromIterable( + Array.from({ length: Math.ceil(data.byteLength / BrowserTunnelProtocol.MaxDataBytes) }, (_, index) => + data.subarray(index * BrowserTunnelProtocol.MaxDataBytes, (index + 1) * BrowserTunnelProtocol.MaxDataBytes), + ), + ).pipe(Stream.runForEach(send)) +} + +function rawFrameError(message: string | Uint8Array) { + if (typeof message === "string") { + return new TransportError({ kind: "protocol", message: "Browser tunnel frames must use binary framing." }) + } + const limit = + message[0] === BrowserTunnelProtocol.FrameType.Control + ? BrowserTunnelProtocol.MaxControlBytes + 1 + : BrowserTunnelProtocol.MaxDataBytes + 1 + if (message.byteLength <= limit) return undefined + return new TransportError({ kind: "too_large", message: "Browser tunnel frame is too large." }) +} + +function connect(host: string, port: number) { + return Effect.gen(function* () { + const net = yield* Effect.promise(() => import("node:net")) + return yield* Effect.acquireRelease( + Effect.callback((resume) => { + const socket = new net.Socket({ + allowHalfOpen: true, + }) + const onError = (cause: Error) => { + resume( + Effect.fail( + new ConnectError({ kind: "failed", message: "Failed to connect browser tunnel target.", cause }), + ), + ) + } + const onConnect = () => { + socket.off("error", onError) + socket.setNoDelay(true) + resume(Effect.succeed(socket)) + } + socket.once("error", onError) + socket.connect(port, host, onConnect) + return Effect.sync(() => socket.destroy()) + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail(new ConnectError({ kind: "timeout", message: "Browser tunnel target connection timed out." })), + }), + ), + (socket) => Effect.sync(() => socket.destroy()), + ) + }) +} + +function write(socket: import("node:net").Socket, data: Uint8Array) { + return Effect.callback((resume) => { + socket.write(data, (cause) => { + if (cause) { + resume( + Effect.fail(new TransportError({ kind: "target", message: "Failed to write browser tunnel data.", cause })), + ) + return + } + resume(Effect.void) + }) + }) +} + +function end(socket: import("node:net").Socket) { + return Effect.try({ + try: () => socket.end(), + catch: (cause) => new TransportError({ kind: "target", message: "Failed to end browser tunnel target.", cause }), + }) +} + +function reject( + write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, + code: BrowserTunnel.OpenErrorCode, + message: string, + closeCode: number, +) { + return Effect.try({ + try: () => BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.rejected", code, message }), + catch: () => undefined, + }).pipe( + Effect.flatMap((frame) => (frame ? write(frame) : Effect.void)), + Effect.catch(() => Effect.void), + Effect.andThen(close(write, closeCode, message)), + ) +} + +function close( + write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, + code: number, + reason: string, +) { + return write(new Socket.CloseEvent(code, reason.slice(0, 123))).pipe( + Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.void }), + Effect.catch(() => Effect.void), + ) +} + +function fail(queue: Queue.Queue, error: TransportError) { + return Effect.sync(() => { + Queue.failCauseUnsafe(queue, Cause.fail(error)) + }) +} diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index a0126ea89e..eb2a6f2532 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -11,6 +11,7 @@ import { CommandHandler } from "./handlers/command" import { SkillHandler } from "./handlers/skill" import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" +import { BrowserHandler } from "./handlers/browser" import { PluginHandler } from "./handlers/plugin" import { HealthHandler } from "./handlers/health" import { ServerHandler } from "./handlers/server" @@ -35,6 +36,7 @@ export const handlers = Layer.mergeAll( DebugHandler, LocationHandler, AgentHandler, + BrowserHandler, PluginHandler, SessionHandler, MessageHandler, diff --git a/packages/server/src/handlers/browser.ts b/packages/server/src/handlers/browser.ts new file mode 100644 index 0000000000..61709b1624 --- /dev/null +++ b/packages/server/src/handlers/browser.ts @@ -0,0 +1,84 @@ +import { NodeHttpServerRequest } from "@effect/platform-node" +import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser" +import { ConflictError, ServiceUnavailableError } from "@opencode-ai/protocol/errors" +import { Effect } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { ServerResponse } from "node:http" +import { Api } from "../api" +import { BrowserControlConnection } from "../browser-control-connection" +import { BrowserTunnelServer } from "../browser-tunnel" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "../cors" + +export const BrowserHandler = HttpApiBuilder.group(Api, "server.browser", (handlers) => + Effect.gen(function* () { + const browser = yield* BrowserHost.Service + const tunnels = yield* BrowserTunnelServer.Service + const cors = yield* CorsConfig + + return handlers + .handleRaw( + "browser.control.connect", + Effect.fn("BrowserHandler.control")(function* (ctx) { + const rejected = rejectUpgrade(ctx.request.headers, BROWSER_CONTROL_PROTOCOL, cors) + if (rejected) return rejected + const connection = yield* browser.claim.pipe( + Effect.mapError((error) => new ConflictError({ resource: "browser", message: error.message })), + ) + const socket = yield* Effect.orDie(ctx.request.upgrade) + const peer = yield* BrowserControlConnection.make( + socket, + Effect.sync(() => markUpgraded(ctx.request)), + ) + yield* connection.run(peer).pipe( + Effect.catchTags({ + "BrowserHost.ProtocolError": (error) => + Effect.logWarning("Browser control protocol failed", { message: error.message }), + "BrowserHost.ConnectionError": (error) => + Effect.logDebug("Browser control connection closed", { message: error.message }), + }), + ) + return HttpServerResponse.empty() + }), + ) + .handleRaw( + "browser.tunnel.connect", + Effect.fn("BrowserHandler.tunnel")(function* (ctx) { + const rejected = rejectUpgrade(ctx.request.headers, BROWSER_TUNNEL_PROTOCOL, cors) + if (rejected) return rejected + const connection = yield* tunnels.acquire.pipe( + Effect.mapError((error) => new ServiceUnavailableError({ service: "browser", message: error.message })), + ) + const socket = yield* Effect.orDie(ctx.request.upgrade) + yield* connection.run( + socket, + Effect.sync(() => markUpgraded(ctx.request)), + ) + return HttpServerResponse.empty() + }), + ) + }), +) + +function markUpgraded(request: HttpServerRequest.HttpServerRequest) { + const socket = NodeHttpServerRequest.toIncomingMessage(request).socket + // Bun leaves its HTTP handshake response assigned after ws takes ownership. Detaching + // matches Node's post-upgrade socket state and lets Effect complete the raw handler normally. + const response = Reflect.get(socket, "_httpMessage") + if (response instanceof ServerResponse) response.detachSocket(socket) +} + +function rejectUpgrade( + headers: Readonly>, + protocol: string, + cors: CorsOptions | undefined, +) { + if (!isAllowedRequestOrigin(headers.origin, headers.host, cors)) { + return HttpServerResponse.empty({ status: 403 }) + } + if (headers["sec-websocket-protocol"]?.split(",", 1)[0]?.trim() !== protocol) { + return HttpServerResponse.empty({ status: 426, headers: { "sec-websocket-protocol": protocol } }) + } + return undefined +} diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index a004fa973d..c87e16132c 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -3,6 +3,7 @@ import { UnauthorizedError } from "@opencode-ai/protocol/errors" import { Authorization } from "@opencode-ai/protocol/middleware/authorization" export { Authorization } from "@opencode-ai/protocol/middleware/authorization" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" +import { isBrowserConnectURL } from "@opencode-ai/protocol/groups/browser" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -28,7 +29,7 @@ function decodeCredential(input: string) { function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { const url = new URL(request.url, "http://localhost") - const token = url.searchParams.get(AUTH_TOKEN_QUERY) + const token = isBrowserConnectURL(request.url) ? undefined : url.searchParams.get(AUTH_TOKEN_QUERY) if (token) return decodeCredential(token) const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") if (match) return decodeCredential(match[1]) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 483745b404..fda8d5d0fb 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -2,6 +2,7 @@ export * as ServerProcess from "./process" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import { BrowserHost } from "@opencode-ai/core/browser-host" import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" @@ -15,6 +16,7 @@ import { withoutParentSpan } from "./request-tracing" import { createRoutes } from "./routes" import { ServerInfo } from "./server-info" import { Status } from "./service-status" +import { BrowserTunnelServer } from "./browser-tunnel" import type { ServerOptions } from "./options" export interface Lifecycle { @@ -46,6 +48,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( }) const bound = yield* listen({ hostname, port }) const application = yield* Ref.make(Option.none()) + const applicationShutdown = yield* Ref.make(Effect.void) // Request fibers may continue inbound trace context, but must not inherit the server startup parent. yield* bound.http .serve( @@ -68,6 +71,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( yield* Effect.addFinalizer(() => status.beginStopping.pipe( Effect.andThen(Ref.set(application, Option.none())), + Effect.andThen(Ref.get(applicationShutdown).pipe(Effect.flatMap((shutdown) => shutdown))), Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())), ), ) @@ -94,6 +98,12 @@ export const start = Effect.fn("ServerProcess.start")(function* ( ) } yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect())) + yield* Ref.set( + applicationShutdown, + Context.get(context, BrowserTunnelServer.Service).shutdown.pipe( + Effect.andThen(Context.get(context, BrowserHost.Service).shutdown), + ), + ) yield* status.ready return { address: bound.http.address, shutdown: Deferred.await(shutdown) } }).pipe( @@ -130,9 +140,39 @@ function bind(hostname: string, port: number) { const parentScope = yield* Scope.Scope const serverScope = yield* Scope.fork(parentScope) const server = createServer() + const sockets = new Set() + const onConnection = (socket: import("node:net").Socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + } + const onUpgrade = (_request: unknown, socket: import("node:net").Socket) => sockets.add(socket) + server.on("connection", onConnection) + server.on("upgrade", onUpgrade) return yield* Effect.gen(function* () { const http = yield* NodeHttpServer.make(() => server, { port, host: hostname }) yield* Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections())) + // Node's closeAllConnections deliberately excludes upgraded sockets. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.off("connection", onConnection) + server.off("upgrade", onUpgrade) + server.closeAllConnections() + }).pipe( + Effect.andThen( + Effect.suspend(() => { + if (sockets.size === 0) return Effect.void + return Effect.sleep("1 second").pipe( + Effect.andThen( + Effect.sync(() => { + for (const socket of sockets) socket.destroy() + sockets.clear() + }), + ), + ) + }), + ), + ), + ) return { http, server, scope: serverScope } }).pipe( Effect.provideService(Scope.Scope, serverScope), @@ -241,7 +281,8 @@ function unavailable(status: Status.State) { /** * The managed server owns restart continuity: it resumes Sessions the previous server suspended and * suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still - * alive: connections close first, this finalizer runs next, and Session execution teardown follows. + * alive: request admission stops first, application-owned transports receive their shutdown signal, + * listener connections close, and this finalizer runs during application teardown. */ const installRestartContinuity = Effect.fnUntraced(function* (restart: SessionRestart.Interface) { yield* Effect.forkScoped(restart.resumeSuspendedSessions) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f2d0d68df7..43b06daa4f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -4,6 +4,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Bus } from "@opencode-ai/core/bus" +import { BrowserHost } from "@opencode-ai/core/browser-host" import { EventLogger } from "@opencode-ai/core/event-logger" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Observability } from "@opencode-ai/util/observability" @@ -40,11 +41,13 @@ import { layer } from "./location" import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" import { ServerInfo } from "./server-info" +import { BrowserTunnelServer } from "./browser-tunnel" import type { ServerOptions } from "./options" const applicationServices = LayerNode.group([ Database.node, Bus.node, + BrowserHost.node, EventLogger.node, httpClient, Job.node, @@ -131,6 +134,7 @@ function makeRoutes( return serviceLayer.pipe( Layer.flatMap((context) => { const services = Layer.succeedContext(context) + const browserTunnel = BrowserTunnelServer.layer.pipe(Layer.provide(services)) const requestServices = Layer.merge( Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), ServerInfo.layer(serviceURLs, options.app), @@ -144,6 +148,7 @@ function makeRoutes( Layer.provide(schemaErrorLayer), Layer.provide(auth), HttpRouter.provideRequest(requestServices), + Layer.provideMerge(browserTunnel), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), ) diff --git a/packages/server/test/browser-tunnel.test.ts b/packages/server/test/browser-tunnel.test.ts new file mode 100644 index 0000000000..0b93024437 --- /dev/null +++ b/packages/server/test/browser-tunnel.test.ts @@ -0,0 +1,105 @@ +import { BrowserHost } from "@opencode-ai/core/browser-host" +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 { describe, expect } from "bun:test" +import { Deferred, Effect, Fiber, Option, Queue } from "effect" +import { Socket } from "effect/unstable/socket" +import { it } from "../../core/test/lib/effect" +import { BrowserTunnelServer } from "../src/browser-tunnel" + +const sessionID = Session.ID.make("ses_pending_tunnel") +const leaseID = Browser.LeaseID.make("brl_pendingtunnel") +const state: Browser.State = { + url: "https://example.com/", + title: "Example", + loading: false, + canGoBack: false, + canGoForward: false, + generation: 1, +} +const end = Symbol("end") + +const makeSocket = Effect.gen(function* () { + const inbound = yield* Queue.unbounded() + const outbound = yield* Queue.unbounded() + return { + inbound, + outbound, + socket: Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + if (options?.onOpen) yield* options.onOpen + while (true) { + const message = yield* Queue.take(inbound) + if (message === end) return + const handled = handler(message) + if (Effect.isEffect(handled)) yield* Effect.asVoid(handled) + } + }), + writer: Effect.succeed((message) => Queue.offer(outbound, message).pipe(Effect.asVoid)), + }), + } +}) + +const cancellationCase = (cause: "reader" | "lease") => + Effect.gen(function* () { + const revoked = yield* Deferred.make() + const started = yield* Deferred.make() + const cancelled = yield* Deferred.make() + const browser = BrowserHost.Service.of({ + claim: Effect.die("unused"), + lease: () => + Effect.succeed( + Option.some({ + id: leaseID, + sessionID, + state, + revoked: Deferred.await(revoked), + request: () => Effect.die("unused"), + }), + ), + shutdown: Effect.void, + }) + const tunnels = yield* BrowserTunnelServer.make(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(cancelled, undefined).pipe(Effect.asVoid)), + ), + ).pipe(Effect.provideService(BrowserHost.Service, browser)) + const connection = yield* tunnels.acquire + const transport = yield* makeSocket + const running = yield* Effect.scoped(connection.run(transport.socket)).pipe(Effect.forkChild) + + const ready = yield* Queue.take(transport.outbound) + const frame = ready instanceof Uint8Array ? ready : yield* Effect.die("expected tunnel ready frame") + expect(yield* BrowserTunnelProtocol.decodeFromServer(frame)).toEqual({ + type: "control", + message: { type: "browser.tunnel.ready" }, + }) + yield* Queue.offer( + transport.inbound, + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.open", + sessionID, + leaseID, + target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(443) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }), + ) + yield* Deferred.await(started) + + if (cause === "reader") yield* Queue.offer(transport.inbound, end) + if (cause === "lease") yield* Deferred.succeed(revoked, undefined) + + yield* Deferred.await(cancelled) + yield* Fiber.join(running) + }) + +describe("BrowserTunnelServer", () => { + it.effect("interrupts a pending target dial when the reader ends or the lease is revoked", () => + Effect.forEach(["reader", "lease"] as const, cancellationCase, { discard: true }), + ) +}) diff --git a/packages/server/test/browser.test.ts b/packages/server/test/browser.test.ts new file mode 100644 index 0000000000..f12a43ff0f --- /dev/null +++ b/packages/server/test/browser.test.ts @@ -0,0 +1,468 @@ +import { NodeSocket } from "@effect/platform-node" +import { Browser } from "@opencode-ai/schema/browser" +import { BrowserControl } from "@opencode-ai/schema/browser-control" +import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" +import { Session } from "@opencode-ai/schema/session" +import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser" +import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel" +import { expect } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Queue, Schema, Scope } from "effect" +import { TestConsole } from "effect/testing" +import { HttpServer } from "effect/unstable/http" +import { randomBytes } from "node:crypto" +import { request } from "node:http" +import { createServer } from "node:net" +import { it } from "../../core/test/lib/effect" +import { ServerProcess } from "../src/process" + +const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}` +const encodeControl = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromDesktop)) +const decodeControl = Schema.decodeUnknownSync(Schema.fromJsonString(BrowserControl.FromServer)) + +const startServer = Effect.fn("BrowserServerTest.startServer")(function* () { + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + }) + const url = new URL(HttpServer.formatAddress(server.address)) + url.protocol = "ws:" + return url +}) + +const open = Effect.fn("BrowserServerTest.open")(function* ( + url: URL, + protocol: string, + headers: Record = { authorization }, +) { + const messages = yield* Queue.unbounded<{ readonly data: string | Uint8Array; readonly binary: boolean }, Error>() + const closed = yield* Deferred.make<{ readonly code: number; readonly reason: string }>() + const webSocket = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const webSocket = new NodeSocket.NodeWS.WebSocket(url, protocol, { headers }) + webSocket.on("message", (data, binary) => + Queue.offerUnsafe(messages, { data: binary ? bytes(data) : Buffer.from(bytes(data)).toString(), binary }), + ) + webSocket.once("close", (code, reason) => + Deferred.doneUnsafe(closed, Effect.succeed({ code, reason: reason.toString() })), + ) + const onOpen = () => { + webSocket.off("error", onError) + resume(Effect.succeed(webSocket)) + } + const onError = (error: Error) => { + webSocket.off("open", onOpen) + resume(Effect.fail(error)) + } + webSocket.once("open", onOpen) + webSocket.once("error", onError) + return Effect.sync(() => webSocket.terminate()) + }), + (webSocket) => Effect.sync(() => webSocket.terminate()), + ) + return { webSocket, messages, closed } +}) + +const upgradeStatus = (url: URL, protocol: string, headers: Record = { authorization }) => + Effect.callback((resume) => { + const target = new URL(url) + target.protocol = "http:" + const upgrade = request(target, { + headers: { + ...headers, + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-key": randomBytes(16).toString("base64"), + "sec-websocket-protocol": protocol, + "sec-websocket-version": "13", + }, + }) + upgrade.once("response", (response) => { + resume(Effect.succeed(response.statusCode ?? 0)) + response.destroy() + }) + upgrade.once("upgrade", (response, socket) => { + resume(Effect.succeed(response.statusCode ?? 101)) + socket.destroy() + }) + upgrade.once("error", (error) => resume(Effect.fail(error))) + upgrade.end() + return Effect.sync(() => upgrade.destroy()) + }) + +const next = (webSocket: Effect.Success>) => Queue.take(webSocket.messages) + +const exchange = Effect.fn("BrowserServerTest.exchange")(function* ( + webSocket: Effect.Success>, + message: string | Uint8Array, +) { + yield* Effect.callback((resume) => + webSocket.webSocket.send(message, { binary: typeof message !== "string" }, (error) => + resume(error ? Effect.fail(error) : Effect.void), + ), + ) + return yield* next(webSocket) +}) + +function bytes(data: NodeSocket.NodeWS.RawData) { + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data)) + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) +} + +const tunnelControl = Effect.fn("BrowserServerTest.tunnelControl")(function* (frame: { + readonly data: string | Uint8Array +}) { + const decoded = yield* BrowserTunnelProtocol.decodeFromServer(frame.data) + if (decoded.type !== "control") throw new Error("expected browser tunnel control frame") + return decoded.message +}) + +const echoServer = Effect.gen(function* () { + const connected = yield* Deferred.make() + const ended = yield* Deferred.make() + const sentEnd = yield* Deferred.make() + const sockets = new Set() + const server = yield* Effect.acquireRelease( + Effect.callback, Error>((resume) => { + const server = createServer({ allowHalfOpen: true }, (socket) => { + sockets.add(socket) + Deferred.doneUnsafe(connected, Effect.void) + socket.on("data", (data) => socket.write(data)) + socket.on("end", () => { + Deferred.doneUnsafe(ended, Effect.void) + socket.end(() => Deferred.doneUnsafe(sentEnd, Effect.void)) + }) + socket.once("close", () => sockets.delete(socket)) + }) + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))) + return Effect.sync(() => server.close()) + }), + (server) => + Effect.sync(() => { + for (const socket of sockets) socket.destroy() + server.close() + }), + ) + return { server, connected, ended, sentEnd } +}) + +const pushServer = Effect.acquireRelease( + Effect.callback, Error>((resume) => { + const server = createServer((socket) => socket.end(Buffer.alloc(512 * 1_024, 7))) + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))) + return Effect.sync(() => server.close()) + }), + (server) => Effect.sync(() => server.close()), +) + +const collectData = ( + tunnel: Effect.Success>, + bytes = 0, + frames = 0, +): Effect.Effect<{ readonly bytes: number; readonly frames: number }, Error | BrowserTunnelProtocol.FrameError> => + Effect.gen(function* () { + const decoded = yield* BrowserTunnelProtocol.decodeFromServer((yield* next(tunnel)).data) + if (decoded.type === "data") { + tunnel.webSocket.send( + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.window", + bytes: BrowserTunnel.WindowBytes.make(decoded.data.byteLength), + frames: BrowserTunnel.FrameWindow.make(1), + }), + { binary: true }, + ) + return yield* collectData(tunnel, bytes + decoded.data.byteLength, frames + 1) + } + if (decoded.message.type === "browser.tunnel.window") return yield* collectData(tunnel, bytes, frames) + if (decoded.message.type === "browser.tunnel.end") return { bytes, frames } + return yield* Effect.fail(new Error(`Unexpected browser tunnel message: ${decoded.message.type}`)) + }) + +it.live( + "authenticates browser upgrades and tunnels TCP through an attached lease", + () => + Effect.gen(function* () { + const base = yield* startServer() + const controlURL = new URL("/api/browser/control", base) + const tunnelURL = new URL("/api/browser/tunnel", base) + + expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, {})).toBe(401) + const queryAuth = new URL(controlURL) + queryAuth.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64")) + expect(yield* upgradeStatus(queryAuth, BROWSER_CONTROL_PROTOCOL, {})).toBe(401) + for (const [path, protocol] of [ + ["/api/browser/control/", BROWSER_CONTROL_PROTOCOL], + ["/%61pi/%62rowser/%63ontrol", BROWSER_CONTROL_PROTOCOL], + ["/API//browser/control;jsessionid=ignored", BROWSER_CONTROL_PROTOCOL], + ["/api/browser/tunnel/", BROWSER_TUNNEL_PROTOCOL], + ["/%61pi/%62rowser/%74unnel", BROWSER_TUNNEL_PROTOCOL], + ] as const) { + const alternate = new URL(path, base) + alternate.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64")) + expect(yield* upgradeStatus(alternate, protocol, {})).toBe(401) + } + expect(yield* upgradeStatus(controlURL, "opencode.browser.control.invalid")).toBe(426) + expect( + yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, { + authorization, + origin: "https://malicious.example", + }), + ).toBe(403) + + const control = yield* open(controlURL, BROWSER_CONTROL_PROTOCOL) + expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" }) + expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL)).toBe(409) + + const sessionID = Session.ID.make("ses_browser_server") + const leaseID = Browser.LeaseID.make("brl_browserserver") + const sessionURL = new URL("/api/session", base) + sessionURL.protocol = "http:" + const created = yield* Effect.promise(() => + fetch(sessionURL, { + method: "POST", + headers: { authorization, "content-type": "application/json" }, + body: JSON.stringify({ id: sessionID }), + }), + ) + expect(created.status).toBe(200) + const acknowledged = yield* exchange( + control, + encodeControl({ + type: "browser.control.sync", + revision: 1, + attachments: [ + { + sessionID, + leaseID, + state: { + url: "http://localhost/", + title: "Local", + loading: false, + canGoBack: false, + canGoForward: false, + generation: 1, + }, + }, + ], + }), + ) + expect(acknowledged.binary).toBe(false) + expect(decodeControl(acknowledged.data)).toEqual({ type: "browser.control.synced", revision: 1 }) + + const stale = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL) + expect(yield* tunnelControl(yield* next(stale))).toEqual({ type: "browser.tunnel.ready" }) + const rejected = yield* exchange( + stale, + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.open", + sessionID, + leaseID: Browser.LeaseID.make("brl_stale"), + target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(1) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }), + ) + expect(yield* tunnelControl(rejected)).toMatchObject({ + type: "browser.tunnel.rejected", + code: "stale_lease", + }) + stale.webSocket.terminate() + + const target = yield* echoServer + const address = target.server.address() + if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP") + const tunnel = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL) + expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" }) + const openFrame = BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.open", + sessionID, + leaseID, + target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }) + yield* Effect.callback((resume) => + tunnel.webSocket.send(openFrame, { binary: true }, (error) => resume(error ? Effect.fail(error) : Effect.void)), + ) + yield* Deferred.await(target.connected) + const opened = yield* next(tunnel) + expect(opened.binary).toBe(true) + expect(yield* tunnelControl(opened)).toEqual({ + type: "browser.tunnel.opened", + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }) + + yield* Effect.callback((resume) => + tunnel.webSocket.send(BrowserTunnelProtocol.data(Buffer.from("browser tunnel")), { binary: true }, (error) => + resume(error ? Effect.fail(error) : Effect.void), + ), + ) + expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ + type: "browser.tunnel.window", + bytes: BrowserTunnel.WindowBytes.make(Buffer.byteLength("browser tunnel")), + frames: BrowserTunnel.FrameWindow.make(1), + }) + const echoed = yield* next(tunnel) + expect(echoed.binary).toBe(true) + const echoedFrame = yield* BrowserTunnelProtocol.decodeFromServer(echoed.data) + if (echoedFrame.type !== "data") throw new Error("expected browser tunnel data frame") + expect(Buffer.from(echoedFrame.data).toString()).toBe("browser tunnel") + tunnel.webSocket.send( + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.window", + bytes: BrowserTunnel.WindowBytes.make(echoedFrame.data.byteLength), + frames: BrowserTunnel.FrameWindow.make(1), + }), + { binary: true }, + ) + + tunnel.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true }) + yield* Deferred.await(target.ended) + yield* Deferred.await(target.sentEnd) + const ended = yield* next(tunnel) + expect(ended.binary).toBe(true) + expect(yield* tunnelControl(ended)).toEqual({ type: "browser.tunnel.end" }) + + tunnel.webSocket.terminate() + + const pushing = yield* pushServer + const pushingAddress = pushing.address() + if (pushingAddress === null || typeof pushingAddress === "string") throw new Error("push server did not bind TCP") + const large = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL) + yield* tunnelControl(yield* next(large)) + yield* exchange( + large, + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.open", + sessionID, + leaseID, + target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(pushingAddress.port) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.MaxDataBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(1), + }), + ) + const pushed = yield* collectData(large) + expect(pushed.bytes).toBe(512 * 1_024) + expect(pushed.frames).toBeGreaterThanOrEqual(8) + large.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true }) + large.webSocket.terminate() + + control.webSocket.terminate() + }), + 15_000, +) + +it.live( + "rejects an oversized raw tunnel frame before opening a tunnel", + () => + Effect.gen(function* () { + const base = yield* startServer() + const control = new Uint8Array(BrowserTunnelProtocol.MaxControlBytes + 2) + control[0] = BrowserTunnelProtocol.FrameType.Control + for (const frame of [new Uint8Array(BrowserTunnelProtocol.MaxDataBytes + 2), control]) { + const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL) + expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" }) + + const rejected = yield* exchange(tunnel, frame) + expect(yield* tunnelControl(rejected)).toMatchObject({ + type: "browser.tunnel.rejected", + code: "invalid_open", + }) + expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1009 }) + } + }), + 10_000, +) + +it.live( + "closes active browser transports with the service-restart code", + () => + Effect.gen(function* () { + const scope = yield* Scope.make() + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + }).pipe(Effect.provideService(Scope.Scope, scope)) + const base = new URL(HttpServer.formatAddress(server.address)) + base.protocol = "ws:" + const control = yield* open(new URL("/api/browser/control", base), BROWSER_CONTROL_PROTOCOL) + expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" }) + + const sessionID = Session.ID.make("ses_browser_shutdown") + const leaseID = Browser.LeaseID.make("brl_browsershutdown") + const sessionURL = new URL("/api/session", base) + sessionURL.protocol = "http:" + const created = yield* Effect.promise(() => + fetch(sessionURL, { + method: "POST", + headers: { authorization, "content-type": "application/json" }, + body: JSON.stringify({ id: sessionID }), + }), + ) + expect(created.status).toBe(200) + expect( + decodeControl( + (yield* exchange( + control, + encodeControl({ + type: "browser.control.sync", + revision: 1, + attachments: [ + { + sessionID, + leaseID, + state: { + url: "http://localhost/", + title: "Local", + loading: false, + canGoBack: false, + canGoForward: false, + generation: 1, + }, + }, + ], + }), + )).data, + ), + ).toEqual({ type: "browser.control.synced", revision: 1 }) + + const target = yield* echoServer + const address = target.server.address() + if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP") + const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL) + expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" }) + expect( + yield* tunnelControl( + yield* exchange( + tunnel, + BrowserTunnelProtocol.encodeFromDesktop({ + type: "browser.tunnel.open", + sessionID, + leaseID, + target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + }), + ), + ), + ).toMatchObject({ type: "browser.tunnel.opened" }) + + const closing = yield* Effect.forkChild(Scope.close(scope, Exit.void), { startImmediately: true }) + expect(yield* Deferred.await(control.closed)).toMatchObject({ code: 1012 }) + expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1012 }) + yield* Fiber.join(closing) + expect((yield* TestConsole.logLines).filter((line) => String(line).includes("Socket already assigned"))).toEqual( + [], + ) + }), + 10_000, +) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index b7cc24d709..8432078209 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -160,6 +160,134 @@ "summary": "Get server information" } }, + "/api/browser/control": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.control.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.control.v1 is required." + } + }, + "description": "Establish an authenticated WebSocket carrying Session-scoped browser attachments and semantic browser commands.", + "summary": "Connect desktop browser host", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.control.v1", + "x-websocket-incoming": "BrowserControl.FromDesktop", + "x-websocket-outgoing": "BrowserControl.FromServer" + } + }, + "/api/browser/tunnel": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.tunnel.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.tunnel.v1 is required." + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.", + "summary": "Open browser network tunnel", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.tunnel.v1", + "x-websocket-incoming": "BrowserTunnel.FromDesktop and binary DATA frames", + "x-websocket-outgoing": "BrowserTunnel.FromServer and binary DATA frames" + } + }, "/api/location": { "get": { "tags": [ @@ -12012,6 +12140,64 @@ ], "additionalProperties": false }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConflictError" + ] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServiceUnavailableError" + ] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, "Location.Info": { "type": "object", "properties": { @@ -12838,35 +13024,6 @@ ], "additionalProperties": false }, - "ConflictError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ConflictError" - ] - }, - "message": { - "type": "string" - }, - "resource": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "CommandNotFoundError": { "type": "object", "properties": { @@ -13058,35 +13215,6 @@ ], "additionalProperties": false }, - "ServiceUnavailableError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ServiceUnavailableError" - ] - }, - "message": { - "type": "string" - }, - "service": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "SessionBusyError": { "type": "object", "properties": { @@ -13705,7 +13833,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13850,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13876,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13930,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -17114,6 +17249,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17375,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17498,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -29135,6 +29313,10 @@ { "name": "server" }, + { + "name": "browser", + "description": "Desktop browser host control and server-network tunnel routes." + }, { "name": "location" }, diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index b7cc24d709..8432078209 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -160,6 +160,134 @@ "summary": "Get server information" } }, + "/api/browser/control": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.control.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "409": { + "description": "ConflictError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConflictError" + } + } + } + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.control.v1 is required." + } + }, + "description": "Establish an authenticated WebSocket carrying Session-scoped browser attachments and semantic browser commands.", + "summary": "Connect desktop browser host", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.control.v1", + "x-websocket-incoming": "BrowserControl.FromDesktop", + "x-websocket-outgoing": "BrowserControl.FromServer" + } + }, + "/api/browser/tunnel": { + "get": { + "tags": [ + "browser" + ], + "operationId": "v2.browser.tunnel.connect", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "WebSocket Origin is not allowed." + }, + "426": { + "description": "WebSocket subprotocol opencode.browser.tunnel.v1 is required." + }, + "503": { + "description": "ServiceUnavailableError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceUnavailableError" + } + } + } + } + }, + "description": "Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.", + "summary": "Open browser network tunnel", + "x-websocket": true, + "x-websocket-subprotocol": "opencode.browser.tunnel.v1", + "x-websocket-incoming": "BrowserTunnel.FromDesktop and binary DATA frames", + "x-websocket-outgoing": "BrowserTunnel.FromServer and binary DATA frames" + } + }, "/api/location": { "get": { "tags": [ @@ -12012,6 +12140,64 @@ ], "additionalProperties": false }, + "ConflictError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ConflictError" + ] + }, + "message": { + "type": "string" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, + "ServiceUnavailableError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServiceUnavailableError" + ] + }, + "message": { + "type": "string" + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": false + }, "Location.Info": { "type": "object", "properties": { @@ -12838,35 +13024,6 @@ ], "additionalProperties": false }, - "ConflictError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ConflictError" - ] - }, - "message": { - "type": "string" - }, - "resource": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "CommandNotFoundError": { "type": "object", "properties": { @@ -13058,35 +13215,6 @@ ], "additionalProperties": false }, - "ServiceUnavailableError": { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "ServiceUnavailableError" - ] - }, - "message": { - "type": "string" - }, - "service": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "message" - ], - "additionalProperties": false - }, "SessionBusyError": { "type": "object", "properties": { @@ -13705,7 +13833,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13850,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13876,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13930,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -17114,6 +17249,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17375,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17498,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -29135,6 +29313,10 @@ { "name": "server" }, + { + "name": "browser", + "description": "Desktop browser host control and server-network tunnel routes." + }, { "name": "location" },