diff --git a/bun.lock b/bun.lock index c44d8ac61c1..e9d51cf53af 100644 --- a/bun.lock +++ b/bun.lock @@ -106,20 +106,6 @@ "vite-plugin-solid": "2.11.14", }, }, - "packages/browser": { - "name": "@opencode-ai/browser", - "version": "0.0.0", - "devDependencies": { - "@opencode-ai/sdk": "workspace:*", - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - }, - "peerDependencies": { - "@opencode-ai/plugin": "workspace:*", - "effect": "catalog:", - }, - }, "packages/cli": { "name": "@opencode-ai/cli", "version": "1.18.4", @@ -2119,8 +2105,6 @@ "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], - "@opencode-ai/browser": ["@opencode-ai/browser@workspace:packages/browser"], - "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], diff --git a/packages/browser/package.json b/packages/browser/package.json deleted file mode 100644 index 6dc229f6204..00000000000 --- a/packages/browser/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@opencode-ai/browser", - "version": "0.0.0", - "type": "module", - "license": "MIT", - "exports": { - ".": "./src/index.ts", - "./rpc": "./src/rpc.ts" - }, - "files": [ - "src" - ], - "scripts": { - "test": "bun test --timeout 15000", - "typecheck": "tsgo --noEmit" - }, - "peerDependencies": { - "@opencode-ai/plugin": "workspace:*", - "effect": "catalog:" - }, - "devDependencies": { - "@opencode-ai/sdk": "workspace:*", - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:" - } -} diff --git a/packages/browser/test/plugin.test.ts b/packages/browser/test/plugin.test.ts deleted file mode 100644 index 142361dbf96..00000000000 --- a/packages/browser/test/plugin.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { expect, test } from "bun:test" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import plugin from "@opencode-ai/browser" -import { Browser } from "@opencode-ai/browser/rpc" -import { Agent, Rpc, Tool } from "@opencode-ai/plugin/effect" -import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect" -import { Effect, Fiber, Queue, Stream } from "effect" -import { tmpdirScoped } from "../../core/test/fixture/tmpdir" - -const state: Browser.State = { - url: "https://example.com/", - title: "Example", - loading: false, - canGoBack: false, - canGoForward: false, - generation: 7, -} - -const fixture = Effect.gen(function* () { - const directory = yield* tmpdirScoped("opencode-browser-") - const config = path.join(directory.path, "config") - yield* Effect.promise(() => mkdir(config)) - const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) }) - const opencode = yield* OpenCode.create({ - database: { path: ":memory:" }, - config: { - directory: config, - project: false, - content: JSON.stringify({ permissions: [{ action: "browser", resource: "*", effect: "allow" }] }), - }, - models: { fetch: false }, - fs: { filewatcher: false, fff: false }, - }) - const captured = Promise.withResolvers() - const permissions: Array<{ action: string; resources: readonly string[] }> = [] - yield* opencode.plugin(plugin) - yield* opencode.plugin({ - id: "browser-test-observer", - effect: (ctx) => - Effect.gen(function* () { - // Inspect the real tool through the public draft, without replacing its executor. - yield* ctx.tool.transform((draft) => { - const tool = draft.get("browser") - if (tool && ctx.location.directory === location.directory) captured.resolve(tool) - }) - yield* ctx.permission.hook("evaluate", (event) => - Effect.sync(() => permissions.push({ action: event.action, resources: event.resources })), - ) - }).pipe(Effect.orDie), - }) - yield* opencode.plugin.list({ location }) - const tool = yield* Effect.promise(() => captured.promise) - const session = yield* opencode.sessions.create({ location }) - const rpc = opencode.rpc(Browser.Definition) - const events = yield* Queue.unbounded>() - yield* rpc.events.subscribe("control").pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped({ startImmediately: true }), - ) - // RPC and native subscriptions share one stream; connected is the readiness barrier. - yield* opencode.events.subscribe().pipe( - Stream.filter((event) => event.type === "server.connected"), - Stream.runHead, - Effect.timeout("5 seconds"), - ) - const next = Queue.take(events).pipe(Effect.timeout("5 seconds")) - const execute = (action: Browser.Action) => - tool.execute(action, { - sessionID: session.id, - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.create(), - id: Tool.CallID.make(crypto.randomUUID()), - progress: () => Effect.void, - }) - return { - opencode, - location, - rpc, - permissions, - execute, - next, - attach: Effect.fn(function* (connectionID: string) { - const input = { sessionID: session.id, connectionID } - const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped) - expect(yield* next).toMatchObject({ - type: "rpc.experimental.browser.control", - location, - data: { type: "attached", connectionID }, - }) - expect(lifetime.pollUnsafe()).toBeUndefined() - return { input, lifetime } - }), - command: Effect.fn(function* (action: Browser.Action) { - const pending = yield* execute(action).pipe(Effect.forkScoped) - const event = yield* next.pipe( - Effect.raceFirst( - Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))), - ), - ) - expect(event.location).toEqual(location) - if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`) - expect(event.data.command.action).toEqual(action) - return { ...event.data, pending } - }), - } -}) - -test("attachment ownership, cancellation, and plugin unload release pending browser work", () => - Effect.gen(function* () { - const host = yield* fixture - const options = { location: host.location } - expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({ - message: "No desktop browser is connected.", - }) - const attached = yield* host.attach("first") - expect( - yield* host.rpc.attach({ ...attached.input, connectionID: "duplicate" }, options).pipe(Effect.flip), - ).toMatchObject({ type: "unavailable" }) - const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) }) - yield* Effect.promise(() => mkdir(other.directory)) - yield* host.opencode.plugin.list({ location: other }) - expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({ - type: "unavailable", - message: "Session belongs to another location.", - }) - expect( - yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip), - ).toMatchObject({ type: "unavailable" }) - yield* host.rpc.state({ ...attached.input, state }, options) - yield* host.rpc.state({ ...attached.input, state: null }, options) - expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({ - message: "Open the browser first.", - }) - - const cancelled = yield* host.command({ type: "open" }) - expect(cancelled.command.generation).toBe(0) - yield* Fiber.interrupt(cancelled.pending) - expect((yield* host.next).data).toEqual({ - type: "cancel", - connectionID: attached.input.connectionID, - requestID: cancelled.requestID, - }) - // A reply to an interrupted request is harmless while its connection is still attached. - yield* host.rpc.result( - { ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } }, - options, - ) - const closing = yield* host.command({ type: "open" }) - yield* Fiber.interrupt(attached.lifetime) - expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({ - message: "Browser connection closed.", - }) - expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({ - type: "unavailable", - }) - - const replacement = yield* host.attach("replacement") - const pending = yield* host.command({ type: "open" }) - expect(pending.connectionID).toBe("replacement") - expect(pending.command.generation).toBe(0) - expect( - yield* host.rpc - .result( - { - ...attached.input, - requestID: pending.requestID, - outcome: { type: "success", result: { type: "state", state } }, - }, - options, - ) - .pipe(Effect.flip), - ).toMatchObject({ type: "unavailable" }) - expect(pending.pending.pollUnsafe()).toBeUndefined() - - // Replacing the SDK registration unloads the production plugin through its normal lifecycle. - yield* host.opencode.plugin({ id: plugin.id, effect: () => Effect.void }) - yield* host.opencode.plugin.list(options) - expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({ - message: "Browser connection closed.", - }) - yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds")) - expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({ - type: "rpc.unavailable", - }) - expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({ - message: "No desktop browser is connected.", - }) - }).pipe(Effect.scoped, Effect.runPromise)) - -test("commands use published state and permissions, and RPC results render text and screenshot bytes", () => - Effect.gen(function* () { - const host = yield* fixture - const options = { location: host.location } - const attached = yield* host.attach("renderer") - const open = yield* host.command({ type: "open" }) - yield* host.rpc.result( - { ...attached.input, requestID: open.requestID, outcome: { type: "success", result: { type: "state", state } } }, - options, - ) - expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url }) - expect(host.permissions).toEqual([]) - yield* host.rpc.state({ ...attached.input, state }, options) - - const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" }) - expect(navigate.command.generation).toBe(7) - const updated = { ...state, url: "https://example.org/next", generation: 8 } - yield* host.rpc.result( - { - ...attached.input, - requestID: navigate.requestID, - outcome: { type: "success", result: { type: "state", state: updated } }, - }, - options, - ) - yield* Fiber.join(navigate.pending) - yield* host.rpc.state({ ...attached.input, state: updated }, options) - const snapshot = yield* host.command({ type: "snapshot" }) - expect(snapshot.command.generation).toBe(8) - yield* host.rpc.result( - { - ...attached.input, - requestID: snapshot.requestID, - outcome: { - type: "success", - result: { type: "snapshot", state: updated, content: "&" }, - }, - }, - options, - ) - const text = yield* Fiber.join(snapshot.pending) - expect(text.metadata).toEqual({ url: updated.url }) - expect(text.content).toContain('encoding="json"') - expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026") - - const screenshot = yield* host.command({ type: "screenshot" }) - const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=" - yield* host.rpc.result( - { - ...attached.input, - requestID: screenshot.requestID, - outcome: { type: "success", result: { type: "screenshot", state: updated, data } }, - }, - options, - ) - expect(yield* Fiber.join(screenshot.pending)).toEqual({ - content: [ - { type: "text", text: "Untrusted browser screenshot." }, - { type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" }, - ], - metadata: { url: updated.url }, - }) - expect(host.permissions).toEqual([ - { action: "browser", resources: [updated.url] }, - { action: "browser", resources: [updated.url] }, - { action: "browser", resources: [updated.url] }, - ]) - const failure = yield* host.command({ type: "snapshot" }) - yield* host.rpc.result( - { ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } }, - options, - ) - expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" }) - }).pipe(Effect.scoped, Effect.runPromise)) diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json deleted file mode 100644 index bf7b16714b7..00000000000 --- a/packages/browser/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@tsconfig/bun/tsconfig.json", - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "strict": true, - "noUncheckedIndexedAccess": false, - "noEmit": true - }, - "include": ["src", "test"] -} diff --git a/packages/browser/README.md b/packages/core/src/plugin/browser/README.md similarity index 79% rename from packages/browser/README.md rename to packages/core/src/plugin/browser/README.md index d3e98f3eb89..a07c2a6250d 100644 --- a/packages/browser/README.md +++ b/packages/core/src/plugin/browser/README.md @@ -1,13 +1,14 @@ # Experimental Browser Plugin -The server-side browser tool is a standalone Effect plugin. It uses only the public -plugin API and keeps its RPC contract in `@opencode-ai/browser/rpc`. +The server-side browser tool lives alongside the other built-in plugins. Its +implementation uses only the public plugin API, public schemas, and Effect. The +shared RPC contract is `@opencode-ai/schema/browser`; desktop clients do not import Core. -Load the workspace package with normal plugin configuration: +Disable it through normal plugin configuration: ```jsonc { - "plugins": ["./packages/browser"], + "plugins": ["-opencode.browser"], } ``` diff --git a/packages/browser/src/index.ts b/packages/core/src/plugin/browser/index.ts similarity index 99% rename from packages/browser/src/index.ts rename to packages/core/src/plugin/browser/index.ts index b8b2454cd84..4b8f2ca7f52 100644 --- a/packages/browser/src/index.ts +++ b/packages/core/src/plugin/browser/index.ts @@ -1,7 +1,7 @@ import { Plugin, Session, Tool } from "@opencode-ai/plugin/effect" import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc" import { Deferred, Effect, Encoding, Stream } from "effect" -import { Browser } from "./rpc.js" +import { Browser } from "@opencode-ai/schema/browser" type Attachment = { connectionID: string diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 7ade26c8c43..50e232d5570 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -74,6 +74,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js" import { WellKnown } from "../wellknown.js" import { WriteTool } from "../tool/plugin/write.js" import { AgentPlugin } from "./agent.js" +import BrowserPlugin from "./browser/index.js" import { CommandPlugin } from "./command.js" import { PlanPlugin } from "./plan.js" import { ModelsDevPlugin } from "./models-dev.js" @@ -230,6 +231,7 @@ export const requirements = LayerNode.group([ export type InternalPlugin = Plugin const pre = [ + BrowserPlugin, ConfigMcpPlugin.Plugin, McpCodeModeExclusionPlugin.Plugin, WellKnownPlugin.Plugin, diff --git a/packages/browser/src/rpc.ts b/packages/schema/src/browser.ts similarity index 83% rename from packages/browser/src/rpc.ts rename to packages/schema/src/browser.ts index 9e885789248..cd1936e3637 100644 --- a/packages/browser/src/rpc.ts +++ b/packages/schema/src/browser.ts @@ -1,14 +1,15 @@ -export * as Browser from "./rpc.js" +export * as Browser from "./browser.js" -import { Session } from "@opencode-ai/plugin/effect" -import { Rpc } from "@opencode-ai/plugin/rpc" import { Schema } from "effect" +import { Rpc } from "./rpc.js" +import { Session } from "./session.js" 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 {} export const State = Schema.Struct({ url: Schema.String.check(Schema.isMaxLength(16_384)), title: Schema.String.check(Schema.isMaxLength(1_024)), @@ -16,8 +17,7 @@ export const State = Schema.Struct({ canGoBack: Schema.Boolean, canGoForward: Schema.Boolean, generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), -}) -export type State = typeof State.Type +}).annotate({ identifier: "Browser.State" }) export const Key = Schema.Literals([ "Enter", @@ -34,9 +34,9 @@ export const Key = Schema.Literals([ "Home", "End", "Space", -]) +]).annotate({ identifier: "Browser.Key" }) export type Key = typeof Key.Type -export const Direction = Schema.Literals(["up", "down", "left", "right"]) +export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" }) export type Direction = typeof Direction.Type export const Action = Schema.Union([ @@ -56,11 +56,13 @@ export const Action = Schema.Union([ direction: Direction, pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)), }), -]) +]).annotate({ identifier: "Browser.Action" }) export type Action = typeof Action.Type -export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }) -export type Command = typeof Command.Type +export interface Command extends Schema.Schema.Type {} +export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({ + identifier: "Browser.Command", +}) export const Result = Schema.Union([ Schema.Struct({ type: Schema.Literal("state"), state: State }), Schema.Struct({ @@ -78,12 +80,16 @@ export const Result = Schema.Union([ state: State, data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)), }), -]).pipe(Schema.toTaggedUnion("type")) +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Result" }) export type Result = typeof Result.Type export const Outcome = Schema.Union([ Schema.Struct({ type: Schema.Literal("success"), result: Result }), Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }), -]).pipe(Schema.toTaggedUnion("type")) +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Outcome" }) export type Outcome = typeof Outcome.Type const attachment = { sessionID: Session.ID, connectionID: Schema.String } @@ -97,7 +103,9 @@ export const Control = Schema.Union([ command: Command, }), Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }), -]).pipe(Schema.toTaggedUnion("type")) +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Browser.Control" }) export type Control = typeof Control.Type export const Definition = Rpc.define({ diff --git a/packages/sdk/test/browser-plugin.test.ts b/packages/sdk/test/browser-plugin.test.ts new file mode 100644 index 00000000000..055b8a09032 --- /dev/null +++ b/packages/sdk/test/browser-plugin.test.ts @@ -0,0 +1,279 @@ +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import plugin from "@opencode-ai/core/plugin/browser/index" +import { Browser } from "@opencode-ai/schema/browser" +import { Agent, Rpc, Tool } from "@opencode-ai/plugin/effect" +import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect" +import { Effect, Fiber, Queue, Stream } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" + +const state: Browser.State = { + url: "https://example.com/", + title: "Example", + loading: false, + canGoBack: false, + canGoForward: false, + generation: 7, +} + +const fixture = Effect.gen(function* () { + const directory = yield* tmpdirScoped("opencode-browser-") + const config = path.join(directory.path, "config") + yield* Effect.promise(() => mkdir(config)) + const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) }) + const opencode = yield* OpenCode.create({ + database: { path: ":memory:" }, + config: { + directory: config, + project: false, + content: JSON.stringify({ + plugins: ["-opencode.browser"], + permissions: [{ action: "browser", resource: "*", effect: "allow" }], + }), + }, + models: { fetch: false }, + fs: { filewatcher: false, fff: false }, + }) + const captured = Promise.withResolvers() + const permissions: Array<{ action: string; resources: readonly string[] }> = [] + yield* opencode.plugin({ ...plugin, id: "browser-test" }) + yield* opencode.plugin({ + id: "browser-test-observer", + effect: (ctx) => + Effect.gen(function* () { + // Inspect the real tool through the public draft, without replacing its executor. + yield* ctx.tool.transform((draft) => { + const tool = draft.get("browser") + if (tool && ctx.location.directory === location.directory) captured.resolve(tool) + }) + yield* ctx.permission.hook("evaluate", (event) => + Effect.sync(() => permissions.push({ action: event.action, resources: event.resources })), + ) + }).pipe(Effect.orDie), + }) + yield* opencode.plugin.list({ location }) + const tool = yield* Effect.promise(() => captured.promise) + const session = yield* opencode.sessions.create({ location }) + const rpc = opencode.rpc(Browser.Definition) + const events = yield* Queue.unbounded>() + yield* rpc.events.subscribe("control").pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped({ startImmediately: true }), + ) + // RPC and native subscriptions share one stream; connected is the readiness barrier. + yield* opencode.events.subscribe().pipe( + Stream.filter((event) => event.type === "server.connected"), + Stream.runHead, + Effect.timeout("5 seconds"), + ) + const next = Queue.take(events).pipe(Effect.timeout("5 seconds")) + const execute = (action: Browser.Action) => + tool.execute(action, { + sessionID: session.id, + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.create(), + id: Tool.CallID.make(crypto.randomUUID()), + progress: () => Effect.void, + }) + return { + opencode, + location, + rpc, + permissions, + execute, + next, + attach: Effect.fn(function* (connectionID: string) { + const input = { sessionID: session.id, connectionID } + const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped) + expect(yield* next).toMatchObject({ + type: "rpc.experimental.browser.control", + location, + data: { type: "attached", connectionID }, + }) + expect(lifetime.pollUnsafe()).toBeUndefined() + return { input, lifetime } + }), + command: Effect.fn(function* (action: Browser.Action) { + const pending = yield* execute(action).pipe(Effect.forkScoped) + const event = yield* next.pipe( + Effect.raceFirst( + Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))), + ), + ) + expect(event.location).toEqual(location) + if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`) + expect(event.data.command.action).toEqual(action) + return { ...event.data, pending } + }), + } +}) + +test( + "attachment ownership, cancellation, and plugin unload release pending browser work", + () => + Effect.gen(function* () { + const host = yield* fixture + const options = { location: host.location } + expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({ + message: "No desktop browser is connected.", + }) + const attached = yield* host.attach("first") + expect( + yield* host.rpc.attach({ ...attached.input, connectionID: "duplicate" }, options).pipe(Effect.flip), + ).toMatchObject({ type: "unavailable" }) + const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) }) + yield* Effect.promise(() => mkdir(other.directory)) + yield* host.opencode.plugin.list({ location: other }) + expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({ + type: "unavailable", + message: "Session belongs to another location.", + }) + expect( + yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip), + ).toMatchObject({ type: "unavailable" }) + yield* host.rpc.state({ ...attached.input, state }, options) + yield* host.rpc.state({ ...attached.input, state: null }, options) + expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({ + message: "Open the browser first.", + }) + + const cancelled = yield* host.command({ type: "open" }) + expect(cancelled.command.generation).toBe(0) + yield* Fiber.interrupt(cancelled.pending) + expect((yield* host.next).data).toEqual({ + type: "cancel", + connectionID: attached.input.connectionID, + requestID: cancelled.requestID, + }) + // A reply to an interrupted request is harmless while its connection is still attached. + yield* host.rpc.result( + { ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } }, + options, + ) + const closing = yield* host.command({ type: "open" }) + yield* Fiber.interrupt(attached.lifetime) + expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({ + message: "Browser connection closed.", + }) + expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({ + type: "unavailable", + }) + + const replacement = yield* host.attach("replacement") + const pending = yield* host.command({ type: "open" }) + expect(pending.connectionID).toBe("replacement") + expect(pending.command.generation).toBe(0) + expect( + yield* host.rpc + .result( + { + ...attached.input, + requestID: pending.requestID, + outcome: { type: "success", result: { type: "state", state } }, + }, + options, + ) + .pipe(Effect.flip), + ).toMatchObject({ type: "unavailable" }) + expect(pending.pending.pollUnsafe()).toBeUndefined() + + // Replacing the SDK registration unloads the production plugin through its normal lifecycle. + yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void }) + yield* host.opencode.plugin.list(options) + expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({ + message: "Browser connection closed.", + }) + yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds")) + expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({ + type: "rpc.unavailable", + }) + expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({ + message: "No desktop browser is connected.", + }) + }).pipe(Effect.scoped, Effect.runPromise), + 15_000, +) + +test( + "commands use published state and permissions, and RPC results render text and screenshot bytes", + () => + Effect.gen(function* () { + const host = yield* fixture + const options = { location: host.location } + const attached = yield* host.attach("renderer") + const open = yield* host.command({ type: "open" }) + yield* host.rpc.result( + { + ...attached.input, + requestID: open.requestID, + outcome: { type: "success", result: { type: "state", state } }, + }, + options, + ) + expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url }) + expect(host.permissions).toEqual([]) + yield* host.rpc.state({ ...attached.input, state }, options) + + const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" }) + expect(navigate.command.generation).toBe(7) + const updated = { ...state, url: "https://example.org/next", generation: 8 } + yield* host.rpc.result( + { + ...attached.input, + requestID: navigate.requestID, + outcome: { type: "success", result: { type: "state", state: updated } }, + }, + options, + ) + yield* Fiber.join(navigate.pending) + yield* host.rpc.state({ ...attached.input, state: updated }, options) + const snapshot = yield* host.command({ type: "snapshot" }) + expect(snapshot.command.generation).toBe(8) + yield* host.rpc.result( + { + ...attached.input, + requestID: snapshot.requestID, + outcome: { + type: "success", + result: { type: "snapshot", state: updated, content: "&" }, + }, + }, + options, + ) + const text = yield* Fiber.join(snapshot.pending) + expect(text.metadata).toEqual({ url: updated.url }) + expect(text.content).toContain('encoding="json"') + expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026") + + const screenshot = yield* host.command({ type: "screenshot" }) + const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=" + yield* host.rpc.result( + { + ...attached.input, + requestID: screenshot.requestID, + outcome: { type: "success", result: { type: "screenshot", state: updated, data } }, + }, + options, + ) + expect(yield* Fiber.join(screenshot.pending)).toEqual({ + content: [ + { type: "text", text: "Untrusted browser screenshot." }, + { type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" }, + ], + metadata: { url: updated.url }, + }) + expect(host.permissions).toEqual([ + { action: "browser", resources: [updated.url] }, + { action: "browser", resources: [updated.url] }, + { action: "browser", resources: [updated.url] }, + ]) + const failure = yield* host.command({ type: "snapshot" }) + yield* host.rpc.result( + { ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } }, + options, + ) + expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" }) + }).pipe(Effect.scoped, Effect.runPromise), + 15_000, +)