diff --git a/packages/protocol/src/browser-control.ts b/packages/protocol/src/browser-control.ts new file mode 100644 index 0000000000..e41b8d4b59 --- /dev/null +++ b/packages/protocol/src/browser-control.ts @@ -0,0 +1,77 @@ +export * as BrowserControlProtocol from "./browser-control.js" + +import { BrowserControl } from "@opencode-ai/schema/browser-control" +import { Effect, Schema } from "effect" + +export const MaxMessageBytes = 8 * 1_024 * 1_024 + +export 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 encodeDesktop = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromDesktop)) +const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromServer)) +const decodeDesktop = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromDesktop), { + errors: "all", + onExcessProperty: "error", +}) +const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromServer), { + errors: "all", + onExcessProperty: "error", +}) + +export function encodeFromDesktop(input: BrowserControl.FromDesktop) { + return encode(input, encodeDesktop) +} + +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 decodeFromDesktop(input: string | Uint8Array) { + return decode(input, decodeDesktop) +} + +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..cfd436026a --- /dev/null +++ b/packages/protocol/src/browser-tunnel.ts @@ -0,0 +1,117 @@ +export * as BrowserTunnelProtocol from "./browser-tunnel.js" + +import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" +import { Effect, Schema } from "effect" + +export const FrameType = { + Data: 0, + Control: 1, +} as const + +export const MaxDataBytes = 64 * 1_024 +export const MaxControlBytes = 16 * 1_024 +export const InitialWindowBytes = 256 * 1_024 +export const InitialFrameWindow = 16 + +export class FrameError extends Schema.TaggedErrorClass()("BrowserTunnelProtocol.FrameError", { + kind: Schema.Literals(["invalid", "too_large"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export type DataFrame = { + readonly type: "data" + readonly data: Uint8Array +} + +export type ControlFrame = { + readonly type: "control" + readonly message: Message +} + +export type FromDesktop = DataFrame | ControlFrame +export type FromServer = DataFrame | ControlFrame + +const encoder = new TextEncoder() +const decoder = new TextDecoder("utf-8", { fatal: true }) +const encodeDesktop = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromDesktop)) +const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromServer)) +const decodeDesktop = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromDesktop), { + errors: "all", + onExcessProperty: "error", +}) +const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromServer), { + errors: "all", + onExcessProperty: "error", +}) + +export function data(input: Uint8Array) { + if (input.byteLength === 0 || input.byteLength > MaxDataBytes) { + throw new RangeError(`Browser tunnel data must contain between 1 and ${MaxDataBytes} bytes.`) + } + const frame = new Uint8Array(input.byteLength + 1) + frame[0] = FrameType.Data + frame.set(input, 1) + return frame +} + +export function encodeFromDesktop(input: BrowserTunnel.FromDesktop) { + return control(encodeDesktop(input)) +} + +export function encodeFromServer(input: BrowserTunnel.FromServer) { + return control(encodeServer(input)) +} + +function control(input: string) { + const payload = encoder.encode(input) + if (payload.byteLength > MaxControlBytes) { + throw new RangeError(`Browser tunnel control data must not exceed ${MaxControlBytes} bytes.`) + } + const frame = new Uint8Array(payload.byteLength + 1) + frame[0] = FrameType.Control + frame.set(payload, 1) + return frame +} + +export function decodeFromDesktop(input: string | Uint8Array): Effect.Effect { + return decode(input, decodeDesktop) +} + +export function decodeFromServer(input: string | Uint8Array): Effect.Effect { + return decode(input, decodeServer) +} + +function decode( + input: string | Uint8Array, + decodeMessage: (input: unknown) => Effect.Effect, +): Effect.Effect, FrameError> { + if (typeof input === "string" || input.byteLength === 0) { + return Effect.fail(new FrameError({ kind: "invalid", message: "Browser tunnel frames must use binary framing." })) + } + if (input[0] === FrameType.Data) { + if (input.byteLength === 1 || input.byteLength > MaxDataBytes + 1) { + return Effect.fail(new FrameError({ kind: "too_large", message: "Browser tunnel data frame size is invalid." })) + } + return Effect.succeed({ type: "data", data: input.subarray(1) }) + } + if (input[0] !== FrameType.Control) { + return Effect.fail(new FrameError({ kind: "invalid", message: "Browser tunnel frame type is invalid." })) + } + if (input.byteLength > MaxControlBytes + 1) { + return Effect.fail(new FrameError({ kind: "too_large", message: "Browser tunnel control frame is too large." })) + } + return Effect.try({ + try: () => decoder.decode(input.subarray(1)), + catch: (cause) => + new FrameError({ kind: "invalid", message: "Browser tunnel control frame is not valid UTF-8.", cause }), + }).pipe( + Effect.flatMap(decodeMessage), + Effect.map((message) => ({ type: "control" as const, message })), + Effect.mapError((cause) => + cause instanceof FrameError + ? cause + : new FrameError({ kind: "invalid", message: "Browser tunnel control frame 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..5fc249a2ff --- /dev/null +++ b/packages/protocol/src/groups/browser.ts @@ -0,0 +1,83 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ConflictError, ServiceUnavailableError } from "../errors.js" + +export const BROWSER_CONTROL_PROTOCOL = "opencode.browser.control.v1" +export const BROWSER_TUNNEL_PROTOCOL = "opencode.browser.tunnel.v1" + +export function isBrowserConnectURL(input: string) { + try { + const path = decodeURI(new URL(input, "http://localhost").pathname) + .replace(/;[^/]*$/, "") + .replace(/\/+/g, "/") + .replace(/\/$/, "") + .toLowerCase() + return path === "/api/browser/control" || path === "/api/browser/tunnel" + } catch { + return false + } +} + +const websocket = ( + identifier: string, + summary: string, + description: string, + subprotocol: string, + incoming: string, + outgoing: string, +) => + OpenApi.annotations({ + identifier, + summary, + description, + transform: (operation) => ({ + ...operation, + "x-websocket": true, + "x-websocket-subprotocol": subprotocol, + "x-websocket-incoming": incoming, + "x-websocket-outgoing": outgoing, + 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", "/api/browser/control", { + success: Schema.Boolean, + error: ConflictError, + }).annotateMerge( + websocket( + "v2.browser.control.connect", + "Connect desktop browser host", + "Establish an authenticated WebSocket carrying Session-scoped browser attachments and semantic browser commands.", + BROWSER_CONTROL_PROTOCOL, + "BrowserControl.FromDesktop", + "BrowserControl.FromServer", + ), + ), + ) + .add( + HttpApiEndpoint.get("browser.tunnel.connect", "/api/browser/tunnel", { + success: Schema.Boolean, + error: ServiceUnavailableError, + }).annotateMerge( + websocket( + "v2.browser.tunnel.connect", + "Open browser network tunnel", + "Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.", + BROWSER_TUNNEL_PROTOCOL, + "BrowserTunnel.FromDesktop and binary DATA frames", + "BrowserTunnel.FromServer and binary DATA frames", + ), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "browser", + description: "Desktop browser host control and server-network tunnel routes.", + }), + ) diff --git a/packages/protocol/test/browser-control.test.ts b/packages/protocol/test/browser-control.test.ts new file mode 100644 index 0000000000..17de566f73 --- /dev/null +++ b/packages/protocol/test/browser-control.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { BrowserControlProtocol } from "../src/browser-control.js" +import { Effect } from "effect" + +describe("BrowserControlProtocol", () => { + test("decodes text and Bun-compatible UTF-8 byte messages", async () => { + const message = '{"type":"browser.control.sync","revision":1,"attachments":[]}' + expect(await Effect.runPromise(BrowserControlProtocol.decodeFromDesktop(message))).toEqual({ + type: "browser.control.sync", + revision: 1, + attachments: [], + }) + expect( + await Effect.runPromise(BrowserControlProtocol.decodeFromDesktop(new TextEncoder().encode(message))), + ).toEqual({ + type: "browser.control.sync", + revision: 1, + attachments: [], + }) + }) + + test("round trips both control directions", async () => { + const desktop = { type: "browser.control.sync" as const, revision: 1, attachments: [] } + const server = { type: "browser.control.synced" as const, revision: 1 } + expect( + await Effect.runPromise(BrowserControlProtocol.decodeFromDesktop(BrowserControlProtocol.encodeFromDesktop(desktop))), + ).toEqual(desktop) + expect( + await Effect.runPromise(BrowserControlProtocol.decodeFromServer(BrowserControlProtocol.encodeFromServer(server))), + ).toEqual(server) + }) + + test("rejects excess properties and oversized messages", async () => { + expect( + await Effect.runPromise( + BrowserControlProtocol.decodeFromDesktop( + '{"type":"browser.control.sync","revision":1,"attachments":[],"extra":true}', + ).pipe(Effect.result), + ), + ).toMatchObject({ _tag: "Failure", failure: { _tag: "BrowserControlProtocol.MessageError" } }) + + expect( + await Effect.runPromise( + BrowserControlProtocol.decodeFromDesktop(new Uint8Array(BrowserControlProtocol.MaxMessageBytes + 1)).pipe( + Effect.result, + ), + ), + ).toMatchObject({ + _tag: "Failure", + failure: { _tag: "BrowserControlProtocol.MessageError", kind: "too_large" }, + }) + }) +}) diff --git a/packages/protocol/test/browser-tunnel.test.ts b/packages/protocol/test/browser-tunnel.test.ts new file mode 100644 index 0000000000..d46f77027e --- /dev/null +++ b/packages/protocol/test/browser-tunnel.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { Browser } from "@opencode-ai/schema/browser" +import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" +import { Session } from "@opencode-ai/schema/session" +import { Effect } from "effect" +import { BrowserTunnelProtocol } from "../src/browser-tunnel.js" + +describe("BrowserTunnelProtocol", () => { + test("round trips binary data without copying protocol semantics into it", async () => { + const payload = new Uint8Array([0, 1, 2, 255]) + const decoded = await Effect.runPromise(BrowserTunnelProtocol.decodeFromServer(BrowserTunnelProtocol.data(payload))) + expect(decoded).toEqual({ type: "data", data: payload }) + expect(Array.from(BrowserTunnelProtocol.data(new Uint8Array([1, 2])))).toEqual([0, 1, 2]) + }) + + test("round trips typed control messages", async () => { + const message: BrowserTunnel.FromDesktop = { + type: "browser.tunnel.open", + sessionID: Session.ID.make("ses_browser_tunnel"), + leaseID: Browser.LeaseID.make("brl_browsertunnel"), + target: { host: BrowserTunnel.Host.make("localhost"), port: BrowserTunnel.Port.make(5173) }, + receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes), + receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow), + } + const decoded = await Effect.runPromise( + BrowserTunnelProtocol.decodeFromDesktop(BrowserTunnelProtocol.encodeFromDesktop(message)), + ) + expect(decoded).toEqual({ type: "control", message }) + }) + + test("rejects unframed, unknown, and malformed control payloads", async () => { + for (const input of [ + "unframed", + new Uint8Array([9, 0]), + new Uint8Array([BrowserTunnelProtocol.FrameType.Control, 255]), + ]) { + expect(await Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(input).pipe(Effect.result))).toMatchObject( + { + _tag: "Failure", + failure: { _tag: "BrowserTunnelProtocol.FrameError" }, + }, + ) + } + }) + + test("pins control framing and public size limits", async () => { + expect(Array.from(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }))).toEqual([ + BrowserTunnelProtocol.FrameType.Control, + ...new TextEncoder().encode('{"type":"browser.tunnel.end"}'), + ]) + expect(() => BrowserTunnelProtocol.data(new Uint8Array())).toThrow() + expect(() => BrowserTunnelProtocol.data(new Uint8Array(BrowserTunnelProtocol.MaxDataBytes + 1))).toThrow() + + const extra = new TextEncoder().encode('{"type":"browser.tunnel.end","extra":true}') + const frame = new Uint8Array(extra.byteLength + 1) + frame[0] = BrowserTunnelProtocol.FrameType.Control + frame.set(extra, 1) + expect(await Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(frame).pipe(Effect.result))).toMatchObject({ + _tag: "Failure", + failure: { _tag: "BrowserTunnelProtocol.FrameError" }, + }) + }) +}) diff --git a/packages/schema/src/browser-control.ts b/packages/schema/src/browser-control.ts new file mode 100644 index 0000000000..c4884e46f9 --- /dev/null +++ b/packages/schema/src/browser-control.ts @@ -0,0 +1,77 @@ +export * as BrowserControl from "./browser-control.js" + +import { Schema } from "effect" +import { Browser } from "./browser.js" +import { ascending } from "./identifier.js" +import { Session } from "./session.js" +import { NonNegativeInt, 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 + +export interface Attachment extends Schema.Schema.Type {} +export const Attachment = Schema.Struct({ + sessionID: Session.ID, + leaseID: Browser.LeaseID, + state: Browser.State, +}).annotate({ identifier: "BrowserControl.Attachment" }) + +export interface Ready extends Schema.Schema.Type {} +export const Ready = Schema.Struct({ + type: Schema.Literal("browser.control.ready"), +}).annotate({ identifier: "BrowserControl.Ready" }) + +export interface Sync extends Schema.Schema.Type {} +export const Sync = Schema.Struct({ + type: Schema.Literal("browser.control.sync"), + revision: NonNegativeInt, + attachments: Schema.Array(Attachment).check(Schema.isMaxLength(16)), +}).annotate({ identifier: "BrowserControl.Sync" }) + +export interface Synced extends Schema.Schema.Type {} +export const Synced = Schema.Struct({ + type: Schema.Literal("browser.control.synced"), + revision: NonNegativeInt, +}).annotate({ identifier: "BrowserControl.Synced" }) + +export interface Request extends Schema.Schema.Type {} +export const Request = Schema.Struct({ + type: Schema.Literal("browser.control.request"), + requestID: RequestID, + sessionID: Session.ID, + leaseID: Browser.LeaseID, + command: Browser.Command, +}).annotate({ identifier: "BrowserControl.Request" }) + +export interface Response extends Schema.Schema.Type {} +export const Response = Schema.Struct({ + type: Schema.Literal("browser.control.response"), + requestID: RequestID, + leaseID: Browser.LeaseID, + outcome: Browser.Outcome, +}).annotate({ identifier: "BrowserControl.Response" }) + +export interface Cancel extends Schema.Schema.Type {} +export const Cancel = Schema.Struct({ + type: Schema.Literal("browser.control.cancel"), + requestID: RequestID, + leaseID: Browser.LeaseID, +}).annotate({ identifier: "BrowserControl.Cancel" }) + +export const FromDesktop = Schema.Union([Sync, Response]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "BrowserControl.FromDesktop" }) +export type FromDesktop = typeof FromDesktop.Type + +export const FromServer = Schema.Union([Ready, Synced, 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..f1a8d5ca43 --- /dev/null +++ b/packages/schema/src/browser-tunnel.ts @@ -0,0 +1,111 @@ +export * as BrowserTunnel from "./browser-tunnel.js" + +import { Schema } from "effect" +import { Browser } from "./browser.js" +import { Session } from "./session.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 const WindowBytes = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_048_576 })) + .pipe(Schema.brand("BrowserTunnel.WindowBytes")) + .annotate({ identifier: "BrowserTunnel.WindowBytes" }) +export type WindowBytes = typeof WindowBytes.Type + +export const WindowSize = Schema.Int.check(Schema.isBetween({ minimum: 65_536, maximum: 1_048_576 })) + .pipe(Schema.brand("BrowserTunnel.WindowSize")) + .annotate({ identifier: "BrowserTunnel.WindowSize" }) +export type WindowSize = typeof WindowSize.Type + +export const FrameWindow = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 })) + .pipe(Schema.brand("BrowserTunnel.FrameWindow")) + .annotate({ identifier: "BrowserTunnel.FrameWindow" }) +export type FrameWindow = typeof FrameWindow.Type + +export interface Target extends Schema.Schema.Type {} +export const Target = Schema.Struct({ + host: Host, + port: Port, +}).annotate({ identifier: "BrowserTunnel.Target" }) + +export interface Open extends Schema.Schema.Type {} +export const Open = Schema.Struct({ + type: Schema.Literal("browser.tunnel.open"), + sessionID: Session.ID, + leaseID: Browser.LeaseID, + target: Target, + receiveWindow: WindowSize, + receiveFrames: FrameWindow, +}).annotate({ identifier: "BrowserTunnel.Open" }) + +export interface Ready extends Schema.Schema.Type {} +export const Ready = Schema.Struct({ + type: Schema.Literal("browser.tunnel.ready"), +}).annotate({ identifier: "BrowserTunnel.Ready" }) + +export interface Opened extends Schema.Schema.Type {} +export const Opened = Schema.Struct({ + type: Schema.Literal("browser.tunnel.opened"), + receiveWindow: WindowSize, + receiveFrames: FrameWindow, +}).annotate({ identifier: "BrowserTunnel.Opened" }) + +export interface Window extends Schema.Schema.Type {} +export const Window = Schema.Struct({ + type: Schema.Literal("browser.tunnel.window"), + bytes: WindowBytes, + frames: FrameWindow, +}).annotate({ identifier: "BrowserTunnel.Window" }) + +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 + +export interface Rejected extends Schema.Schema.Type {} +export 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 interface End extends Schema.Schema.Type {} +export const End = Schema.Struct({ + type: Schema.Literal("browser.tunnel.end"), +}).annotate({ identifier: "BrowserTunnel.End" }) + +export const ResetCode = Schema.Literals([ + "cancelled", + "lease_revoked", + "message_too_large", + "target_error", + "protocol_error", +]).annotate({ identifier: "BrowserTunnel.ResetCode" }) +export type ResetCode = typeof ResetCode.Type + +export interface Reset extends Schema.Schema.Type {} +export const Reset = Schema.Struct({ + type: Schema.Literal("browser.tunnel.reset"), + code: ResetCode, +}).annotate({ identifier: "BrowserTunnel.Reset" }) + +export const FromDesktop = Schema.Union([Open, Window, End, Reset]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "BrowserTunnel.FromDesktop" }) +export type FromDesktop = typeof FromDesktop.Type + +export const FromServer = Schema.Union([Ready, Opened, Rejected, Window, End, Reset]) + .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..8a47e0e954 --- /dev/null +++ b/packages/schema/src/browser.ts @@ -0,0 +1,174 @@ +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 + +export interface NavigateResult extends Schema.Schema.Type {} +export const NavigateResult = Schema.Struct({ + type: Schema.Literal("navigate"), + state: State, +}).annotate({ identifier: "Browser.NavigateResult" }) + +export interface SnapshotResult extends Schema.Schema.Type {} +export 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" }) + +export interface ClickResult extends Schema.Schema.Type {} +export const ClickResult = Schema.Struct({ + type: Schema.Literal("click"), + state: State, +}).annotate({ identifier: "Browser.ClickResult" }) + +export interface FillResult extends Schema.Schema.Type {} +export const FillResult = Schema.Struct({ + type: Schema.Literal("fill"), + state: State, +}).annotate({ identifier: "Browser.FillResult" }) + +export interface PressResult extends Schema.Schema.Type {} +export const PressResult = Schema.Struct({ + type: Schema.Literal("press"), + state: State, +}).annotate({ identifier: "Browser.PressResult" }) + +export interface ScrollResult extends Schema.Schema.Type {} +export const ScrollResult = Schema.Struct({ + type: Schema.Literal("scroll"), + state: State, +}).annotate({ identifier: "Browser.ScrollResult" }) + +export interface ScreenshotResult extends Schema.Schema.Type {} +export 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 type ResultFor = Extract + +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 + +export interface Failure extends Schema.Schema.Type {} +export const Failure = Schema.Struct({ + type: Schema.Literal("failure"), + code: ErrorCode, + message: Schema.String.check(Schema.isMaxLength(1_024)), +}).annotate({ identifier: "Browser.Failure" }) + +export interface Success extends Schema.Schema.Type {} +export 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" diff --git a/packages/schema/test/browser.test.ts b/packages/schema/test/browser.test.ts new file mode 100644 index 0000000000..0405b71f1e --- /dev/null +++ b/packages/schema/test/browser.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { Browser } from "../src/browser.js" +import { BrowserControl } from "../src/browser-control.js" +import { BrowserTunnel } from "../src/browser-tunnel.js" +import { Session } from "../src/session.js" +import { Schema } from "effect" + +const state: Browser.State = { + url: "https://example.com/", + title: "Example", + loading: false, + canGoBack: false, + canGoForward: false, + generation: 1, +} + +describe("browser contracts", () => { + test("creates exact-prefixed identifiers", () => { + expect(Browser.LeaseID.create()).toStartWith("brl_") + expect(BrowserControl.RequestID.create()).toStartWith("brr_") + expect(() => Browser.LeaseID.make("lease_invalid")).toThrow() + expect(() => BrowserControl.RequestID.make("request_invalid")).toThrow() + }) + + test("round trips browser control messages", () => { + const codec = Schema.fromJsonString(BrowserControl.FromDesktop) + const message: BrowserControl.FromDesktop = { + type: "browser.control.sync", + revision: 1, + attachments: [ + { + sessionID: Session.ID.make("ses_browser_contract"), + leaseID: Browser.LeaseID.make("brl_contract"), + state, + }, + ], + } + const encoded = Schema.encodeSync(codec)(message) + expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(message) + }) + + test("encodes screenshot bytes as base64", () => { + const codec = Schema.fromJsonString(Browser.Outcome) + const outcome: Browser.Outcome = { + type: "success", + result: { + type: "screenshot", + state, + mediaType: "image/png", + data: new Uint8Array([1, 2, 3]), + width: 10, + height: 20, + }, + } + const encoded = Schema.encodeSync(codec)(outcome) + expect(encoded).toContain('"data":"AQID"') + expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(outcome) + }) + + test("rejects invalid tunnel targets", () => { + expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "", port: 3000 })).toThrow() + expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "https://example.com", port: 443 })).toThrow() + expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "local host", port: 3000 })).toThrow() + expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "localhost", port: 0 })).toThrow() + expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "localhost", port: 65_536 })).toThrow() + }) +})