1
0
Эх сурвалжийг харах

feat(protocol): add browser contracts

LukeParkerDev 2 долоо хоног өмнө
parent
commit
a7a1b3011e

+ 79 - 0
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<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 - 0
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<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 - 0
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.",
+    }),
+  )

+ 5 - 0
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<boolean>("@opencode/HttpApiAuthorization/HeaderOnly", {
+  defaultValue: () => false,
+})
+
 export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
   error: UnauthorizedError,
 }) {}

+ 78 - 0
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

+ 55 - 0
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<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

+ 163 - 0
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<typeof State> {}
+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

+ 1 - 0
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"