Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/protocol/src/browser-control.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
export * as BrowserControlProtocol from "./browser-control.js"

import { BrowserControl } from "@opencode-ai/schema/browser-control"
import { Effect, Schema } from "effect"

export const Path = "/api/browser/control"
export const Subprotocol = "opencode.browser.control.v1"
export const MaxMessageBytes = 8 * 1_024 * 1_024

class MessageError extends Schema.TaggedErrorClass<MessageError>()("BrowserControlProtocol.MessageError", {
kind: Schema.Literals(["invalid", "too_large"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}

const decoder = new TextDecoder("utf-8", { fatal: true })
const encoder = new TextEncoder()
const encodeClient = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromClient))
const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromServer))
const decodeClient = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromClient), {
errors: "all",
onExcessProperty: "error",
})
const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromServer), {
errors: "all",
onExcessProperty: "error",
})

export function encodeFromClient(input: BrowserControl.FromClient) {
return encode(input, encodeClient)
}

export function encodeFromServer(input: BrowserControl.FromServer) {
return encode(input, encodeServer)
}

function encode<Message>(input: Message, encodeMessage: (input: Message) => string) {
const output = encodeMessage(input)
if (encoder.encode(output).byteLength > MaxMessageBytes) {
throw new RangeError(`Browser control message must not exceed ${MaxMessageBytes} bytes.`)
}
return output
}

export function decodeFromClient(input: string | Uint8Array) {
return decode(input, decodeClient)
}

export function decodeFromServer(input: string | Uint8Array) {
return decode(input, decodeServer)
}

function decode<Message>(
input: string | Uint8Array,
decodeMessage: (input: unknown) => Effect.Effect<Message, unknown>,
): Effect.Effect<Message, MessageError> {
if (typeof input === "string" && encoder.encode(input).byteLength > MaxMessageBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser control message is too large." }))
}
if (typeof input !== "string" && input.byteLength > MaxMessageBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser control message is too large." }))
}
const text =
typeof input === "string"
? Effect.succeed(input)
: Effect.try({
try: () => decoder.decode(input),
catch: (cause) =>
new MessageError({ kind: "invalid", message: "Browser control message is not valid UTF-8.", cause }),
})
return text.pipe(
Effect.flatMap(decodeMessage),
Effect.mapError((cause) =>
cause instanceof MessageError
? cause
: new MessageError({ kind: "invalid", message: "Browser control message is invalid.", cause }),
),
)
}
75 changes: 75 additions & 0 deletions packages/protocol/src/browser-tunnel.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
export * as BrowserTunnelProtocol from "./browser-tunnel.js"

import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
import { Effect, Schema } from "effect"

export const Path = "/api/browser/tunnel"
export const Subprotocol = "opencode.browser.tunnel.v1"
export const MaxFrameBytes = 64 * 1_024
export const MaxHandshakeBytes = 16 * 1_024

class MessageError extends Schema.TaggedErrorClass<MessageError>()("BrowserTunnelProtocol.MessageError", {
kind: Schema.Literals(["invalid", "too_large"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}

const encoder = new TextEncoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
const encodeClient = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromClient))
const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromServer))
const decodeClient = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromClient), {
errors: "all",
onExcessProperty: "error",
})
const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromServer), {
errors: "all",
onExcessProperty: "error",
})

export function encodeFromClient(input: BrowserTunnel.FromClient) {
return encode(encodeClient(input))
}

export function encodeFromServer(input: BrowserTunnel.FromServer) {
return encode(encodeServer(input))
}

function encode(input: string) {
if (encoder.encode(input).byteLength > MaxHandshakeBytes) {
throw new RangeError(`Browser tunnel handshake must not exceed ${MaxHandshakeBytes} bytes.`)
}
return input
}

export function decodeFromClient(input: string | Uint8Array) {
return decode(input, decodeClient)
}

export function decodeFromServer(input: string | Uint8Array) {
return decode(input, decodeServer)
}

