Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions packages/bcode-browser/src/browser-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
// `{log, error, warn, info}` API as the real console.
// standard JS globals.
//
// Nothing is auto-loaded. To reuse code from a previous snippet the agent
// writes plain `await import("/abs/path/foo.ts?t=" + Date.now())` against a
// `.ts` file it owns under `<projectDir>/.bcode/agent-workspace/`. Same
// mechanism for a 5-line wrapper and a 500-line scrape script. The Level-2
// wrapper supplies `ctx.workspaceDir` so `.ts` files written under it can be
// addressed by absolute path; this resolver creates the dir on first use.
// When BU_CDP_WS or BU_CDP_URL binds the process to a provisioned browser,
// the tool ensures the process-scoped Session is connected before running a
// snippet. Local sessions without either variable keep explicit-connect
// behavior. Nothing else is auto-loaded. To reuse code from a previous
// snippet the agent writes plain
// `await import("/abs/path/foo.ts?t=" + Date.now())` against a `.ts` file it
// owns under `<projectDir>/.bcode/agent-workspace/`. Same mechanism for a
// 5-line wrapper and a 500-line scrape script. The Level-2 wrapper supplies
// `ctx.workspaceDir` so `.ts` files written under it can be addressed by
// absolute path; this resolver creates the dir on first use.
//
// Output capture: a per-call `console` object (`{log, error, warn, info}`)
// is bound into the snippet's lexical scope as the second AsyncFunction
Expand Down Expand Up @@ -48,6 +52,7 @@ import { Skills } from "./skills"

const DEFAULT_TIMEOUT_MS = 60 * 1000
const MAX_TIMEOUT_MS = 10 * 60 * 1000
const cloudConnections = new WeakMap<ReturnType<typeof SessionStore.get>, Promise<void>>()

// Field order matters: providers stream tool-call args in schema-declared
// order, so the model commits to whichever field comes first. `code` is the
Expand All @@ -71,9 +76,8 @@ export type Parameters = Schema.Schema.Type<typeof parameters>

