Skip to content
Open
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
59 changes: 52 additions & 7 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ export class Session implements Transport {
* Connect to Chrome's browser-level WebSocket.
*
* 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).
* 1. `BU_CDP_WS` — fixed WebSocket endpoint. `BU_CDP_URL` — HTTP
* DevTools endpoint resolved through `/json/version` (a WebSocket URL
* is also accepted for backwards compatibility). These are used by
* harnesses and CI to hand the agent a preconfigured browser. Failure
* does NOT fall through to scan: silently using another browser would
* violate the harness's intent.
* 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
Expand All @@ -85,11 +86,22 @@ export class Session implements Transport {
await this.openWs(wsUrl, timeoutMs);
return;
}
const envWsUrl = process.env.BU_CDP_WS ?? process.env.BU_CDP_URL;
const envWsUrl = process.env.BU_CDP_WS;
if (envWsUrl) {
await this.openWs(envWsUrl, timeoutMs);
return;
}
const envCdpUrl = process.env.BU_CDP_URL;
if (envCdpUrl) {
const deadline = Date.now() + timeoutMs;
const wsUrl = /^https?:\/\//i.test(envCdpUrl)
? await resolveHttpCdpUrl(envCdpUrl, timeoutMs)
: envCdpUrl;
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error(`timed out after ${timeoutMs}ms`);
await this.openWs(wsUrl, remainingMs);
return;
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const browsers = await detectBrowsers();
if (browsers.length === 0) {
const scanned = getBrowserCandidates().map(c => c.name).join(', ');
Expand Down Expand Up @@ -246,6 +258,40 @@ export class Session implements Transport {
}
}

async function resolveHttpCdpUrl(baseUrl: string, timeoutMs: number): Promise<string> {
const endpoint = `${baseUrl.replace(/\/$/, '')}/json/version`;
const deadline = Date.now() + timeoutMs;
let lastError: unknown;

do {
try {
const remainingMs = Math.max(1, deadline - Date.now());
const response = await fetch(endpoint, {
signal: AbortSignal.timeout(Math.min(1_000, remainingMs)),
});
if (response.status === 403) {
throw new Error(
'permission-blocked: Chrome is reachable, but remote debugging permission was not granted',
);
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json() as { webSocketDebuggerUrl?: unknown };
if (typeof payload.webSocketDebuggerUrl !== 'string') {
throw new Error('missing webSocketDebuggerUrl');
}
return payload.webSocketDebuggerUrl;
} catch (error) {
if (error instanceof Error && error.message.startsWith('permission-blocked:')) throw error;
lastError = error;
}

const remainingMs = deadline - Date.now();
if (remainingMs > 0) await Bun.sleep(Math.min(200, remainingMs));
} while (Date.now() < deadline);

throw new Error(`BU_CDP_URL=${baseUrl} unreachable after ${timeoutMs}ms: ${lastError}`);
}

export class CdpError extends Error {
constructor(public code: number, message: string, public data?: unknown) {
super(`CDP ${code}: ${message}`);
Expand Down Expand Up @@ -423,4 +469,3 @@ async function tryReadDevToolsActivePort(
return undefined;
}
}

119 changes: 115 additions & 4 deletions packages/bcode-browser/test/connect-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,30 @@ 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.
let versionRequests = 0
let versionFailuresRemaining = 0
let versionStatus = 200
let versionDelayMs = 0
let websocketDelayMs = 0
let wsUrl = ""
const server = Bun.serve({
port: 0,
fetch(req, srv) {
if (srv.upgrade(req)) return
async fetch(req, srv) {
const path = new URL(req.url).pathname
if (path === "/json/version") {
versionRequests++
if (versionDelayMs > 0) await Bun.sleep(versionDelayMs)
if (versionFailuresRemaining > 0) {
versionFailuresRemaining--
return new Response("starting", { status: 503 })
}
if (versionStatus !== 200) return new Response("blocked", { status: versionStatus })
return Response.json({ webSocketDebuggerUrl: wsUrl })
}
if (path === "/devtools/browser/test") {
if (websocketDelayMs > 0) await Bun.sleep(websocketDelayMs)
if (srv.upgrade(req)) return undefined
}
return new Response("nope", { status: 400 })
},
websocket: {
Expand All @@ -25,7 +45,7 @@ const server = Bun.serve({

afterAll(() => server.stop(true))

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

const withEnv = async <T>(vars: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> => {
const prev: Record<string, string | undefined> = {}
Expand Down Expand Up @@ -56,7 +76,7 @@ test("connect() with no args connects to BU_CDP_WS when set", async () => {
})
})

test("connect() falls back to BU_CDP_URL when BU_CDP_WS is unset", async () => {
test("BU_CDP_URL still accepts a WebSocket URL for compatibility", async () => {
await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: wsUrl }, async () => {
const session = new Session()
try {
Expand All @@ -68,6 +88,97 @@ test("connect() falls back to BU_CDP_URL when BU_CDP_WS is unset", async () => {
})
})

test("BU_CDP_URL resolves an HTTP DevTools endpoint through /json/version", async () => {
versionRequests = 0
await withEnv({
BU_CDP_WS: undefined,
BU_CDP_URL: `http://127.0.0.1:${server.port}`,
}, async () => {
const session = new Session()
try {
await session.connect()
expect(session.isConnected()).toBe(true)
expect(versionRequests).toBe(1)
} finally {
session.close()
}
})
})

test("BU_CDP_URL recognizes an uppercase HTTP scheme", async () => {
versionRequests = 0
await withEnv({
BU_CDP_WS: undefined,
BU_CDP_URL: `HTTP://127.0.0.1:${server.port}`,
}, async () => {
const session = new Session()
try {
await session.connect()
expect(session.isConnected()).toBe(true)
expect(versionRequests).toBe(1)
} finally {
session.close()
}
})
})

test("BU_CDP_URL retries while a DevTools HTTP endpoint starts", async () => {
versionRequests = 0
versionFailuresRemaining = 2
await withEnv({
BU_CDP_WS: undefined,
BU_CDP_URL: `http://127.0.0.1:${server.port}`,
}, async () => {
const session = new Session()
try {
await session.connect({ timeoutMs: 1_000 })
expect(session.isConnected()).toBe(true)
expect(versionRequests).toBe(3)
} finally {
versionFailuresRemaining = 0
session.close()
}
})
})

test("BU_CDP_URL discovery and WebSocket opening share one timeout", async () => {
versionDelayMs = 130
websocketDelayMs = 130
await withEnv({
BU_CDP_WS: undefined,
BU_CDP_URL: `http://127.0.0.1:${server.port}`,
}, async () => {
const session = new Session()
const started = performance.now()
try {
await expect(session.connect({ timeoutMs: 200 })).rejects.toThrow("timed out")
expect(performance.now() - started).toBeLessThan(270)
} finally {
versionDelayMs = 0
websocketDelayMs = 0
session.close()
}
})
})

test("BU_CDP_URL reports an HTTP permission block immediately", async () => {
versionRequests = 0
versionStatus = 403
await withEnv({
BU_CDP_WS: undefined,
BU_CDP_URL: `http://127.0.0.1:${server.port}`,
}, async () => {
const session = new Session()
try {
await expect(session.connect({ timeoutMs: 1_000 })).rejects.toThrow("permission-blocked")
expect(versionRequests).toBe(1)
} finally {
versionStatus = 200
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.
Expand Down
Loading