Skip to content

Commit ed4db53

Browse files
authored
[STG-1859] fix(cli): use exact DevToolsActivePort websocket path (browserbase#2049)
## Summary - move local CDP discovery into a dedicated module so the DevToolsActivePort behavior is directly testable - reuse the exact `DevToolsActivePort` websocket path for `browse env local --auto-connect` - reuse the same exact-path lookup for bare numeric CDP targets in `resolveWsTarget` - avoid the extra speculative websocket verification pass on the file-backed discovery path - add regression coverage for exact-path resolution, stale file cleanup, ambiguity, and fallback port probing ## Root Cause The CLI was reading `DevToolsActivePort` but then discarding the exact websocket path and probing generic endpoints first. On Chrome's remote-debugging approval flow that could trigger extra preflight requests before the real attach. ## Validation - `pnpm --filter @browserbasehq/stagehand gen-version` - `pnpm --filter @browserbasehq/stagehand build-dom-scripts` - `pnpm --filter @browserbasehq/stagehand build` - `pnpm --filter @browserbasehq/browse-cli build` - `pnpm --filter @browserbasehq/browse-cli typecheck` - `pnpm --filter @browserbasehq/browse-cli test` Linear: https://linear.app/browserbase/issue/STG-1859/use-exact-devtoolsactiveport-websocket-path-for-browse-cli-auto <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Fixes local CDP auto-connect by using the exact websocket path from DevToolsActivePort to avoid extra probes and speed up attach in Chrome’s approval flow. Addresses Linear STG-1859 and improves `@browserbasehq/browse-cli` for `browse env local --auto-connect` and bare port targets. - **Bug Fixes** - Reuse the exact DevToolsActivePort websocket path for local auto-connect. - Prefer exact-path resolution for bare numeric ports in `resolveWsTarget`, then fall back to `/json/version`, then `/devtools/browser`. - Skip speculative websocket verification when a file provides the exact path. - Clean up stale DevToolsActivePort files and return null on ambiguity. - **Refactors** - Extracted local CDP discovery to `local-cdp-discovery.ts` with tests for exact-path resolution, stale cleanup, ambiguity, and fallback probing. <sup>Written for commit 440eee4. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/2049">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. -->
1 parent 04765c1 commit ed4db53

6 files changed

Lines changed: 587 additions & 253 deletions

File tree

.changeset/rare-falcons-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/browse-cli": patch
3+
---
4+
5+
Use the exact DevToolsActivePort websocket path for local auto-connect and bare-port CDP attach to avoid extra remote debugging probes before the real browser connection.

packages/cli/src/index.ts

Lines changed: 1 addition & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ import {
2323
DEFAULT_LOCAL_CONFIG,
2424
getLocalModeHint,
2525
type LocalBrowserLaunchOptions,
26-
type LocalCdpDiscovery,
2726
type LocalConfig,
2827
type LocalInfo,
2928
resolveLocalStrategy,
3029
} from "./local-strategy";
30+
import { discoverLocalCdp } from "./local-cdp-discovery";
3131
import { resolveWsTarget } from "./resolve-ws";
3232
import { NodeHtmlMarkdown } from "node-html-markdown";
3333

@@ -282,238 +282,6 @@ async function getDesiredMode(session: string): Promise<BrowseMode> {
282282
return hasBrowserbaseCredentials() ? "browserbase" : "local";
283283
}
284284

285-
// ==================== CDP AUTO-DISCOVERY ====================
286-
287-
/**
288-
* Well-known Chrome user-data directories per platform.
289-
* Each may contain a DevToolsActivePort file when Chrome is running with
290-
* remote debugging enabled.
291-
*/
292-
function getChromeUserDataDirs(): string[] {
293-
const home = os.homedir();
294-
const dirs: string[] = [];
295-
296-
if (process.platform === "darwin") {
297-
const base = path.join(home, "Library", "Application Support");
298-
for (const name of [
299-
"Google/Chrome",
300-
"Google/Chrome Canary",
301-
"Chromium",
302-
"BraveSoftware/Brave-Browser",
303-
]) {
304-
dirs.push(path.join(base, name));
305-
}
306-
} else if (process.platform === "linux") {
307-
const config = path.join(home, ".config");
308-
for (const name of [
309-
"google-chrome",
310-
"google-chrome-unstable",
311-
"chromium",
312-
"BraveSoftware/Brave-Browser",
313-
]) {
314-
dirs.push(path.join(config, name));
315-
}
316-
}
317-
318-
return dirs;
319-
}
320-
321-
/**
322-
* Read DevToolsActivePort file from a Chrome user-data directory.
323-
* Returns { port, wsPath } or null if file doesn't exist or is malformed.
324-
*/
325-
async function readDevToolsActivePort(
326-
userDataDir: string,
327-
): Promise<{ port: number; wsPath: string } | null> {
328-
try {
329-
const content = await fs.readFile(
330-
path.join(userDataDir, "DevToolsActivePort"),
331-
"utf-8",
332-
);
333-
const lines = content.trim().split("\n");
334-
const port = parseInt(lines[0]?.trim(), 10);
335-
if (isNaN(port) || port <= 0 || port > 65535) return null;
336-
const wsPath = lines[1]?.trim() || "/devtools/browser";
337-
return { port, wsPath };
338-
} catch {
339-
return null;
340-
}
341-
}
342-
343-
/**
344-
* Check if a TCP port is reachable on localhost with a short timeout.
345-
*/
346-
function isPortReachable(port: number, timeoutMs = 500): Promise<boolean> {
347-
return new Promise((resolve) => {
348-
const sock = net.createConnection({ host: "127.0.0.1", port });
349-
const timer = setTimeout(() => {
350-
sock.destroy();
351-
resolve(false);
352-
}, timeoutMs);
353-
sock.on("connect", () => {
354-
clearTimeout(timer);
355-
sock.destroy();
356-
resolve(true);
357-
});
358-
sock.on("error", () => {
359-
clearTimeout(timer);
360-
resolve(false);
361-
});
362-
});
363-
}
364-
365-
/**
366-
* Probe a CDP endpoint at the given port.
367-
* Tries /json/version first, then falls back to a direct WebSocket handshake
368-
* (needed for Chrome 136+ with UI-based remote debugging).
369-
* Returns the webSocketDebuggerUrl on success, or null.
370-
*/
371-
async function probeCdpEndpoint(port: number): Promise<string | null> {
372-
// Try /json/version (standard path)
373-
try {
374-
const controller = new AbortController();
375-
const timer = setTimeout(() => controller.abort(), 2000);
376-
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
377-
signal: controller.signal,
378-
});
379-
clearTimeout(timer);
380-
if (res.ok) {
381-
const json = (await res.json()) as { webSocketDebuggerUrl?: string };
382-
if (json.webSocketDebuggerUrl) {
383-
return json.webSocketDebuggerUrl;
384-
}
385-
}
386-
} catch {
387-
// /json/version unavailable
388-
}
389-
390-
// Fallback: direct WebSocket at /devtools/browser
391-
// Chrome 136+ with chrome://inspect may only expose WS, not HTTP endpoints
392-
const wsUrl = `ws://127.0.0.1:${port}/devtools/browser`;
393-
try {
394-
const verified = await verifyCdpWebSocket(wsUrl);
395-
if (verified) return wsUrl;
396-
} catch {
397-
// WS fallback also failed
398-
}
399-
400-
return null;
401-
}
402-
403-
/**
404-
* Verify a WebSocket URL is a valid CDP endpoint by attempting an HTTP upgrade.
405-
* Sends a minimal WebSocket handshake and checks for a 101 Switching Protocols response.
406-
*/
407-
function verifyCdpWebSocket(wsUrl: string): Promise<boolean> {
408-
return new Promise((resolve) => {
409-
const url = new URL(wsUrl);
410-
const port = parseInt(url.port) || 80;
411-
const wsKey = Buffer.from(
412-
Array.from({ length: 16 }, () => Math.floor(Math.random() * 256)),
413-
).toString("base64");
414-
415-
const sock = net.createConnection({ host: url.hostname, port });
416-
let response = "";
417-
418-
const timer = setTimeout(() => {
419-
sock.destroy();
420-
resolve(false);
421-
}, 2000);
422-
423-
sock.on("connect", () => {
424-
// Send a WebSocket upgrade request
425-
sock.write(
426-
`GET ${url.pathname} HTTP/1.1\r\n` +
427-
`Host: ${url.hostname}:${port}\r\n` +
428-
`Upgrade: websocket\r\n` +
429-
`Connection: Upgrade\r\n` +
430-
`Sec-WebSocket-Key: ${wsKey}\r\n` +
431-
`Sec-WebSocket-Version: 13\r\n` +
432-
`\r\n`,
433-
);
434-
});
435-
436-
sock.on("data", (data) => {
437-
response += data.toString();
438-
// Check for successful WebSocket upgrade (101 Switching Protocols)
439-
if (/^HTTP\/1\.[01] 101(?:\s|$)/.test(response)) {
440-
clearTimeout(timer);
441-
sock.destroy();
442-
resolve(true);
443-
} else if (response.includes("\r\n\r\n")) {
444-
// Got a complete HTTP response that isn't 101
445-
clearTimeout(timer);
446-
sock.destroy();
447-
resolve(false);
448-
}
449-
});
450-
451-
sock.on("error", () => {
452-
clearTimeout(timer);
453-
resolve(false);
454-
});
455-
});
456-
}
457-
458-
interface CdpCandidate {
459-
wsUrl: string;
460-
source: string; // e.g. "DevToolsActivePort (Google Chrome)" or "port 9222"
461-
}
462-
463-
/**
464-
* Discover locally-running Chrome instances with CDP debugging enabled.
465-
* Returns the discovered CDP WebSocket URL, or null with a reason.
466-
*
467-
* Discovery order:
468-
* 1. DevToolsActivePort files in well-known Chrome user-data dirs
469-
* 2. Common debugging ports (9222, 9229)
470-
*
471-
* If multiple healthy candidates are found, returns null (ambiguity).
472-
*/
473-
async function discoverLocalCdp(): Promise<LocalCdpDiscovery | null> {
474-
const candidates: CdpCandidate[] = [];
475-
476-
// Phase 1: Scan DevToolsActivePort files
477-
const userDataDirs = getChromeUserDataDirs();
478-
for (const dir of userDataDirs) {
479-
const info = await readDevToolsActivePort(dir);
480-
if (!info) continue;
481-
482-
// Verify port is alive
483-
if (!(await isPortReachable(info.port))) {
484-
// Stale file — clean up
485-
try {
486-
await fs.unlink(path.join(dir, "DevToolsActivePort"));
487-
} catch {}
488-
continue;
489-
}
490-
491-
const wsUrl = await probeCdpEndpoint(info.port);
492-
if (wsUrl) {
493-
const name = path.basename(dir);
494-
candidates.push({ wsUrl, source: `DevToolsActivePort (${name})` });
495-
}
496-
}
497-
498-
// Phase 2: Probe common ports (only if DevToolsActivePort yielded nothing)
499-
if (candidates.length === 0) {
500-
for (const port of [9222, 9229]) {
501-
if (!(await isPortReachable(port))) continue;
502-
const wsUrl = await probeCdpEndpoint(port);
503-
if (wsUrl) {
504-
candidates.push({ wsUrl, source: `port ${port}` });
505-
}
506-
}
507-
}
508-
509-
// Ambiguity check
510-
if (candidates.length > 1) {
511-
return null; // Caller should fall back to isolated and report ambiguity
512-
}
513-
514-
return candidates[0] ?? null;
515-
}
516-
517285
async function isDaemonRunning(session: string): Promise<boolean> {
518286
try {
519287
const pidFile = getPidPath(session);

0 commit comments

Comments
 (0)