From f28d0aa21106bb14a58371810017479eca8282cb Mon Sep 17 00:00:00 2001 From: MagMueller Date: Mon, 27 Jul 2026 17:37:52 -0700 Subject: [PATCH] fix(browser): auto-connect provisioned sessions --- packages/bcode-browser/src/browser-execute.ts | 50 +++- packages/bcode-browser/src/session-store.ts | 11 +- .../test/browser-auto-connect.test.ts | 232 ++++++++++++++++++ 3 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 packages/bcode-browser/test/browser-auto-connect.test.ts diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 230bacbd99..0f7cf0c491 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -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 `/.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 `/.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 @@ -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, Promise>() // Field order matters: providers stream tool-call args in schema-declared // order, so the model commits to whichever field comes first. `code` is the @@ -71,9 +76,8 @@ export type Parameters = Schema.Schema.Type 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: /.bcode/agent-workspace/. Created // on first call. The agent reads/writes/edits .ts files here via the @@ -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 `/skills/` once @@ -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), + 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" @@ -235,4 +245,20 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) return { parameters, execute, skillsDir } }) +async function ensureCloudConnected(session: ReturnType) { + 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() + cloudConnections.set(session, connecting) + try { + await connecting + } finally { + if (cloudConnections.get(session) === connecting) cloudConnections.delete(session) + } +} + export * as BrowserExecute from "./browser-execute" diff --git a/packages/bcode-browser/src/session-store.ts b/packages/bcode-browser/src/session-store.ts index 8be05462c8..d4d08b5f9a 100644 --- a/packages/bcode-browser/src/session-store.ts +++ b/packages/bcode-browser/src/session-store.ts @@ -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 diff --git a/packages/bcode-browser/test/browser-auto-connect.test.ts b/packages/bcode-browser/test/browser-auto-connect.test.ts new file mode 100644 index 0000000000..9a249c0598 --- /dev/null +++ b/packages/bcode-browser/test/browser-auto-connect.test.ts @@ -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 (vars: Record, fn: () => Promise): Promise => { + 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>, + sessionID: string, + workspaceDir: string, + ) => Promise, +) => { + 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") + }), + ) +})