export interface ExecuteContext {
// Identifies the per-opencode-session CDP Session to bind into the snippet.
// The same Session is reused across calls — the agent calls
// `session.connect(...)` in one snippet and subsequent snippets find the
// already-connected Session.
// The same Session is reused across calls. Provisioned cloud sessions
// auto-connect; local sessions are connected explicitly by the agent.
readonly sessionID: string
// Per-project workspace dir: <projectDir>/.bcode/agent-workspace/. Created
// on first call. The agent reads/writes/edits .ts files here via the
Expand Down Expand Up @@ -144,9 +148,10 @@ const serialize = (v: unknown): string => {
}

// Snippet executor. The CDP Session is resolved per-call from `SessionStore`
// keyed on `ctx.sessionID`. The agent connects with `await session.connect(...)`
// in one snippet (Way 1 / Way 2 / Way 3 in skills/browser-execute/SKILL.md); the Session persists
// for follow-up snippets in the same opencode session.
// keyed on `ctx.sessionID`. A provisioned cloud endpoint auto-connects before
// the snippet; otherwise the agent connects explicitly (Way 1 / Way 2 / Way 3
// in skills/browser-execute/SKILL.md). The Session persists for follow-up
// snippets in the same opencode session.
//
// `dataDir` is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/ on
// Linux/Mac). Compiled-mode skills are extracted to `<dataDir>/skills/` once
Expand All @@ -167,6 +172,11 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`),
})

yield* Effect.tryPromise({
try: () => ensureCloudConnected(session),

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Cloud snippets following the required browser-execute skill now connect twice: the tool boundary auto-connects, then the skill's first-call session.connect() opens and replaces the WebSocket because Session.connect() is not idempotent. Updating the skill to skip connect() when the boundary has already connected (for example, if (!session.isConnected()) await session.connect()) would keep the documented flow to one socket.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/browser-execute.ts, line 176:

<comment>Cloud snippets following the required `browser-execute` skill now connect twice: the tool boundary auto-connects, then the skill's first-call `session.connect()` opens and replaces the WebSocket because `Session.connect()` is not idempotent. Updating the skill to skip `connect()` when the boundary has already connected (for example, `if (!session.isConnected()) await session.connect()`) would keep the documented flow to one socket.</comment>

<file context>
@@ -167,6 +172,11 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
       })
 
+      yield* Effect.tryPromise({
+        try: () => ensureCloudConnected(session),
+        catch: (err) => (err instanceof Error ? err : new Error(String(err))),
+      })
</file context>
Fix with cubic

catch: (err) => (err instanceof Error ? err : new Error(String(err))),
})

let output = ""
const tee = (...a: unknown[]) => {
output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n"
Expand Down Expand Up @@ -235,4 +245,20 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
return { parameters, execute, skillsDir }
})

async function ensureCloudConnected(session: ReturnType<typeof SessionStore.get>) {
if (session.isConnected()) return
if (!process.env.BU_CDP_WS && !process.env.BU_CDP_URL) return

const existing = cloudConnections.get(session)
if (existing) return existing

const connecting = session.connect()

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Auto-connect via BU_CDP_URL fails when deployment supplies BU_CDP_WS="". Select the non-empty endpoint explicitly so an empty alias falls back to BU_CDP_URL, matching this gate's behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/browser-execute.ts, line 255:

<comment>Auto-connect via `BU_CDP_URL` fails when deployment supplies `BU_CDP_WS=""`. Select the non-empty endpoint explicitly so an empty alias falls back to `BU_CDP_URL`, matching this gate's behavior.</comment>

<file context>
@@ -235,4 +245,20 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
+  const existing = cloudConnections.get(session)
+  if (existing) return existing
+
+  const connecting = session.connect()
+  cloudConnections.set(session, connecting)
+  try {
</file context>
Suggested change
const connecting = session.connect()
const connecting = session.connect({ wsUrl: process.env.BU_CDP_WS || process.env.BU_CDP_URL })
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A tool call that reconnects after session.close() can have its new CDP requests rejected as CDP socket closed if the prior socket's delayed close event arrives afterward. Scope the close handler's pending-request cleanup to the socket it created (or otherwise wait for the old socket to close) before automatic reconnects.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/browser-execute.ts, line 255:

<comment>A tool call that reconnects after `session.close()` can have its new CDP requests rejected as `CDP socket closed` if the prior socket's delayed close event arrives afterward. Scope the close handler's pending-request cleanup to the socket it created (or otherwise wait for the old socket to close) before automatic reconnects.</comment>

<file context>
@@ -235,4 +245,20 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
+  const existing = cloudConnections.get(session)
+  if (existing) return existing
+
+  const connecting = session.connect()
+  cloudConnections.set(session, connecting)
+  try {
</file context>
Fix with cubic

cloudConnections.set(session, connecting)
try {
await connecting
} finally {
if (cloudConnections.get(session) === connecting) cloudConnections.delete(session)
}
}

export * as BrowserExecute from "./browser-execute"
11 changes: 6 additions & 5 deletions packages/bcode-browser/src/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Process-scope per-opencode-session CDP Session map.
//
// `browser_execute` looks up a `Session` keyed by `sessionID` so that calls
// to `session.connect(...)` made inside one snippet persist across later
// snippets in the same opencode session — the agent connects once, drives
// many. The Session is a single CDP transport (one WebSocket); the agent
// is the source of truth for which browser is on the other end.
// `browser_execute` looks up a `Session` keyed by `sessionID` so that its
// provisioned-cloud auto-connect, or an explicit `session.connect(...)`
// inside a snippet, persists across later snippets in the same opencode
// session — connect once, drive many. The Session is a single CDP transport
// (one WebSocket); the agent is the source of truth for which browser is on
// the other end.
//
// Lifetime: Sessions live for the life of the opencode process. The
// underlying WebSocket closes naturally when the browser exits. The agent
Expand Down
232 changes: 232 additions & 0 deletions packages/bcode-browser/test/browser-auto-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { afterAll, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { BrowserExecute } from "../src/browser-execute"
import { SessionStore } from "../src/session-store"

let connections = 0
const server = Bun.serve({
port: 0,
fetch(req, srv) {
if (srv.upgrade(req)) return undefined
return new Response("upgrade required", { status: 426 })
},
websocket: {
open() {
connections++
},
message(ws, message) {
const request: unknown = JSON.parse(String(message))
if (
!request ||
typeof request !== "object" ||
!("id" in request) ||
typeof request.id !== "number" ||
!("method" in request) ||
typeof request.method !== "string"
)
return
const result =
request.method === "Target.getTargets"
? {
targetInfos: [
{
targetId: "page-1",
type: "page",
title: "",
url: "about:blank",
attached: false,
canAccessOpener: false,
},
],
}
: {}
ws.send(JSON.stringify({ id: request.id, result }))
},
close() {},
},
})

const failingServer = Bun.serve({
port: 0,
fetch() {
return new Response("unavailable", { status: 503 })
},
})

afterAll(() => {
server.stop(true)
failingServer.stop(true)
})

const wsUrl = `ws://127.0.0.1:${server.port}/`

const withEnv = async <T>(vars: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
Object.entries(vars).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
try {
return await fn()
} finally {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}
}

const withBrowserExecute = async (
name: string,
fn: (
impl: Effect.Success<ReturnType<typeof BrowserExecute.make>>,
sessionID: string,
workspaceDir: string,
) => Promise<void>,
) => {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), `bcode-auto-data-${name}-`))
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), `bcode-auto-ws-${name}-`))
const sessionID = `auto-connect-${name}-${Math.random().toString(36).slice(2)}`
try {
const impl = await Effect.runPromise(Effect.scoped(BrowserExecute.make(dataDir)))
await fn(impl, sessionID, workspaceDir)
} finally {
await SessionStore.evict(sessionID)
await Promise.all([dataDir, workspaceDir].map((dir) => fs.rm(dir, { recursive: true, force: true })))
}
}

test("cloud endpoint auto-connects before raw CDP and reuses the connection", async () => {
connections = 0
await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () =>
withBrowserExecute("reuse", async (impl, sessionID, workspaceDir) => {
const run = () =>
Effect.runPromise(
impl.execute(
{
description: "List browser targets",
code: "return (await session.Target.getTargets({})).targetInfos.map((tab) => tab.targetId)",
},
{ sessionID, workspaceDir },
),
)

expect(JSON.parse((await run()).result)).toEqual(["page-1"])
expect(JSON.parse((await run()).result)).toEqual(["page-1"])
expect(connections).toBe(1)
}),
)
})

test("BU_CDP_URL also enables cloud auto-connect", async () => {
connections = 0
await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: wsUrl }, () =>
withBrowserExecute("cdp-url", async (impl, sessionID, workspaceDir) => {
const result = await Effect.runPromise(
impl.execute(
{
description: "List browser targets",
code: "return (await session.Target.getTargets({})).targetInfos.length",
},
{ sessionID, workspaceDir },
),
)

expect(JSON.parse(result.result)).toBe(1)
expect(connections).toBe(1)
}),
)
})

test("parallel first calls share one cloud connection attempt", async () => {
connections = 0
await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () =>
withBrowserExecute("race", async (impl, sessionID, workspaceDir) => {
const run = () =>
Effect.runPromise(
impl.execute(
{
description: "Read target count",
code: "return (await session.Target.getTargets({})).targetInfos.length",
},
{ sessionID, workspaceDir },
),
)

const results = await Promise.all([run(), run()])
expect(results.map((result) => JSON.parse(result.result))).toEqual([1, 1])
expect(connections).toBe(1)
}),
)
})

test("an explicit close and reconnect remains under snippet control", async () => {
connections = 0
await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () =>
withBrowserExecute("reconnect", async (impl, sessionID, workspaceDir) => {
const execute = (code: string) =>
Effect.runPromise(
impl.execute({ description: "Exercise browser connection", code }, { sessionID, workspaceDir }),
)

await execute("return (await session.Target.getTargets({})).targetInfos.length")
const reconnected = await execute(`
session.close()
await new Promise((resolve) => setTimeout(resolve, 20))
await session.connect()
return (await session.Target.getTargets({})).targetInfos.length
`)
const reused = await execute("return (await session.Target.getTargets({})).targetInfos.length")

expect(JSON.parse(reconnected.result)).toBe(1)
expect(JSON.parse(reused.result)).toBe(1)
expect(connections).toBe(2)
}),
)
})

test("sessions without a cloud endpoint still require explicit connect", async () => {
await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: undefined }, () =>
withBrowserExecute("local", async (impl, sessionID, workspaceDir) => {
await expect(
Effect.runPromise(
impl.execute(
{
description: "Call CDP without connect",
code: "return await session.Target.getTargets({})",
},
{ sessionID, workspaceDir },
),
),
).rejects.toThrow("Not connected. Call session.connect(...) first.")
}),
)
})

test("failed cloud auto-connect reports the connection error", async () => {
await withEnv(
{
BU_CDP_WS: `ws://127.0.0.1:${failingServer.port}/`,
BU_CDP_URL: undefined,
},
() =>
withBrowserExecute("failure", async (impl, sessionID, workspaceDir) => {
const failure = Effect.runPromise(
impl.execute(
{
description: "Do not run snippet",
code: 'throw new Error("snippet should not run")',
},
{ sessionID, workspaceDir },
),
)

await expect(failure).rejects.toThrow(/WS error|WS closed before open/)
await expect(failure).rejects.not.toThrow("browser_execute snippet threw")
await expect(failure).rejects.not.toThrow("snippet should not run")
}),
)
})
Loading