Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/bcode-browser/skills/BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const timeoutMs = opts.timeoutMs ?? 5_000;
Expand All @@ -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(', ');
Expand Down
98 changes: 98 additions & 0 deletions packages/bcode-browser/test/connect-env.test.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(vars: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> => {
const prev: Record<string, string | undefined> = {}
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)
})
})
Loading