function decode<Message>(
input: string | Uint8Array,
decodeMessage: (input: unknown) => Effect.Effect<Message, unknown>,
): Effect.Effect<Message, MessageError> {
if ((typeof input === "string" ? encoder.encode(input).byteLength : input.byteLength) > MaxHandshakeBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser tunnel handshake is too large." }))
}
const text =
typeof input === "string"
? Effect.succeed(input)
: Effect.try({
try: () => decoder.decode(input),
catch: (cause) => new MessageError({ kind: "invalid", message: "Invalid tunnel handshake UTF-8.", cause }),
})
return text.pipe(
Effect.flatMap(decodeMessage),
Effect.mapError((cause) =>
cause instanceof MessageError
? cause
: new MessageError({ kind: "invalid", message: "Browser tunnel handshake is invalid.", cause }),
),
)
}
63 changes: 63 additions & 0 deletions packages/protocol/src/groups/browser.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ConflictError, ServiceUnavailableError } from "../errors.js"
import { BrowserControlProtocol } from "../browser-control.js"
import { BrowserTunnelProtocol } from "../browser-tunnel.js"
import { HeaderOnlyAuthorization } from "../middleware/authorization.js"

const websocket = (identifier: string, summary: string, description: string, subprotocol: string) =>
OpenApi.annotations({
identifier,
summary,
description,
transform: (operation) => ({
...operation,
"x-websocket": true,
"x-websocket-subprotocol": subprotocol,
responses: {
...operation.responses,
403: { description: "WebSocket Origin is not allowed." },
426: { description: `WebSocket subprotocol ${subprotocol} is required.` },
},
}),
})

export const BrowserGroup = HttpApiGroup.make("server.browser")
.add(
HttpApiEndpoint.get("browser.control.connect", BrowserControlProtocol.Path, {
success: Schema.Boolean,
error: ConflictError,
})
.annotate(HeaderOnlyAuthorization, true)
.annotate(OpenApi.Exclude, true)
.annotateMerge(
websocket(
"v2.browser.control.connect",
"Connect Session browser host",
"Establish an authenticated WebSocket controlling the browser attachment for one Session.",
BrowserControlProtocol.Subprotocol,
),
),
)
.add(
HttpApiEndpoint.get("browser.tunnel.connect", BrowserTunnelProtocol.Path, {
success: Schema.Boolean,
error: ServiceUnavailableError,
})
.annotate(HeaderOnlyAuthorization, true)
.annotate(OpenApi.Exclude, true)
.annotateMerge(
websocket(
"v2.browser.tunnel.connect",
"Open browser network tunnel",
"Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.",
BrowserTunnelProtocol.Subprotocol,
),
),
)
.annotateMerge(
OpenApi.annotations({
title: "browser",
description: "Desktop browser host control and server-network tunnel routes.",
}),
)
5 changes: 5 additions & 0 deletions packages/protocol/src/middleware/authorization.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
import { Context } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { UnauthorizedError } from "../errors.js"

export const HeaderOnlyAuthorization = Context.Reference<boolean>("@opencode/HttpApiAuthorization/HeaderOnly", {
defaultValue: () => false,
})

export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
error: UnauthorizedError,
}) {}
78 changes: 78 additions & 0 deletions packages/schema/src/browser-control.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
export * as BrowserControl from "./browser-control.js"

import { Schema } from "effect"
import { Browser } from "./browser.js"
import { ascending } from "./identifier.js"
import { SessionID } from "./session-id.js"
import { statics } from "./schema.js"

const RequestIDSchema = Schema.String.check(Schema.isPattern(/^brr_[0-9A-Za-z]+$/))
.pipe(Schema.brand("BrowserControl.RequestID"))
.annotate({ identifier: "BrowserControl.RequestID" })

export const RequestID = RequestIDSchema.pipe(
statics((schema: typeof RequestIDSchema) => ({
create: () => schema.make("brr_" + ascending()),
})),
)
export type RequestID = typeof RequestID.Type

const Register = Schema.Struct({
type: Schema.Literal("browser.control.register"),
sessionID: SessionID,
})

const Attach = Schema.Struct({
type: Schema.Literal("browser.control.attach"),
leaseID: Browser.LeaseID,
state: Browser.State,
})

const State = Schema.Struct({
type: Schema.Literal("browser.control.state"),
leaseID: Browser.LeaseID,
state: Browser.State,
})

const Detach = Schema.Struct({
type: Schema.Literal("browser.control.detach"),
leaseID: Browser.LeaseID,
})

const Response = Schema.Struct({
type: Schema.Literal("browser.control.response"),
requestID: RequestID,
leaseID: Browser.LeaseID,
outcome: Browser.Outcome,
})

const Registered = Schema.Struct({ type: Schema.Literal("browser.control.registered") })
const Open = Schema.Struct({ type: Schema.Literal("browser.control.open") })

const Attached = Schema.Struct({
type: Schema.Literal("browser.control.attached"),
leaseID: Browser.LeaseID,
})

const Request = Schema.Struct({
type: Schema.Literal("browser.control.request"),
requestID: RequestID,
leaseID: Browser.LeaseID,
command: Browser.Command,
})

const Cancel = Schema.Struct({
type: Schema.Literal("browser.control.cancel"),
requestID: RequestID,
leaseID: Browser.LeaseID,
})

export const FromClient = Schema.Union([Register, Attach, State, Detach, Response])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserControl.FromClient" })
export type FromClient = typeof FromClient.Type

export const FromServer = Schema.Union([Registered, Open, Attached, Request, Cancel])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserControl.FromServer" })
export type FromServer = typeof FromServer.Type
55 changes: 55 additions & 0 deletions packages/schema/src/browser-tunnel.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
export * as BrowserTunnel from "./browser-tunnel.js"

import { Schema } from "effect"
import { Browser } from "./browser.js"
import { SessionID } from "./session-id.js"

export const Host = Schema.NonEmptyString.check(Schema.isMaxLength(253), Schema.isPattern(/^[^\s/?#]+$/))
.pipe(Schema.brand("BrowserTunnel.Host"))
.annotate({ identifier: "BrowserTunnel.Host" })
export type Host = typeof Host.Type

export const Port = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }))
.pipe(Schema.brand("BrowserTunnel.Port"))
.annotate({ identifier: "BrowserTunnel.Port" })
export type Port = typeof Port.Type

export interface Target extends Schema.Schema.Type<typeof Target> {}
export const Target = Schema.Struct({
host: Host,
port: Port,
}).annotate({ identifier: "BrowserTunnel.Target" })

const Open = Schema.Struct({
type: Schema.Literal("browser.tunnel.open"),
sessionID: SessionID,
leaseID: Browser.LeaseID,
target: Target,
}).annotate({ identifier: "BrowserTunnel.Open" })

const Opened = Schema.Struct({
type: Schema.Literal("browser.tunnel.opened"),
}).annotate({ identifier: "BrowserTunnel.Opened" })

export const OpenErrorCode = Schema.Literals([
"invalid_open",
"not_attached",
"stale_lease",
"connect_failed",
"connect_timeout",
]).annotate({ identifier: "BrowserTunnel.OpenErrorCode" })
export type OpenErrorCode = typeof OpenErrorCode.Type

const Rejected = Schema.Struct({
type: Schema.Literal("browser.tunnel.rejected"),
code: OpenErrorCode,
message: Schema.String.check(Schema.isMaxLength(1_024)),
}).annotate({ identifier: "BrowserTunnel.Rejected" })

export const FromClient = Open.annotate({ identifier: "BrowserTunnel.FromClient" })
export type FromClient = typeof FromClient.Type

export const FromServer = Schema.Union([Opened, Rejected])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserTunnel.FromServer" })
export type FromServer = typeof FromServer.Type
Loading
Loading