From a7a1b3011eefa52b71ef09d02945f608818cc493 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:39:56 +1000 Subject: [PATCH] feat(protocol): add browser contracts --- packages/protocol/src/browser-control.ts | 79 +++++++++ packages/protocol/src/browser-tunnel.ts | 75 ++++++++ packages/protocol/src/groups/browser.ts | 63 +++++++ .../protocol/src/middleware/authorization.ts | 5 + packages/schema/src/browser-control.ts | 78 +++++++++ packages/schema/src/browser-tunnel.ts | 55 ++++++ packages/schema/src/browser.ts | 163 ++++++++++++++++++ packages/schema/src/index.ts | 1 + 8 files changed, 519 insertions(+) create mode 100644 packages/protocol/src/browser-control.ts create mode 100644 packages/protocol/src/browser-tunnel.ts create mode 100644 packages/protocol/src/groups/browser.ts create mode 100644 packages/schema/src/browser-control.ts create mode 100644 packages/schema/src/browser-tunnel.ts create mode 100644 packages/schema/src/browser.ts diff --git a/packages/protocol/src/browser-control.ts b/packages/protocol/src/browser-control.ts new file mode 100644 index 0000000000..0b9d617c8a --- /dev/null +++ b/packages/protocol/src/browser-control.ts @@ -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()("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(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( + input: string | Uint8Array, + decodeMessage: (input: unknown) => Effect.Effect, +): Effect.Effect { + 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 }), + ), + ) +} diff --git a/packages/protocol/src/browser-tunnel.ts b/packages/protocol/src/browser-tunnel.ts new file mode 100644 index 0000000000..ae60bb82d3 --- /dev/null +++ b/packages/protocol/src/browser-tunnel.ts @@ -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()("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( + input: string | Uint8Array, + decodeMessage: (input: unknown) => Effect.Effect, +): Effect.Effect { + 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 }), + ), + ) +} diff --git a/packages/protocol/src/groups/browser.ts b/packages/protocol/src/groups/browser.ts new file mode 100644 index 0000000000..2a3ca39856 --- /dev/null +++ b/packages/protocol/src/groups/browser.ts @@ -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.", + }), + ) diff --git a/packages/protocol/src/middleware/authorization.ts b/packages/protocol/src/middleware/authorization.ts index 54d33bc26e..60675551f5 100644 --- a/packages/protocol/src/middleware/authorization.ts +++ b/packages/protocol/src/middleware/authorization.ts @@ -1,6 +1,11 @@ +import { Context } from "effect" import { HttpApiMiddleware } from "effect/unstable/httpapi" import { UnauthorizedError } from "../errors.js" +export const HeaderOnlyAuthorization = Context.Reference("@opencode/HttpApiAuthorization/HeaderOnly", { + defaultValue: () => false, +}) + export class Authorization extends HttpApiMiddleware.Service()("@opencode/HttpApiAuthorization", { error: UnauthorizedError, }) {} diff --git a/packages/schema/src/browser-control.ts b/packages/schema/src/browser-control.ts new file mode 100644 index 0000000000..1acf1b5043 --- /dev/null +++ b/packages/schema/src/browser-control.ts @@ -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 diff --git a/packages/schema/src/browser-tunnel.ts b/packages/schema/src/browser-tunnel.ts new file mode 100644 index 0000000000..b814a6b5cf --- /dev/null +++ b/packages/schema/src/browser-tunnel.ts @@ -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 {} +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 diff --git a/packages/schema/src/browser.ts b/packages/schema/src/browser.ts new file mode 100644 index 0000000000..2c7e1ec11a --- /dev/null +++ b/packages/schema/src/browser.ts @@ -0,0 +1,163 @@ +export * as Browser from "./browser.js" + +import { Schema } from "effect" +import { ascending } from "./identifier.js" +import { NonNegativeInt, PositiveInt, statics } from "./schema.js" + +const LeaseIDSchema = Schema.String.check(Schema.isPattern(/^brl_[0-9A-Za-z]+$/)) + .pipe(Schema.brand("Browser.LeaseID")) + .annotate({ identifier: "Browser.LeaseID" }) + +export const LeaseID = LeaseIDSchema.pipe( + statics((schema: typeof LeaseIDSchema) => ({ + create: () => schema.make("brl_" + ascending()), + })), +) +export type LeaseID = typeof LeaseID.Type + +export const Ref = Schema.String.check(Schema.isPattern(/^e[1-9][0-9]*$/)) + .pipe(Schema.brand("Browser.Ref")) + .annotate({ identifier: "Browser.Ref" }) +export type Ref = typeof Ref.Type + +export interface State extends Schema.Schema.Type {} +export const State = Schema.Struct({ + url: Schema.String.check(Schema.isMaxLength(16_384)), + title: Schema.String.check(Schema.isMaxLength(1_024)), + loading: Schema.Boolean, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + generation: NonNegativeInt, +}).annotate({ identifier: "Browser.State" }) + +export const Key = Schema.Literals([ + "Enter", + "Tab", + "Escape", + "Backspace", + "Delete", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "PageUp", + "PageDown", + "Home", + "End", + "Space", +]).annotate({ identifier: "Browser.Key" }) +export type Key = typeof Key.Type + +export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ + identifier: "Browser.Direction", +}) +export type Direction = typeof Direction.Type + +const generation = { generation: NonNegativeInt } + +export const Command = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("navigate"), + url: Schema.String.check(Schema.isMaxLength(16_384)), + ...generation, + }), + Schema.Struct({ type: Schema.Literal("snapshot"), ...generation }), + Schema.Struct({ type: Schema.Literal("click"), ref: Ref, ...generation }), + Schema.Struct({ + type: Schema.Literal("fill"), + ref: Ref, + text: Schema.String.check(Schema.isMaxLength(10_000)), + ...generation, + }), + Schema.Struct({ type: Schema.Literal("press"), key: Key, ...generation }), + Schema.Struct({ type: Schema.Literal("scroll"), direction: Direction, pixels: PositiveInt, ...generation }), + Schema.Struct({ type: Schema.Literal("screenshot"), ...generation }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Command" }) +export type Command = typeof Command.Type + +const NavigateResult = Schema.Struct({ + type: Schema.Literal("navigate"), + state: State, +}).annotate({ identifier: "Browser.NavigateResult" }) + +const SnapshotResult = Schema.Struct({ + type: Schema.Literal("snapshot"), + state: State, + format: Schema.Literal("opencode.semantic.v1"), + content: Schema.String.check(Schema.isMaxLength(100_000)), +}).annotate({ identifier: "Browser.SnapshotResult" }) + +const ClickResult = Schema.Struct({ + type: Schema.Literal("click"), + state: State, +}).annotate({ identifier: "Browser.ClickResult" }) + +const FillResult = Schema.Struct({ + type: Schema.Literal("fill"), + state: State, +}).annotate({ identifier: "Browser.FillResult" }) + +const PressResult = Schema.Struct({ + type: Schema.Literal("press"), + state: State, +}).annotate({ identifier: "Browser.PressResult" }) + +const ScrollResult = Schema.Struct({ + type: Schema.Literal("scroll"), + state: State, +}).annotate({ identifier: "Browser.ScrollResult" }) + +const ScreenshotResult = Schema.Struct({ + type: Schema.Literal("screenshot"), + state: State, + mediaType: Schema.Literal("image/png"), + data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)), + width: PositiveInt, + height: PositiveInt, +}).annotate({ identifier: "Browser.ScreenshotResult" }) + +export const Result = Schema.Union([ + NavigateResult, + SnapshotResult, + ClickResult, + FillResult, + PressResult, + ScrollResult, + ScreenshotResult, +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Result" }) +export type Result = typeof Result.Type + +export const ErrorCode = Schema.Literals([ + "not_attached", + "stale_ref", + "invalid_url", + "navigation_failed", + "timeout", + "aborted", + "page_crashed", + "result_too_large", + "overloaded", + "protocol", + "internal", +]).annotate({ identifier: "Browser.ErrorCode" }) +export type ErrorCode = typeof ErrorCode.Type + +const Failure = Schema.Struct({ + type: Schema.Literal("failure"), + code: ErrorCode, + message: Schema.String.check(Schema.isMaxLength(1_024)), +}).annotate({ identifier: "Browser.Failure" }) + +const Success = Schema.Struct({ + type: Schema.Literal("success"), + result: Result, +}).annotate({ identifier: "Browser.Success" }) + +export const Outcome = Schema.Union([Success, Failure]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Outcome" }) +export type Outcome = typeof Outcome.Type diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 0cbb430add..9d8351b46d 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,4 +1,5 @@ export { Agent } from "./agent.js" +export { Browser } from "./browser.js" export { Command } from "./command.js" export { Config } from "./config.js" export { Connection } from "./connection.js"