mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 12:15:59 +00:00
refactor(browser): isolate browser lifecycle in internal plugin
This commit is contained in:
parent
ec394ac537
commit
81e37fc010
16 changed files with 228 additions and 110 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
packages/core/src/plugin/browser/README.md
Normal file
21
packages/core/src/plugin/browser/README.md
Normal 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.
|
||||
|
|
@ -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: [] })
|
||||
31
packages/core/src/plugin/browser/index.ts
Normal file
31
packages/core/src/plugin/browser/index.ts
Normal 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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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." })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue