From 75575060a4c791faaeefe59ad55cea3f999d1b5b Mon Sep 17 00:00:00 2001 From: bcode Date: Sat, 9 May 2026 01:11:09 +0000 Subject: [PATCH] feat(connect): honor BU_CDP_WS env var in session.connect() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eval harnesses and CI need to hand the agent a preconfigured browser without putting the WS URL in the prompt. Restores the convention from the pre-port Python harness: when `BU_CDP_WS` (or alias `BU_CDP_URL`) is set, no-args `session.connect()` connects there directly, ahead of OS scan. Explicit { wsUrl } / { profileDir } calls ignore the env var. If the env var is set but unreachable, surface the error rather than silently falling through to OS scan — matches Python harness PR #300 which closed the same hole. - src/cdp/session.ts: env-var precedence in connect(), ~5 lines. - skills/BROWSER.md: one-paragraph note under Connecting. - test/connect-env.test.ts: 4 tests covering precedence, fallback, override. Verified: bun typecheck clean, bun test 12 pass + 5 chrome-gated skip (was 8 + 5 baseline; +4 from the new tests). --- packages/bcode-browser/skills/BROWSER.md | 2 + packages/bcode-browser/src/cdp/session.ts | 24 +++-- .../bcode-browser/test/connect-env.test.ts | 98 +++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 packages/bcode-browser/test/connect-env.test.ts diff --git a/packages/bcode-browser/skills/BROWSER.md b/packages/bcode-browser/skills/BROWSER.md index 6f3452652..9e364fae8 100644 --- a/packages/bcode-browser/skills/BROWSER.md +++ b/packages/bcode-browser/skills/BROWSER.md @@ -17,6 +17,8 @@ You always call `session.connect(...)` once at the start of your work. The `Sess For most tasks where the agent acts on behalf of the user in their normal browser, use **Way 1**. For automation that runs without the user watching, or any case where popup interruptions are unacceptable, use **Way 2** or a cloud browser. Cloud is only used when the user opts in. +**Preconfigured environments (eval harnesses, CI).** If `BU_CDP_WS` (or its alias `BU_CDP_URL`) is set in the environment, `session.connect()` with no args connects to that endpoint directly — no OS scan, no cloud provision. The harness has already chosen the browser for you; just call `await session.connect()` and start driving. Explicit `{ wsUrl }` / `{ profileDir }` calls ignore the env var. + **Way 1 — connect to the user's running Chrome (real profile, popup-gated).** Inherits the user's everyday Chrome logins, extensions, history, and bookmarks. Right choice when the task involves the user's actual logged-in sites. ```js diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index ac5c65da2..66327a148 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -63,14 +63,19 @@ export class Session implements Transport { /** * Connect to Chrome's browser-level WebSocket. * - * With no args, runs auto-detect: scans OS-specific profile dirs via - * `detectBrowsers()` and tries each candidate (most-recently-launched first) - * until a WebSocket open succeeds. Each attempt has a short timeout so - * dead ports and permission-denied (403) candidates fail fast and the - * loop moves on. + * With no args, picks a browser in this precedence: + * 1. `BU_CDP_WS` / `BU_CDP_URL` env var — single fixed endpoint, used + * by eval harnesses and CI to hand the agent a preconfigured browser. + * If set, we connect there; failure does NOT fall through to scan + * (the harness's intent is binding — silently using a different + * browser is the worse failure mode). + * 2. OS scan via `detectBrowsers()` — try each candidate + * (most-recently-launched first) until a WebSocket open succeeds. + * Each attempt has a short timeout so dead ports and 403s fail + * fast and the loop moves on. * - * With explicit opts ({ wsUrl } | { profileDir } | { port }), connects - * directly to that single URL with a generous timeout. + * With explicit opts ({ wsUrl } | { profileDir }), env vars are ignored + * and we connect directly to the supplied endpoint. */ async connect(opts: ConnectOptions = {}): Promise { const timeoutMs = opts.timeoutMs ?? 5_000; @@ -79,6 +84,11 @@ export class Session implements Transport { await this.openWs(wsUrl, timeoutMs); return; } + const envWsUrl = process.env.BU_CDP_WS ?? process.env.BU_CDP_URL; + if (envWsUrl) { + await this.openWs(envWsUrl, timeoutMs); + return; + } const browsers = await detectBrowsers(); if (browsers.length === 0) { const scanned = getBrowserCandidates().map(c => c.name).join(', '); diff --git a/packages/bcode-browser/test/connect-env.test.ts b/packages/bcode-browser/test/connect-env.test.ts new file mode 100644 index 000000000..60a8b9859 --- /dev/null +++ b/packages/bcode-browser/test/connect-env.test.ts @@ -0,0 +1,98 @@ +// `session.connect()` env-var precedence. +// +// `BU_CDP_WS` (and `BU_CDP_URL`) hand the agent a preconfigured browser: +// when set, no-args connect skips OS scan and connects there directly. +// Used by eval harnesses and CI to ensure the agent always lands on the +// browser they provisioned, regardless of which local Chromes are running. + +import { afterAll, expect, test } from "bun:test" +import { Session } from "../src/cdp/session" + +// Tiny WS echo server. Accept the upgrade so `connect()` resolves; the +// CDP protocol itself is never exercised in this test. +const server = Bun.serve({ + port: 0, + fetch(req, srv) { + if (srv.upgrade(req)) return + return new Response("nope", { status: 400 }) + }, + websocket: { + open() {}, + message() {}, + close() {}, + }, +}) + +afterAll(() => server.stop(true)) + +const wsUrl = `ws://127.0.0.1:${server.port}/` + +const withEnv = async (vars: Record, fn: () => Promise): Promise => { + const prev: Record = {} + for (const k of Object.keys(vars)) { + prev[k] = process.env[k] + if (vars[k] === undefined) delete process.env[k] + else process.env[k] = vars[k] + } + try { + return await fn() + } finally { + for (const k of Object.keys(prev)) { + if (prev[k] === undefined) delete process.env[k] + else process.env[k] = prev[k] + } + } +} + +test("connect() with no args connects to BU_CDP_WS when set", async () => { + await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, async () => { + const session = new Session() + try { + await session.connect() + expect(session.isConnected()).toBe(true) + } finally { + session.close() + } + }) +}) + +test("connect() falls back to BU_CDP_URL when BU_CDP_WS is unset", async () => { + await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: wsUrl }, async () => { + const session = new Session() + try { + await session.connect() + expect(session.isConnected()).toBe(true) + } finally { + session.close() + } + }) +}) + +test("explicit { wsUrl } overrides env vars", async () => { + // Env points at an unreachable port; explicit opts point at the live server. + // If env-var were consulted first, the test would fail with a timeout. + await withEnv({ BU_CDP_WS: "ws://127.0.0.1:1/", BU_CDP_URL: undefined }, async () => { + const session = new Session() + try { + await session.connect({ wsUrl, timeoutMs: 2_000 }) + expect(session.isConnected()).toBe(true) + } finally { + session.close() + } + }) +}) + +test("BU_CDP_WS pointing at a dead port surfaces the error (no fallback to OS scan)", async () => { + await withEnv({ BU_CDP_WS: "ws://127.0.0.1:1/", BU_CDP_URL: undefined }, async () => { + const session = new Session() + let threw = false + try { + await session.connect({ timeoutMs: 1_000 }) + } catch { + threw = true + } finally { + session.close() + } + expect(threw).toBe(true) + }) +})