From 81e37fc010e1f38432e74093e1418d2efd26ae1c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:40:01 +1000 Subject: [PATCH] refactor(browser): isolate browser lifecycle in internal plugin --- packages/core/src/agent.ts | 3 - packages/core/src/instance.ts | 2 + packages/core/src/plugin/browser/README.md | 21 ++++++ .../browser/host.ts} | 51 +++++++------- packages/core/src/plugin/browser/index.ts | 31 +++++++++ .../browser.ts => plugin/browser/tools.ts} | 27 ++------ packages/core/src/plugin/internal.ts | 6 +- packages/core/test/agent.test.ts | 3 - packages/core/test/config/agent.test.ts | 3 - packages/core/test/location-layer.test.ts | 20 +++++- .../browser.test.ts} | 44 ++++++------ packages/sdk/test/browser.test.ts | 35 +++++++++- .../server/src/browser-control-connection.ts | 11 ++- packages/server/src/browser-tunnel.ts | 8 +-- packages/server/src/handlers/browser.ts | 68 +++++++++++++++---- packages/server/src/routes.ts | 5 +- 16 files changed, 228 insertions(+), 110 deletions(-) create mode 100644 packages/core/src/plugin/browser/README.md rename packages/core/src/{browser-host.ts => plugin/browser/host.ts} (83%) create mode 100644 packages/core/src/plugin/browser/index.ts rename packages/core/src/{tool/plugin/browser.ts => plugin/browser/tools.ts} (89%) rename packages/core/test/{tool-browser.test.ts => plugin/browser.test.ts} (84%) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7f7bc02aed5..abe8eaee744 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -57,9 +57,6 @@ const layer = Layer.effect( const bus = yield* Bus.Service const global = yield* Global.Service const permissions: Info["permissions"] = [ - { action: "browser_navigate", resource: "*", effect: "ask" }, - { action: "browser_read", resource: "*", effect: "ask" }, - { action: "browser_interact", resource: "*", effect: "ask" }, { action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" }, { action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" }, { action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" }, diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 0be7ecf37ca..3414873b73c 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -23,6 +23,7 @@ import { Mcp } from "./mcp/index.js" import { Permission } from "./permission.js" import { Plugin } from "./plugin.js" import { PluginHooks } from "./plugin/hooks.js" +import { BrowserHost } from "./plugin/browser/host.js" import { InstancePlugins } from "./plugin/instance.js" import { PluginSupervisor } from "./plugin/supervisor.js" import { Worktree } from "./worktree.js" @@ -72,6 +73,7 @@ const nodes = [ AISDK.node, Plugin.node, PluginHooks.node, + BrowserHost.node, InstancePlugins.node, PluginSupervisor.node, Worktree.refreshNode, diff --git a/packages/core/src/plugin/browser/README.md b/packages/core/src/plugin/browser/README.md new file mode 100644 index 00000000000..b5862703e27 --- /dev/null +++ b/packages/core/src/plugin/browser/README.md @@ -0,0 +1,21 @@ +# Browser Plugin + +The browser feature owns its tools, context hooks, and live +attachment state here. Generic agent, session, and tool services contain no +browser policy. + +- `index.ts` registers tools and lifecycle hooks through the existing plugin + context. Everything belongs to `opencode.browser`. +- `host.ts` is an instance-scoped bridge for the plugin and server transport. It + has no Session store or Bus dependencies. Plugin activation enables it; plugin + unload revokes registrations and pending requests. +- `tools.ts` owns browser commands, leaf permission checks, and untrusted output + rendering. Permissions follow the existing agent rules and permission service, + like other built-in tools. The plugin does not add or change agent defaults. + +The server adapter owns WebSocket and TCP I/O. It validates the Session and selects +its instance before accessing the bridge. Session deletion or movement releases +the old attachment through the plugin's event subscription. + +Disable the feature with `plugins: ["-opencode.browser"]`. There are no legacy +plugin IDs, compatibility entrypoints, or process-global browser registrations. diff --git a/packages/core/src/browser-host.ts b/packages/core/src/plugin/browser/host.ts similarity index 83% rename from packages/core/src/browser-host.ts rename to packages/core/src/plugin/browser/host.ts index e118a74647b..813b95cbd7b 100644 --- a/packages/core/src/browser-host.ts +++ b/packages/core/src/plugin/browser/host.ts @@ -1,15 +1,12 @@ -export * as BrowserHost from "./browser-host.js" +export * as BrowserHost from "./host.js" import { Browser } from "@opencode-ai/schema/browser" import { Session } from "@opencode-ai/schema/session" -import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" -import { Context, Deferred, Effect, Layer, Schema, Scope, Stream } from "effect" -import { Bus } from "./bus.js" -import { SessionEvent } from "./session/event.js" -import { SessionStore } from "./session/store.js" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Context, Deferred, Effect, Layer, Schema, Scope } from "effect" export class RegistrationError extends Schema.TaggedError()("BrowserHost.RegistrationError", { - reason: Schema.Literals(["unknown_session", "already_registered", "stale_registration", "stale_lease"]), + reason: Schema.Literals(["disabled", "unknown_session", "already_registered", "stale_registration", "stale_lease"]), message: Schema.String, }) {} export class RequestError extends Schema.TaggedError()("BrowserHost.RequestError", { @@ -21,6 +18,7 @@ export interface Peer { readonly request: (command: Browser.Command, leaseID: Browser.LeaseID) => Effect.Effect } export interface Controller { + readonly closed: Effect.Effect readonly attach: (leaseID: Browser.LeaseID, state: Browser.State) => Effect.Effect readonly state: (leaseID: Browser.LeaseID, state: Browser.State) => Effect.Effect readonly detach: (leaseID: Browser.LeaseID) => Effect.Effect @@ -38,6 +36,8 @@ export interface Attached { } export type Capability = Available | Attached export interface Interface { + readonly activate: Effect.Effect + readonly release: (sessionID: Session.ID) => Effect.Effect readonly register: (sessionID: Session.ID, peer: Peer) => Effect.Effect readonly get: (sessionID: Session.ID) => Effect.Effect } @@ -50,11 +50,9 @@ type Registration = { attachment?: { readonly leaseID: Browser.LeaseID; readonly revoked: Deferred.Deferred; state: Browser.State } } -export function make( - exists: (id: Session.ID) => Effect.Effect, - deleted: Stream.Stream = Stream.never, -) { - return Effect.gen(function* () { +export function make() { + return Effect.sync(() => { + let active = false const registrations = new Map() const deferred = () => Deferred.makeUnsafe() const resolve = (value: Deferred.Deferred) => Deferred.doneUnsafe(value, Effect.void) @@ -71,12 +69,22 @@ export function make( if (current.attachment) resolve(current.attachment.revoked) }) - yield* Stream.runForEach(deleted, release).pipe(Effect.forkScoped) return Service.of({ + activate: Effect.acquireRelease( + Effect.sync(() => { + active = true + }), + () => + Effect.gen(function* () { + active = false + yield* Effect.forEach(registrations.keys(), (id) => release(id), { discard: true }) + }), + ), + release, register: Effect.fn("BrowserHost.register")(function* (id, peer) { - if (!(yield* exists(id))) return yield* invalid("unknown_session") const registration = yield* Effect.acquireRelease( Effect.suspend(() => { + if (!active) return invalid("disabled") if (registrations.has(id)) return invalid("already_registered") const current: Registration = { peer, closed: deferred(), ready: deferred() } registrations.set(id, current) @@ -92,6 +100,7 @@ export function make( return Effect.void }) return { + closed: Deferred.await(registration.closed), attach: (leaseID, state) => update(leaseID, false, () => { if (registration.attachment) resolve(registration.attachment.revoked) @@ -150,15 +159,5 @@ export function make( }) } -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionStore.Service - const bus = yield* Bus.Service - return yield* make( - (id) => sessions.get(id).pipe(Effect.map((session) => session !== undefined)), - bus.subscribe(SessionEvent.Deleted).pipe(Stream.map((event) => event.data.sessionID)), - ) - }), -) -export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, Bus.node] }) +export const layer = Layer.effect(Service, make()) +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin/browser/index.ts b/packages/core/src/plugin/browser/index.ts new file mode 100644 index 00000000000..38b98bd7833 --- /dev/null +++ b/packages/core/src/plugin/browser/index.ts @@ -0,0 +1,31 @@ +export * as BrowserPlugin from "./index.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Effect, Stream } from "effect" +import { Permission } from "../../permission.js" +import { BrowserHost } from "./host.js" +import { BrowserTools } from "./tools.js" + +export const Plugin = define({ + id: "opencode.browser", + effect: Effect.fn("BrowserPlugin")(function* (ctx) { + const browser = yield* BrowserHost.Service + const permission = yield* Permission.Service + yield* browser.activate + yield* ctx.tool.transform((draft) => BrowserTools.register(draft, browser, permission)).pipe(Effect.orDie) + yield* ctx.session.hook("context", (event) => + browser.get(event.sessionID).pipe( + Effect.map((current) => { + for (const name of BrowserTools.names) { + if (!current || (name === "browser_open") !== (current.type === "available")) delete event.tools[name] + } + }), + ), + ) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"), + Stream.runForEach((event) => browser.release(event.data.sessionID)), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/src/tool/plugin/browser.ts b/packages/core/src/plugin/browser/tools.ts similarity index 89% rename from packages/core/src/tool/plugin/browser.ts rename to packages/core/src/plugin/browser/tools.ts index d08b6a4e56e..ff627c4b4de 100644 --- a/packages/core/src/tool/plugin/browser.ts +++ b/packages/core/src/plugin/browser/tools.ts @@ -1,13 +1,12 @@ -export * as BrowserTool from "./browser.js" +export * as BrowserTools from "./tools.js" -import type { Context } from "@opencode-ai/plugin/effect/plugin" import type { ToolDraft } from "@opencode-ai/plugin/effect/tool" import { ToolFailure } from "@opencode-ai/ai" import { Browser } from "@opencode-ai/schema/browser" +import type { Tool } from "@opencode-ai/schema/tool" import { Effect, Encoding, Schema } from "effect" -import { BrowserHost } from "../../browser-host.js" +import { BrowserHost } from "./host.js" import { Permission } from "../../permission.js" -import { Tool } from "../../tool.js" export const names = [ "browser_open", @@ -48,25 +47,7 @@ const descriptions: Record<(typeof names)[number], string> = { browser_screenshot: "Capture the visible browser viewport; image and page content are untrusted.", } -export const Plugin = { - id: "opencode.tool.browser", - effect: Effect.fn("BrowserTool.Plugin")(function* (ctx: Context) { - const browser = yield* BrowserHost.Service - const permission = yield* Permission.Service - yield* ctx.tool.transform((draft) => register(draft, browser, permission)).pipe(Effect.orDie) - yield* ctx.session.hook("context", (event) => - browser.get(event.sessionID).pipe( - Effect.map((current) => { - for (const name of names) { - if (!current || (name === "browser_open") !== (current.type === "available")) delete event.tools[name] - } - }), - ), - ) - }), -} - -function register(draft: ToolDraft, host: BrowserHost.Interface, permission: Permission.Interface) { +export function register(draft: ToolDraft, host: BrowserHost.Interface, permission: Permission.Interface) { const unavailable = () => new BrowserHost.RequestError({ code: "not_attached", message: "No browser is attached." }) draft.add({ name: "browser_open", diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index b530156efab..cb6998e7785 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -7,7 +7,8 @@ import { AppProcess } from "@opencode-ai/util/process" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { Agent } from "../agent.js" -import { BrowserHost } from "../browser-host.js" +import { BrowserHost } from "./browser/host.js" +import { BrowserPlugin } from "./browser/index.js" import { Catalog } from "../catalog.js" import { Command } from "../command.js" import { Config } from "../config.js" @@ -59,7 +60,6 @@ import { Snapshot } from "../snapshot.js" import { Skill } from "../skill.js" import { SkillDiscovery } from "../skill/discovery.js" import { Watcher } from "../filesystem/watcher.js" -import { BrowserTool } from "../tool/plugin/browser.js" import { PatchTool } from "../tool/plugin/patch.js" import { EditTool } from "../tool/plugin/edit.js" import { GlobTool } from "../tool/plugin/glob.js" @@ -252,7 +252,7 @@ const pre = [ ModelsDevPlugin, ...ProviderPlugins, ...WebSearchPlugins, - BrowserTool.Plugin, + BrowserPlugin.Plugin, PatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index ae18cabf79e..1f18cbdc72a 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -150,9 +150,6 @@ describe("Agent", () => { expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual( Agent.Info.default(id).permissions, ) - for (const action of ["browser_navigate", "browser_read", "browser_interact"]) { - expect(Permission.evaluate(action, "https://example.com/", info?.permissions ?? []).effect).toBe("ask") - } expect( Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []) .effect, diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index f00378edcae..2df2cd93d35 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -25,9 +25,6 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node const decode = Schema.decodeUnknownSync(Info) const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [ ...Agent.Info.default(Agent.ID.make("test")).permissions, - { action: "browser_navigate", resource: "*", effect: "ask" }, - { action: "browser_read", resource: "*", effect: "ask" }, - { action: "browser_interact", resource: "*", effect: "ask" }, { action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" }, { action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" }, { action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" }, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index c978380ef0f..d846acccfbe 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -728,8 +728,16 @@ describe("LocationServiceMap", () => { const blockedState = yield* update(blocked.path, blockedID) expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true) expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false) - const blockedTools = blockedState.tools.map((tool) => tool.name).filter((name) => !/^browser_/.test(name)) + const blockedTools = blockedState.tools.map((tool) => tool.name) expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([ + "browser_click", + "browser_fill", + "browser_navigate", + "browser_open", + "browser_press", + "browser_screenshot", + "browser_scroll", + "browser_snapshot", "edit", "glob", "grep", @@ -746,9 +754,17 @@ describe("LocationServiceMap", () => { const allowedState = yield* update(allowed.path, allowedID) expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true) expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false) - const allowedTools = allowedState.tools.map((tool) => tool.name).filter((name) => !/^browser_/.test(name)) + const allowedTools = allowedState.tools.map((tool) => tool.name) expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute")) expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([ + "browser_click", + "browser_fill", + "browser_navigate", + "browser_open", + "browser_press", + "browser_screenshot", + "browser_scroll", + "browser_snapshot", "edit", "glob", "grep", diff --git a/packages/core/test/tool-browser.test.ts b/packages/core/test/plugin/browser.test.ts similarity index 84% rename from packages/core/test/tool-browser.test.ts rename to packages/core/test/plugin/browser.test.ts index b689c451e0c..3e57f91af08 100644 --- a/packages/core/test/tool-browser.test.ts +++ b/packages/core/test/plugin/browser.test.ts @@ -1,23 +1,23 @@ import { describe, expect } from "bun:test" -import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BrowserHost } from "@opencode-ai/core/plugin/browser/host" +import { BrowserPlugin } from "@opencode-ai/core/plugin/browser/index" +import { BrowserTools } from "@opencode-ai/core/plugin/browser/tools" 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 { Session } from "@opencode-ai/schema/session" import { Tool } from "@opencode-ai/core/tool" -import { BrowserTool } from "@opencode-ai/core/tool/plugin/browser" import { Browser } from "@opencode-ai/schema/browser" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Effect, Fiber, Layer, Queue, Stream } from "effect" -import { testEffect } from "./lib/effect" -import { imagePassthrough } from "./lib/image" -import { permissionLayer } from "./lib/permission" -import { executeTool, registerToolPlugin, toolDefinitions, toolIdentity } from "./lib/tool" +import { Effect, Fiber, Layer } from "effect" +import { testEffect } from "../lib/effect" +import { imagePassthrough } from "../lib/image" +import { permissionLayer } from "../lib/permission" +import { executeTool, registerToolPlugin, toolDefinitions, toolIdentity } from "../lib/tool" const sessionID = Session.ID.make("ses_browser_tools") const otherID = Session.ID.make("ses_browser_other") -const missingID = Session.ID.make("ses_browser_missing") const leaseID = Browser.LeaseID.make("brl_first") const replacementID = Browser.LeaseID.make("brl_second") const state: Browser.State = { @@ -47,17 +47,12 @@ const peer: BrowserHost.Peer = { }), } const browserTool = makeLocationNode({ - name: "test/browser-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(BrowserTool.Plugin)), + name: "test/browser-plugin", + layer: Layer.effectDiscard(registerToolPlugin(BrowserPlugin.Plugin)), deps: [Tool.node, BrowserHost.node, Permission.node], }) -const browserLayer = Layer.effect( - BrowserHost.Service, - BrowserHost.make(() => Effect.succeed(true)), -) const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, BrowserHost.node, browserTool]), [ - BrowserHost.node.replace(browserLayer), Permission.node.replace( permissionLayer({ assert: (input) => @@ -79,13 +74,15 @@ const call = (name: string, input: Record = {}, session = sessi }) describe("Browser", () => { - it.effect("enforces Session ownership, authoritative leases, scoped cleanup, and deletion", () => + it.effect("isolates instance state and enforces leases and scoped cleanup", () => Effect.gen(function* () { - const deleted = yield* Queue.unbounded() - const browser = yield* BrowserHost.make((id) => Effect.succeed(id !== missingID), Stream.fromQueue(deleted)) + const browser = yield* BrowserHost.make() + const sibling = yield* BrowserHost.make() expect(yield* browser.get(sessionID)).toBeUndefined() - expect((yield* browser.register(missingID, peer).pipe(Effect.flip)).reason).toBe("unknown_session") + expect((yield* browser.register(sessionID, peer).pipe(Effect.flip)).reason).toBe("disabled") + yield* browser.activate const controller = yield* browser.register(sessionID, peer) + expect(yield* sibling.get(sessionID)).toBeUndefined() expect((yield* browser.register(sessionID, peer).pipe(Effect.flip)).reason).toBe("already_registered") yield* controller.attach(leaseID, state) const previous = yield* browser.get(sessionID) @@ -99,7 +96,7 @@ describe("Browser", () => { expect(current.leaseID).toBe(replacementID) yield* Effect.scoped(browser.register(otherID, peer)) expect(yield* browser.get(otherID)).toBeUndefined() - yield* Queue.offer(deleted, sessionID) + yield* browser.release(sessionID) yield* current.revoked expect(yield* browser.get(sessionID)).toBeUndefined() expect((yield* controller.detach(replacementID).pipe(Effect.flip)).reason).toBe("stale_registration") @@ -112,7 +109,7 @@ describe("Browser", () => { denied = false const browser = yield* BrowserHost.Service const tools = yield* Tool.Service - expect((yield* toolDefinitions(tools)).length).toBe(BrowserTool.names.length + 1) + expect((yield* toolDefinitions(tools)).length).toBe(BrowserTools.names.length + 1) const controller = yield* browser.register(sessionID, peer) const opening = yield* executeTool(tools, call("browser_open")).pipe(Effect.forkChild({ startImmediately: true })) yield* controller.attach(leaseID, state) @@ -138,7 +135,8 @@ describe("Browser", () => { denied = false const browser = yield* BrowserHost.Service const tools = yield* Tool.Service - yield* (yield* browser.register(sessionID, peer)).attach(leaseID, state) + const controller = yield* browser.register(sessionID, peer) + yield* controller.attach(leaseID, state) expect((yield* executeTool(tools, call("browser_snapshot", {}, otherID))).status).toBe("error") expect((yield* executeTool(tools, call("browser_navigate", { url: "file:///secret" }))).status).toBe("error") expect(requests).toHaveLength(0) diff --git a/packages/sdk/test/browser.test.ts b/packages/sdk/test/browser.test.ts index 8f95e24a5dd..5a0d646b740 100644 --- a/packages/sdk/test/browser.test.ts +++ b/packages/sdk/test/browser.test.ts @@ -29,7 +29,11 @@ it.live("proxies HTTP and CONNECT through authenticated, Session-isolated browse port: 0, password: "browser-secret", database: { path: ":memory:" }, - config: { directory: directory.path, project: false, content: "{}" }, + config: { + directory: directory.path, + project: false, + content: JSON.stringify({ plugins: ["-*", "opencode.agent", "opencode.browser"] }), + }, fs: { filewatcher: false }, }) const headers = { Authorization: `Basic ${btoa("opencode:browser-secret")}` } @@ -139,6 +143,35 @@ it.live("proxies HTTP and CONNECT through authenticated, Session-isolated browse }), ) +it.live("rejects browser registration when the plugin is disabled", () => + Effect.gen(function* () { + const directory = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir("opencode-browser-plugin-")), + (temporary) => Effect.promise(() => temporary[Symbol.asyncDispose]()), + ) + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "browser-secret", + database: { path: ":memory:" }, + config: { + directory: directory.path, + project: false, + content: JSON.stringify({ plugins: ["-*", "opencode.agent"] }), + }, + fs: { filewatcher: false }, + }) + const client = OpenCode.make({ + baseUrl: HttpServer.formatAddress(server.address), + headers: { Authorization: `Basic ${btoa("opencode:browser-secret")}` }, + }) + const session = yield* Effect.promise(() => client.session.create({ location: { directory: directory.path } })) + yield* Effect.promise(async () => { + await expect(client.browser.register({ sessionID: session.id, open: () => undefined })).rejects.toThrow() + }) + }), +) + function proxyAuthorization(proxy: BrowserProxy) { return `Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}` } diff --git a/packages/server/src/browser-control-connection.ts b/packages/server/src/browser-control-connection.ts index ef0d73af25e..cdb204610c5 100644 --- a/packages/server/src/browser-control-connection.ts +++ b/packages/server/src/browser-control-connection.ts @@ -1,6 +1,6 @@ export * as BrowserControlConnection from "./browser-control-connection" -import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BrowserHost } from "@opencode-ai/core/plugin/browser/host" import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control" import { Browser } from "@opencode-ai/schema/browser" import { BrowserControl } from "@opencode-ai/schema/browser-control" @@ -8,7 +8,7 @@ import { Deferred, Effect } from "effect" import { Socket } from "effect/unstable/socket" export const run = Effect.fn("BrowserControlConnection.run")(function* ( - browser: BrowserHost.Interface, + register: BrowserHost.Interface["register"], socket: Socket.Socket, opened: Effect.Effect, ) { @@ -70,7 +70,12 @@ export const run = Effect.fn("BrowserControlConnection.run")(function* ( if (message.type !== "browser.control.register") { return yield* Effect.fail(new Error("Expected browser registration.")) } - controller = yield* browser.register(message.sessionID, peer) + controller = yield* register(message.sessionID, peer) + yield* controller.closed.pipe( + Effect.andThen(write(new Socket.CloseEvent(1000, "Browser control registration released"))), + Effect.catch(() => Effect.void), + Effect.forkScoped, + ) return yield* send({ type: "browser.control.registered" }) } if (message.type === "browser.control.register") { diff --git a/packages/server/src/browser-tunnel.ts b/packages/server/src/browser-tunnel.ts index 561a38a7555..e49b9360f7d 100644 --- a/packages/server/src/browser-tunnel.ts +++ b/packages/server/src/browser-tunnel.ts @@ -1,6 +1,6 @@ export * as BrowserTunnelServer from "./browser-tunnel" -import { BrowserHost } from "@opencode-ai/core/browser-host" +import { BrowserHost } from "@opencode-ai/core/plugin/browser/host" import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel" import type { Browser } from "@opencode-ai/schema/browser" import type { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" @@ -25,16 +25,16 @@ export interface Interface { readonly sessionID: Session.ID readonly leaseID: Browser.LeaseID readonly target: BrowserTunnel.Target - }) => Effect.Effect + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/server/BrowserTunnel") {} -export function make(): Effect.Effect { +export function make(): Effect.Effect { return Effect.gen(function* () { - const browser = yield* BrowserHost.Service const active = yield* SynchronizedRef.make(0) const open: Interface["open"] = Effect.fn("BrowserTunnel.open")(function* (input) { + const browser = yield* BrowserHost.Service const capability = yield* browser.get(input.sessionID) if (!capability || capability.type !== "attached") { return yield* new OpenError({ status: 404, message: "No browser is attached to this Session." }) diff --git a/packages/server/src/handlers/browser.ts b/packages/server/src/handlers/browser.ts index f1492180fea..0bdae64cfda 100644 --- a/packages/server/src/handlers/browser.ts +++ b/packages/server/src/handlers/browser.ts @@ -1,10 +1,12 @@ -import { BrowserHost } from "@opencode-ai/core/browser-host" +import { Instance } from "@opencode-ai/core/instance/service" +import { BrowserHost } from "@opencode-ai/core/plugin/browser/host" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service" +import { Session } from "@opencode-ai/core/session" import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control" import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel" import { Browser } from "@opencode-ai/schema/browser" import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel" -import { Session } from "@opencode-ai/schema/session" -import { Effect, Option, Result, Schema } from "effect" +import { Deferred, Effect, Option, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -18,9 +20,35 @@ const decodeTunnel = Schema.decodeUnknownOption( export const BrowserHandler = HttpApiBuilder.group(Api, "server.browser", (handlers) => Effect.gen(function* () { - const browser = yield* BrowserHost.Service + const sessions = yield* Session.Service + const instances = yield* Instance.Service const tunnels = yield* BrowserTunnelServer.Service const cors = yield* CorsConfig + const register: BrowserHost.Interface["register"] = Effect.fn("BrowserHandler.register")(function* (id, peer) { + const session = yield* sessions + .get(id) + .pipe( + Effect.mapError( + () => new BrowserHost.RegistrationError({ reason: "unknown_session", message: "Session not found." }), + ), + ) + const ready = yield* Deferred.make() + // Retain the Location while the socket owns its registration. + yield* Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + const browser = yield* BrowserHost.Service + const controller = yield* browser.register(id, peer) + yield* Deferred.succeed(ready, controller) + yield* controller.closed + }).pipe( + Effect.scoped, + instances.provide(session), + Effect.catchCause((cause) => Deferred.failCause(ready, cause)), + Effect.forkScoped, + ) + return yield* Deferred.await(ready) + }) return handlers .handleRaw( @@ -30,7 +58,7 @@ export const BrowserHandler = HttpApiBuilder.group(Api, "server.browser", (handl if (rejected) return rejected const socket = yield* Effect.orDie(ctx.request.upgrade) yield* BrowserControlConnection.run( - browser, + register, socket, Effect.sync(() => markUpgraded(ctx.request)), ) @@ -54,14 +82,30 @@ export const BrowserHandler = HttpApiBuilder.group(Api, "server.browser", (handl ) : undefined if (!input) return HttpServerResponse.empty({ status: 400 }) - const connection = yield* tunnels.open(input).pipe(Effect.result) - if (Result.isFailure(connection)) return HttpServerResponse.empty({ status: connection.failure.status }) - const socket = yield* Effect.orDie(ctx.request.upgrade) - yield* connection.success.relay( - socket, - Effect.sync(() => markUpgraded(ctx.request)), + return yield* Effect.gen(function* () { + const session = yield* sessions + .get(input.sessionID) + .pipe( + Effect.mapError( + () => new BrowserTunnelServer.OpenError({ status: 404, message: "Session not found." }), + ), + ) + return yield* Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + const connection = yield* tunnels.open(input) + const socket = yield* Effect.orDie(ctx.request.upgrade) + yield* connection.relay( + socket, + Effect.sync(() => markUpgraded(ctx.request)), + ) + return HttpServerResponse.empty() + }).pipe(instances.provide(session)) + }).pipe( + Effect.catchTag("BrowserTunnel.OpenError", (error) => + Effect.succeed(HttpServerResponse.empty({ status: error.status })), + ), ) - return HttpServerResponse.empty() }), ) }), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c3b8b3b7e0a..ef4a3d9e716 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -5,7 +5,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Bus } from "@opencode-ai/core/bus" -import { BrowserHost } from "@opencode-ai/core/browser-host" import { EventLogger } from "@opencode-ai/core/event-logger" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Credential } from "@opencode-ai/core/credential" @@ -53,7 +52,6 @@ const applicationServiceNodes = [ Global.node, Database.node, Bus.node, - BrowserHost.node, EventLogger.node, httpClient, Job.node, @@ -146,7 +144,6 @@ function makeRoutes( return serviceLayer.pipe( Layer.flatMap((context) => { const services = Layer.succeedContext(context) - const browserTunnel = BrowserTunnelServer.layer.pipe(Layer.provide(services)) const requestServices = Layer.merge( Layer.succeedContext( Context.pick(Database.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context), @@ -162,7 +159,7 @@ function makeRoutes( Layer.provide(schemaErrorLayer), Layer.provide(auth), HttpRouter.provideRequest(requestServices), - Layer.provideMerge(browserTunnel), + Layer.provideMerge(BrowserTunnelServer.layer), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), )