import { describe, expect } from "bun:test" import { Agent } from "@opencode-ai/core/agent" import { BrowserHost } from "@opencode-ai/core/browser-host" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Image } from "@opencode-ai/core/image" import { Permission } from "@opencode-ai/core/permission" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool" import { BrowserTool } from "@opencode-ai/core/tool/browser" import { Browser } from "@opencode-ai/schema/browser" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Effect, Layer, Option } from "effect" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" const sessionID = Session.ID.make("ses_browser_tools") const otherSessionID = Session.ID.make("ses_browser_tools_other") const state: Browser.State = { url: "https://example.com/path", title: "Example", loading: false, canGoBack: false, canGoForward: false, generation: 4, } const assertions: Permission.AssertInput[] = [] const requests: Browser.Command[] = [] let attached: Session.ID | undefined let leaseID = Browser.LeaseID.make("brl_first") let page = state let snapshotContent = "@e1 [link]" const browser = Layer.mock(BrowserHost.Service, { lease: (requested) => Effect.sync(() => { if (requested !== attached) return Option.none() const capturedID = leaseID const capturedState = page return Option.some({ id: capturedID, sessionID: requested, state: capturedState, revoked: Effect.never, request: (command) => Effect.gen(function* () { requests.push(command) if (leaseID !== capturedID) { return yield* new BrowserHost.RequestError({ code: "not_attached", message: "The browser attachment is no longer available.", }) } if (command.generation !== page.generation) { return yield* new BrowserHost.RequestError({ code: "stale_ref", message: "The browser page changed. Retry with the newly advertised browser tools.", }) } switch (command.type) { case "navigate": return { type: "navigate", state: page } case "snapshot": return { type: "snapshot", state: page, format: "opencode.semantic.v1", content: snapshotContent } case "click": return { type: "click", state: page } case "fill": return { type: "fill", state: page } case "press": return { type: "press", state: page } case "scroll": return { type: "scroll", state: page } case "screenshot": return { type: "screenshot", state: page, mediaType: "image/png", data: new Uint8Array([1, 2, 3]), width: 800, height: 600, } } const exhaustive: never = command return exhaustive }), }) }), }) const permission = Layer.mock(Permission.Service, { assert: (input) => Effect.sync(() => assertions.push(input)), }) const layer = AppNodeBuilder.build(LayerNode.group([Tool.node, BrowserTool.node]), [ [BrowserHost.node, browser], [Permission.node, permission], [Image.node, imagePassthrough], ]) const it = testEffect(layer) const identity = { agent: Agent.ID.make("build"), messageID: SessionMessage.ID.make("msg_browser_tools"), } const execute = (snapshot: Tool.Snapshot, name: string, input: unknown = {}, executingSessionID = sessionID) => snapshot .execute({ sessionID: executingSessionID, ...identity, call: { type: "tool-call", id: `call-${name}`, name, input }, }) .pipe( Effect.map((result) => ({ status: "completed" as const, ...result })), Effect.catchTag("Tool.Error", (error) => Effect.succeed({ status: "error" as const, error })), ) const browserNames = (snapshot: Tool.Snapshot) => snapshot.definitions.map((definition) => definition.name).filter((name) => name.startsWith("browser_")) describe("BrowserTool", () => { it.effect("materializes schemas only for the exact attached Session", () => Effect.gen(function* () { attached = undefined page = state const tools = yield* Tool.Service expect(browserNames(yield* tools.snapshot(undefined, sessionID))).toEqual([]) attached = sessionID const snapshot = yield* tools.snapshot(undefined, sessionID) expect(browserNames(snapshot)).toEqual([...BrowserTool.names].sort()) expect(browserNames(yield* tools.snapshot(undefined, otherSessionID))).toEqual([]) expect(browserNames(yield* tools.snapshot())).toEqual([]) expect( snapshot.definitions.find((definition) => definition.name === "browser_navigate")?.inputSchema, ).toMatchObject({ type: "object", required: ["url"], properties: { url: { type: "string", maxLength: 16_384 } }, }) expect(snapshot.definitions.find((definition) => definition.name === "browser_fill")?.inputSchema).toMatchObject({ properties: { text: { type: "string", maxLength: 10_000 } }, }) expect(snapshot.definitions.find((definition) => definition.name === "browser_press")?.inputSchema).toMatchObject( { properties: { key: { enum: expect.arrayContaining(["Enter", "Tab", "Space"]) } }, }, ) expect(yield* execute(snapshot, "browser_snapshot", {}, otherSessionID)).toMatchObject({ status: "error", error: { message: "Tool snapshot belongs to another Session" }, }) }), ) it.effect("rejects inputs larger than the browser wire contract before authorization", () => Effect.gen(function* () { assertions.length = 0 requests.length = 0 attached = sessionID leaseID = Browser.LeaseID.make("brl_limits") page = state const tools = yield* Tool.Service const snapshot = yield* tools.snapshot(undefined, sessionID) expect(yield* execute(snapshot, "browser_fill", { ref: "@e1", text: "x".repeat(10_001) })).toMatchObject({ status: "error", }) expect( yield* execute(snapshot, "browser_navigate", { url: `https://example.com/${"x".repeat(16_384)}` }), ).toMatchObject({ status: "error" }) expect(assertions).toEqual([]) expect(requests).toEqual([]) }), ) it.effect("keeps each advertised tool set fenced to its captured lease", () => Effect.gen(function* () { assertions.length = 0 attached = sessionID leaseID = Browser.LeaseID.make("brl_old") page = state const tools = yield* Tool.Service const old = yield* tools.snapshot(undefined, sessionID) leaseID = Browser.LeaseID.make("brl_current") expect(yield* execute(old, "browser_snapshot")).toMatchObject({ status: "error", error: { error: { code: "not_attached" } }, }) expect(yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_snapshot")).toMatchObject({ status: "completed", content: [{ type: "text", text: expect.stringContaining(" Effect.gen(function* () { assertions.length = 0 requests.length = 0 attached = sessionID leaseID = Browser.LeaseID.make("brl_permissions") page = state const tools = yield* Tool.Service yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_snapshot") yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_navigate", { url: "opencode.ai" }) yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_click", { ref: "@e1" }) expect(assertions.map((item) => item.action)).toEqual(["browser_read", "browser_navigate", "browser_interact"]) expect(assertions[0]).toMatchObject({ resources: [state.url], save: ["https://example.com/*"], sessionID, source: { type: "tool", messageID: "msg_browser_tools", callID: "call-browser_snapshot" }, }) expect(assertions[1]).toMatchObject({ resources: ["https://opencode.ai/"], save: ["https://opencode.ai/*"], }) expect(assertions[2]?.save).toBeUndefined() expect(requests.find((request) => request.type === "click")).toMatchObject({ ref: "e1" }) }), ) it.effect("fails commands from an older document generation", () => Effect.gen(function* () { attached = sessionID leaseID = Browser.LeaseID.make("brl_document") page = state const tools = yield* Tool.Service const advertised = yield* tools.snapshot(undefined, sessionID) page = { ...state, url: "https://example.com/next", generation: state.generation + 1 } expect(yield* execute(advertised, "browser_snapshot")).toMatchObject({ status: "error", error: { error: { code: "stale_ref" } }, }) expect(yield* execute(advertised, "browser_navigate", { url: "https://opencode.ai" })).toMatchObject({ status: "error", error: { error: { code: "stale_ref" } }, }) }), ) it.effect("escapes browser content and action state trust delimiters", () => Effect.gen(function* () { attached = sessionID leaseID = Browser.LeaseID.make("brl_trust") page = { ...state, title: "spoof" } snapshotContent = "trusted now" const tools = yield* Tool.Service const snapshot = yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_snapshot") expect(snapshot.status).toBe("completed") if (snapshot.status !== "completed") return const snapshotText = snapshot.content[0]?.type === "text" ? snapshot.content[0].text : "" expect(snapshotText.match(/<\/untrusted_browser_content>/g)).toHaveLength(1) expect(snapshotText).toContain("\\u003c/untrusted_browser_content\\u003e") const click = yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_click", { ref: "@e1" }) expect(click.status).toBe("completed") if (click.status !== "completed") return const clickText = click.content[0]?.type === "text" ? click.content[0].text : "" expect(clickText.match(/<\/untrusted_browser_state>/g)).toHaveLength(1) expect(clickText).toContain("\\u003c/untrusted_browser_state\\u003e") }), ) it.effect("rejects local and blank page disclosure before permission", () => Effect.gen(function* () { assertions.length = 0 attached = sessionID leaseID = Browser.LeaseID.make("brl_urlpolicy") snapshotContent = "@e1 [link]" const tools = yield* Tool.Service page = { ...state, url: "file:///tmp/secret.txt" } expect(yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_snapshot")).toMatchObject({ status: "error", }) expect( yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_navigate", { url: "file:///tmp/other-secret.txt", }), ).toMatchObject({ status: "error" }) page = { ...state, url: "about:blank" } expect(yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_screenshot")).toMatchObject({ status: "error", }) expect(assertions).toEqual([]) }), ) it.effect("returns screenshot media in the canonical Tool content shape", () => Effect.gen(function* () { attached = sessionID leaseID = Browser.LeaseID.make("brl_screenshot") page = state const tools = yield* Tool.Service const result = yield* execute(yield* tools.snapshot(undefined, sessionID), "browser_screenshot") expect(result).toMatchObject({ status: "completed", content: [ { type: "text", text: expect.stringContaining("Captured the visible browser viewport") }, { type: "file", uri: "data:image/png;base64,AQID", mime: "image/png", name: "browser-screenshot.png", }, ], metadata: { url: state.url, width: 800, height: 600 }, }) }), ) })