mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 09:44:38 +00:00
fix(browser): tighten wire contracts and file handling
This commit is contained in:
parent
5c34b3da5e
commit
09223172a3
5 changed files with 70 additions and 19 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue