diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 6267868276..be61761267 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,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; + } const browsers = await detectBrowsers(); if (browsers.length === 0) { const scanned = getBrowserCandidates().map(c => c.name).join(', '); @@ -246,6 +258,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 +469,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..f3e11bc91b 100644 --- a/packages/bcode-browser/test/connect-env.test.ts +++ b/packages/bcode-browser/test/connect-env.test.ts @@ -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: { @@ -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 (vars: Record, fn: () => Promise): Promise => { const prev: Record = {} @@ -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 { @@ -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.