fix(browser): tighten wire contracts and file handling

This commit is contained in:
LukeParkerDev 2026-09-03 17:24:02 +10:00
parent 5c34b3da5e
commit 09223172a3
5 changed files with 70 additions and 19 deletions

View file

@ -5,7 +5,7 @@ import { Browser } from "@opencode-ai/schema/browser"
// Files cross machines as bytes. Only this endpoint interprets its local paths.
export async function read(paths: readonly string[], directory: string): Promise<Browser.File[]> {
const { open } = await import("node:fs/promises")
const { resolve, basename } = await import("node:path")
const { resolve, basename, extname } = await import("node:path")
const files = await Promise.all(
paths.map(async (input) => {
const file = await open(resolve(directory, input), "r")
@ -13,13 +13,11 @@ export async function read(paths: readonly string[], directory: string): Promise
const stat = await file.stat()
if (!stat.isFile() || stat.size > Browser.MAX_FILE_BYTES)
throw new Error("Upload must be a file no larger than 5 MiB.")
const data = new Uint8Array(Number(stat.size))
const { bytesRead } = await file.read(data, 0, data.length, 0)
return {
id: Browser.FileID.make(`file_${crypto.randomUUID()}`),
name: basename(input),
mime: "application/octet-stream",
data: data.subarray(0, bytesRead),
mime: types[extname(input).toLowerCase()] ?? "application/octet-stream",
data: new Uint8Array(await file.readFile()),
}
} finally {
await file.close()
@ -31,6 +29,22 @@ export async function read(paths: readonly string[], directory: string): Promise
return files
}
const types: Record<string, string> = {
".txt": "text/plain",
".csv": "text/csv",
".json": "application/json",
".html": "text/html",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".zip": "application/zip",
".gz": "application/gzip",
}
export async function save(files: readonly Browser.File[]): Promise<Browser.FileInfo[]> {
if (files.length === 0) return []
if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES)

View file

@ -58,7 +58,7 @@ export default Plugin.define({
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.emit("control", { type: "attached", connectionID: input.connectionID, version: 2 })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
@ -95,8 +95,16 @@ export default Plugin.define({
})
.pipe(Effect.orDie)
const execute = (operation: Browser.Operation, action: Browser.Action, tool: Tool.Context) =>
const execute = (operation: Browser.Operation, input: Browser.Action, tool: Tool.Context) =>
Effect.gen(function* () {
const action = yield* Effect.try({
try: () => normalizeAction(input),
catch: (error) =>
new Tool.Error({
message: "Invalid browser URL. Use HTTP, HTTPS, or about:blank without credentials.",
error,
}),
})
const browser = browsers.get(tool.sessionID)
if (!browser)
return yield* new Tool.Error({ message: "[browser.disconnected] No desktop browser is connected." })
@ -225,3 +233,16 @@ function requireObject(value: unknown): Record<string, unknown> {
throw new Error("Browser file output must be an object.")
return value as Record<string, unknown>
}
function normalizeAction(action: Browser.Action): Browser.Action {
if (action.type !== "navigate" && action.type !== "tabs.open") return action
if (action.type === "tabs.open" && action.url === undefined) return action
const value = action.url?.trim() || "about:blank"
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
const url = new URL(
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}`,
)
if ((url.href !== "about:blank" && !/^https?:$/.test(url.protocol)) || url.username || url.password)
throw new Error("Unsupported browser URL")
return { ...action, url: url.href }
}

View file

@ -16,13 +16,19 @@ const limit = optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum:
const timeoutMs = optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 30_000 }))).annotate({
description: "Timeout in milliseconds, 130000. Default 10000.",
})
export const TabID = Schema.String.check(Schema.isPattern(/^tab_[a-f0-9-]{36}$/)).pipe(Schema.brand("Browser.TabID"))
export const TabID = Schema.String.check(Schema.isPattern(/^tab_[a-f0-9-]{36}$/))
.pipe(Schema.brand("Browser.TabID"))
.annotate({ identifier: "Browser.TabID" })
export type TabID = typeof TabID.Type
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/)).pipe(Schema.brand("Browser.Ref"))
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 const FileID = Schema.String.check(Schema.isPattern(/^file_[a-f0-9-]{36}$/)).pipe(Schema.brand("Browser.FileID"))
export const FileID = Schema.String.check(Schema.isPattern(/^file_[a-f0-9-]{36}$/))
.pipe(Schema.brand("Browser.FileID"))
.annotate({ identifier: "Browser.FileID" })
export type FileID = typeof FileID.Type
export const tab = {
const tab = {
tabID: TabID.annotate({
description: "Exact tab ID returned by browser.tabs.open/list. Focus does not select a tool target.",
}),
@ -87,7 +93,7 @@ export const ResourceType = Schema.Literals([
"websocket",
"manifest",
"other",
])
]).annotate({ identifier: "Browser.ResourceType" })
export type ResourceType = typeof ResourceType.Type
const headers = Schema.Array(Schema.Struct({ name: short, value: text }))
export const Body = Schema.Union([
@ -99,17 +105,19 @@ export const Body = Schema.Union([
}),
]).annotate({ identifier: "Browser.Body" })
export type Body = typeof Body.Type
export const NetworkRequest = Schema.Struct({
const requestFields = {
id: short,
url: text,
method: short,
resourceType: ResourceType,
timestampMs: Schema.Finite,
state: Schema.Literals(["pending", "completed", "failed"]),
statusCode: optional(count),
durationMs: optional(Schema.Finite),
failure: optional(short),
}).annotate({ identifier: "Browser.NetworkRequest" })
}
export const NetworkRequest = Schema.Union([
Schema.Struct({ ...requestFields, state: Schema.Literal("pending") }),
Schema.Struct({ ...requestFields, state: Schema.Literal("completed"), durationMs: Schema.Finite }),
Schema.Struct({ ...requestFields, state: Schema.Literal("failed"), durationMs: Schema.Finite, failure: short }),
]).annotate({ identifier: "Browser.NetworkRequest" })
export type NetworkRequest = typeof NetworkRequest.Type
export const ConsoleEntry = Schema.Struct({
id: short,
@ -489,7 +497,7 @@ const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const request = { ...attachment, requestID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String, version: Schema.Literal(2) }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,

View file

@ -39,3 +39,11 @@ test("browser files are bounded bytes, not remote filesystem paths", () => {
}),
).toThrow()
})
test("network lifecycle and RPC version are explicit", () => {
const request = { id: "request", url: "https://example.com", method: "GET", resourceType: "document", timestampMs: 1 }
const decode = Schema.decodeUnknownSync(Browser.NetworkRequest)
expect(decode({ ...request, state: "completed", statusCode: 404, durationMs: 3 }).state).toBe("completed")
expect(() => decode({ ...request, state: "failed" })).toThrow()
expect(() => Schema.decodeUnknownSync(Browser.Control)({ type: "attached", connectionID: "old-client" })).toThrow()
})

View file

@ -95,7 +95,7 @@ const fixture = Effect.gen(function* () {
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach({ ...input, version: 2 }, { location }).pipe(Effect.forkScoped)
expect((yield* next).data).toEqual({ type: "attached", connectionID })
expect((yield* next).data).toEqual({ type: "attached", connectionID, version: 2 })
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {