refactor(browser): isolate browser lifecycle in internal plugin

This commit is contained in:
LukeParkerDev 2026-09-01 08:40:01 +10:00
parent ec394ac537
commit 81e37fc010
16 changed files with 228 additions and 110 deletions

View file

@ -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" },

View file

@ -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,

View file

@ -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.

View file

@ -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<RegistrationError>()("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<RequestError>()("BrowserHost.RequestError", {
@ -21,6 +18,7 @@ export interface Peer {
readonly request: (command: Browser.Command, leaseID: Browser.LeaseID) => Effect.Effect<Browser.Result, RequestError>
}
export interface Controller {
readonly closed: Effect.Effect<void>
readonly attach: (leaseID: Browser.LeaseID, state: Browser.State) => Effect.Effect<void, RegistrationError>
readonly state: (leaseID: Browser.LeaseID, state: Browser.State) => Effect.Effect<void, RegistrationError>
readonly detach: (leaseID: Browser.LeaseID) => Effect.Effect<void, RegistrationError>
@ -38,6 +36,8 @@ export interface Attached {
}
export type Capability = Available | Attached
export interface Interface {
readonly activate: Effect.Effect<void, never, Scope.Scope>
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
readonly register: (sessionID: Session.ID, peer: Peer) => Effect.Effect<Controller, RegistrationError, Scope.Scope>
readonly get: (sessionID: Session.ID) => Effect.Effect<Capability | undefined>
}
@ -50,11 +50,9 @@ type Registration = {
attachment?: { readonly leaseID: Browser.LeaseID; readonly revoked: Deferred.Deferred<void>; state: Browser.State }
}
export function make(
exists: (id: Session.ID) => Effect.Effect<boolean>,
deleted: Stream.Stream<Session.ID> = Stream.never,
) {
return Effect.gen(function* () {
export function make() {
return Effect.sync(() => {
let active = false
const registrations = new Map<Session.ID, Registration>()
const deferred = () => Deferred.makeUnsafe<void>()
const resolve = (value: Deferred.Deferred<void>) => 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: [] })

View file

@ -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 }),
)
}),
})

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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" },

View file

@ -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",

View file

@ -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<string, unknown> = {}, 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<Session.ID>()
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)

View file

@ -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<never, never>({
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")}`
}

View file

@ -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<void>,
) {
@ -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") {

View file

@ -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<Connection, OpenError, Scope.Scope>
}) => Effect.Effect<Connection, OpenError, Scope.Scope | BrowserHost.Service>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/server/BrowserTunnel") {}
export function make(): Effect.Effect<Interface, never, BrowserHost.Service> {
export function make(): Effect.Effect<Interface> {
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." })

View file

@ -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<BrowserHost.Controller, BrowserHost.RegistrationError>()
// 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()
}),
)
}),

View file

@ -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<AuthError, AuthServices>(
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<AuthError, AuthServices>(
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
HttpRouter.provideRequest(requestServices),
Layer.provideMerge(browserTunnel),
Layer.provideMerge(BrowserTunnelServer.layer),
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),
)