From 29463766908d745e270394eda3e7b14bdff5e16b Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:56:57 -0700 Subject: [PATCH 1/2] fix(browser): resolve HTTP CDP URLs --- packages/bcode-browser/src/cdp/session.ts | 56 ++++++++++++-- .../bcode-browser/test/connect-env.test.ts | 74 ++++++++++++++++++- 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 6267868276..72f105d09c 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -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 @@ -85,11 +86,19 @@ 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 wsUrl = envCdpUrl.startsWith('http://') || envCdpUrl.startsWith('https://') + ? await resolveHttpCdpUrl(envCdpUrl, timeoutMs) + : envCdpUrl; + await this.openWs(wsUrl, timeoutMs); + return; + } const browsers = await detectBrowsers(); if (browsers.length === 0) { const scanned = getBrowserCandidates().map(c => c.name).join(', '); @@ -246,6 +255,40 @@ export class Session implements Transport { } } +async function resolveHttpCdpUrl(baseUrl: string, timeoutMs: number): Promise { + 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}`); @@ -423,4 +466,3 @@ async function tryReadDevToolsActivePort( return undefined; } } - diff --git a/packages/bcode-browser/test/connect-env.test.ts b/packages/bcode-browser/test/connect-env.test.ts index 60a8b98593..5e09851a7f 100644 --- a/packages/bcode-browser/test/connect-env.test.ts +++ b/packages/bcode-browser/test/connect-env.test.ts @@ -10,10 +10,24 @@ 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 wsUrl = "" const server = Bun.serve({ port: 0, fetch(req, srv) { - if (srv.upgrade(req)) return + const path = new URL(req.url).pathname + if (path === "/json/version") { + versionRequests++ + 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" && srv.upgrade(req)) return undefined return new Response("nope", { status: 400 }) }, websocket: { @@ -25,7 +39,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 (vars: Record, fn: () => Promise): Promise => { const prev: Record = {} @@ -56,7 +70,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 { @@ -68,6 +82,60 @@ 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 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 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. From d3e7b76c110ab81f3397714f6ccca2bad9aa51bd Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 12:52:53 -0700 Subject: [PATCH 2/2] fix(browser): keep CDP discovery within deadline --- packages/bcode-browser/src/cdp/session.ts | 7 ++- .../bcode-browser/test/connect-env.test.ts | 47 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 72f105d09c..be61761267 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -93,10 +93,13 @@ export class Session implements Transport { } const envCdpUrl = process.env.BU_CDP_URL; if (envCdpUrl) { - const wsUrl = envCdpUrl.startsWith('http://') || envCdpUrl.startsWith('https://') + const deadline = Date.now() + timeoutMs; + const wsUrl = /^https?:\/\//i.test(envCdpUrl) ? await resolveHttpCdpUrl(envCdpUrl, timeoutMs) : envCdpUrl; - await this.openWs(wsUrl, timeoutMs); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error(`timed out after ${timeoutMs}ms`); + await this.openWs(wsUrl, remainingMs); return; } const browsers = await detectBrowsers(); diff --git a/packages/bcode-browser/test/connect-env.test.ts b/packages/bcode-browser/test/connect-env.test.ts index 5e09851a7f..f3e11bc91b 100644 --- a/packages/bcode-browser/test/connect-env.test.ts +++ b/packages/bcode-browser/test/connect-env.test.ts @@ -13,13 +13,16 @@ import { Session } from "../src/cdp/session" 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) { + 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 }) @@ -27,7 +30,10 @@ const server = Bun.serve({ if (versionStatus !== 200) return new Response("blocked", { status: versionStatus }) return Response.json({ webSocketDebuggerUrl: wsUrl }) } - if (path === "/devtools/browser/test" && srv.upgrade(req)) return undefined + 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: { @@ -99,6 +105,23 @@ test("BU_CDP_URL resolves an HTTP DevTools endpoint through /json/version", asyn }) }) +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 @@ -118,6 +141,26 @@ test("BU_CDP_URL retries while a DevTools HTTP endpoint starts", async () => { }) }) +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