diff --git a/packages/plugin-browser/src/files.ts b/packages/plugin-browser/src/files.ts index ea2729e4054..c7c8cb8578d 100644 --- a/packages/plugin-browser/src/files.ts +++ b/packages/plugin-browser/src/files.ts @@ -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 { 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 = { + ".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 { if (files.length === 0) return [] if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES) diff --git a/packages/plugin-browser/src/index.ts b/packages/plugin-browser/src/index.ts index a64e8bcf402..1dcd9ff991e 100644 --- a/packages/plugin-browser/src/index.ts +++ b/packages/plugin-browser/src/index.ts @@ -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 { throw new Error("Browser file output must be an object.") return value as Record } + +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 } +} diff --git a/packages/schema/src/browser.ts b/packages/schema/src/browser.ts index faf0da2562c..c387cc649b5 100644 --- a/packages/schema/src/browser.ts +++ b/packages/schema/src/browser.ts @@ -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, 1–30000. 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, diff --git a/packages/schema/test/browser.test.ts b/packages/schema/test/browser.test.ts index a7d2af8a3a5..89c9a98608e 100644 --- a/packages/schema/test/browser.test.ts +++ b/packages/schema/test/browser.test.ts @@ -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() +}) diff --git a/packages/sdk/test/browser-plugin.test.ts b/packages/sdk/test/browser-plugin.test.ts index 998c2dc02d7..292ee0730db 100644 --- a/packages/sdk/test/browser-plugin.test.ts +++ b/packages/sdk/test/browser-plugin.test.ts @@ -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) {