diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-07-13 00:47:35 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-13 00:47:35 +0530 |
| commit | 7de0beaf2cf713afc50c573e941fa279ec6df9d9 (patch) | |
| tree | eae649843603d06053c82b319e1ae010b4e2dca6 | |
| parent | cf92c10eda651cdad882aa050439c80d2f40b4b1 (diff) | |
feat: support Brave + Chrome channels, fix stale DevToolsActivePort discovery (#13)
* feat: support Brave + Chrome channels, fix stale DevToolsActivePort discovery
Broadens browser detection and hardens CDP endpoint discovery, based on the
investigation and approach in #4 (Brave Beta on macOS with a leftover Chrome
DevToolsActivePort file).
Browser detection (src/setup.ts, src/cdp/discovery.ts):
- checkChromeRunning() now recognizes Brave (macOS "brave browser", Linux
brave/brave-browser, Windows brave.exe), alongside the existing Chrome/
Chromium/Edge detection. Also excludes gpu/updater sub-processes that can
linger after a browser quits.
- profileDirs() gains Brave (Stable/Beta/Nightly/Dev) and Chrome Beta/Dev/
Canary/Chromium profile paths across macOS, Linux, and Windows.
Stale DevToolsActivePort handling (src/cdp/discovery.ts):
Previously discovery trusted the first readable DevToolsActivePort file. A
browser that has quit leaves its file behind, so if another browser later binds
the same port (e.g. 9222), discovery returned a WS URL carrying the dead
browser's UUID against the live browser's server, and the connection failed.
discoverWsUrl() now:
- collects all readable candidates instead of committing to the first;
- when candidates share a port, keeps the most-recently-written file (mtime);
- skips candidates whose port isn't live via a fast single-shot probe (no
30s waitForPort block on stale files);
- asks each live browser for its canonical webSocketDebuggerUrl via
/json/version, falling back to the file's WS path when that endpoint is
disabled (some browsers only expose it via --remote-debugging-port);
- falls back to probing well-known ports (9222, plus BU_CDP_PORTS) when no
profile file is readable — covering sandboxed harnesses (EPERM/EACCES) and
non-default install locations.
Setup guidance updated to mention brave://inspect / edge://inspect and the
--remote-debugging-port launch flag.
Verified: typecheck clean; queryLiveWsUrl / mtime-dedup / fallback-port logic
exercised against a mock /json/version server; checkChromeRunning() matches the
single main process against live macOS ps output, excluding all helpers.
Closes #4
Co-authored-by: Trevor Whitney <trevorjwhitney@gmail.com>
* fix: harden discovery against malformed port files + stale-then-fallback
Addresses two issues raised in review of the discovery rewrite:
- Validate the port parsed from DevToolsActivePort before building a
candidate. A truncated/corrupt file (non-numeric or out-of-range first
line) yielded NaN, and net.connect throws ERR_SOCKET_BAD_PORT
synchronously for NaN / <=0 / >=65536 — which escaped discoverWsUrl as an
unhandled rejection instead of surfacing as a Result error. Such entries
are now skipped.
- Fall back to well-known ports (9222 / BU_CDP_PORTS) after all discovered
candidates prove stale. Previously the fallback ran only when no port file
was readable, so a leftover file for a quit browser caused a ~30s
waitForPort block and failure without ever trying a live fallback port.
Ports already covered by a candidate are skipped to avoid re-probing.
---------
Co-authored-by: Trevor Whitney <trevorjwhitney@gmail.com>
| -rw-r--r-- | src/cdp/discovery.ts | 180 | ||||
| -rw-r--r-- | src/setup.ts | 33 |
2 files changed, 195 insertions, 18 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts index 8b35864..1b35418 100644 --- a/src/cdp/discovery.ts +++ b/src/cdp/discovery.ts @@ -1,7 +1,8 @@ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { connect as netConnect } from "node:net"; +import { request as httpRequest } from "node:http"; import { type Result, err, ok } from "../util/result"; import { type CdpError, cdpError } from "./errors"; @@ -11,23 +12,45 @@ const PORT_PROBE_INTERVAL_MS = 1_000; const profileDirs = (): ReadonlyArray<string> => { const home = homedir(); return [ + // macOS — Chrome (all channels), Chromium, Brave, Edge join(home, "Library/Application Support/Google/Chrome"), + join(home, "Library/Application Support/Google/Chrome Beta"), + join(home, "Library/Application Support/Google/Chrome Dev"), + join(home, "Library/Application Support/Google/Chrome Canary"), + join(home, "Library/Application Support/Chromium"), + join(home, "Library/Application Support/BraveSoftware/Brave-Browser"), + join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Beta"), + join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Nightly"), + join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Dev"), join(home, "Library/Application Support/Microsoft Edge"), join(home, "Library/Application Support/Microsoft Edge Beta"), join(home, "Library/Application Support/Microsoft Edge Dev"), join(home, "Library/Application Support/Microsoft Edge Canary"), + // Linux — Chrome (all channels), Chromium, Brave, Edge join(home, ".config/google-chrome"), + join(home, ".config/google-chrome-beta"), + join(home, ".config/google-chrome-unstable"), join(home, ".config/chromium"), join(home, ".config/chromium-browser"), + join(home, ".config/BraveSoftware/Brave-Browser"), + join(home, ".config/BraveSoftware/Brave-Browser-Beta"), + join(home, ".config/BraveSoftware/Brave-Browser-Nightly"), join(home, ".config/microsoft-edge"), join(home, ".config/microsoft-edge-beta"), join(home, ".config/microsoft-edge-dev"), + // Linux flatpak join(home, ".var/app/org.chromium.Chromium/config/chromium"), join(home, ".var/app/com.google.Chrome/config/google-chrome"), join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"), join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"), + // Windows — Chrome, Chromium, Brave, Edge join(home, "AppData/Local/Google/Chrome/User Data"), + join(home, "AppData/Local/Google/Chrome Beta/User Data"), + join(home, "AppData/Local/Google/Chrome Dev/User Data"), + join(home, "AppData/Local/Google/Chrome SxS/User Data"), join(home, "AppData/Local/Chromium/User Data"), + join(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data"), + join(home, "AppData/Local/BraveSoftware/Brave-Browser-Beta/User Data"), join(home, "AppData/Local/Microsoft/Edge/User Data"), join(home, "AppData/Local/Microsoft/Edge Beta/User Data"), join(home, "AppData/Local/Microsoft/Edge Dev/User Data"), @@ -66,17 +89,94 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => { )); }; +// Fast, single-shot TCP check (no retry loop). Used to skip stale +// DevToolsActivePort files left behind by a browser that has since quit — +// unlike waitForPort, this returns immediately instead of blocking ~30s. +const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success; + +// Ask the live browser for its canonical webSocketDebuggerUrl via +// http://127.0.0.1:<port>/json/version. This is authoritative: it avoids +// trusting a path from a stale DevToolsActivePort file that may belong to a +// different browser which has since exited (e.g. Chrome's stale file pointing +// at 9222 while Brave actually owns the port now). Returns null if the endpoint +// is unreachable, non-200, or doesn't expose a WS URL. +const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> => + new Promise<string | null>((resolve) => { + const req = httpRequest( + { host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + try { + const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>; + const ws = json["webSocketDebuggerUrl"]; + resolve(typeof ws === "string" ? ws : null); + } catch { + resolve(null); + } + }); + }, + ); + req.on("timeout", () => { req.destroy(); resolve(null); }); + req.on("error", () => resolve(null)); + req.end(); + }); + +// Well-known CDP ports to probe when no DevToolsActivePort file is readable +// (sandboxed harness, restricted filesystem permissions, or an unusual profile +// location not on the profileDirs list). The default Chromium remote-debugging +// port is 9222; users can extend via BU_CDP_PORTS="9222,9333". +const fallbackPorts = (): ReadonlyArray<number> => { + const ports = new Set<number>([9222]); + const fromEnv = process.env["BU_CDP_PORTS"]; + if (fromEnv) { + for (const tok of fromEnv.split(",")) { + const n = Number(tok.trim()); + if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); + } + } + return Array.from(ports); +}; + +type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number }; + export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { const dirs = profileDirs(); + + // Phase 1: collect every readable DevToolsActivePort candidate up front, + // rather than committing to the first one found. A single browser writes one + // such file, but stale files from quit browsers linger, so we must + // disambiguate below instead of trusting discovery order. + const candidates: Candidate[] = []; + const readErrors: string[] = []; for (const base of dirs) { const portFile = join(base, "DevToolsActivePort"); let raw: string; + let mtimeMs = 0; try { raw = await readFile(portFile, "utf8"); + try { + mtimeMs = (await stat(portFile)).mtimeMs; + } catch { + // mtime is only a tiebreaker; a stat failure just leaves it at 0. + } } catch (e) { - // Node fs errors carry .code; see ErrnoException + // Node fs errors carry .code; see ErrnoException. ENOENT is normal — the + // dir is on our list but the user hasn't installed that browser. EPERM/ + // EACCES is common under sandboxes; remember it and fall back to network + // probing rather than failing the whole discovery. const code = (e as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === undefined) continue; + if (code === "EPERM" || code === "EACCES") { + readErrors.push(`${portFile}: ${code}`); + continue; + } return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`)); } const lines = raw.trim().split("\n"); @@ -84,12 +184,74 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { const port = lines[0]?.trim(); const path = lines[1]?.trim(); if (!port || !path) continue; - const ready = await waitForPort(Number(port)); - if (!ready.success) return ready; - return ok(`ws://127.0.0.1:${port}${path}`); + // Guard against a truncated/corrupt port file (e.g. a non-numeric first + // line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT + // synchronously, which would escape discoverWsUrl instead of surfacing as + // a Result error. Skip such entries. + const portNum = Number(port); + if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue; + candidates.push({ port: portNum, path, mtimeMs }); } - return err(cdpError( - "discovery_failed", - `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`, - )); + + // Phase 2: no readable candidate — probe well-known ports directly. Covers + // sandboxed harnesses that can't read profile dirs and non-default install + // locations. Requires the browser to have been launched with + // --remote-debugging-port so /json/version is served. + if (candidates.length === 0) { + for (const port of fallbackPorts()) { + if (!(await isPortLive(port))) continue; + const live = await queryLiveWsUrl(port); + if (live) return ok(live); + } + const permHint = readErrors.length > 0 + ? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.` + : ""; + return err(cdpError( + "discovery_failed", + `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`, + )); + } + + // Phase 3: disambiguate candidates sharing a port (one browser running, and + // another's file left behind on the default 9222): keep the most-recently- + // written file — that's the currently-running browser. + const byPort = new Map<number, Candidate>(); + for (const c of candidates) { + const prev = byPort.get(c.port); + if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c); + } + const unique = Array.from(byPort.values()); + + // Prefer candidates whose port is actually accepting connections; this skips + // stale files whose browser has quit (avoiding a ~30s waitForPort block). + const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port))); + const liveCandidates = unique.filter((_, i) => liveChecks[i]); + const ordered = liveCandidates.length > 0 ? liveCandidates : unique; + + // Phase 4: for each candidate, prefer the browser's own canonical WS URL from + // /json/version. Some browsers disable that HTTP endpoint when remote + // debugging is toggled only via chrome://inspect (rather than a launch flag), + // so fall back to the WS path written in DevToolsActivePort. + let lastErr: Result<string, CdpError> | null = null; + for (const c of ordered) { + const ready = await waitForPort(c.port); + if (!ready.success) { + lastErr = err(ready.error); + continue; + } + const liveUrl = await queryLiveWsUrl(c.port); + return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`); + } + + // Every discovered candidate was stale/unreachable. Before giving up, try the + // well-known fallback ports — a user with a leftover DevToolsActivePort file + // may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports + // already covered by a candidate above so we don't re-probe them.) + for (const port of fallbackPorts()) { + if (byPort.has(port) || !(await isPortLive(port))) continue; + const liveUrl = await queryLiveWsUrl(port); + if (liveUrl) return ok(liveUrl); + } + + return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates")); }; diff --git a/src/setup.ts b/src/setup.ts index 1ff7c36..40f501c 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -60,9 +60,12 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult> return { success: false, error: - "Chrome remote debugging needs to be enabled.\n\n" + - 'Open chrome://inspect/#remote-debugging in your browser, tick the\n' + + "Browser remote debugging needs to be enabled.\n\n" + + "Open chrome://inspect/#remote-debugging (or brave://inspect,\n" + + "edge://inspect) in your browser, tick the\n" + '"Discover network targets" / Allow checkbox, then retry.\n\n' + + "If that doesn't expose DevTools, relaunch the browser with\n" + + "--remote-debugging-port=9222.\n\n" + "Or set BU_CDP_WS to a remote browser WebSocket URL.", }; } @@ -90,19 +93,31 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult> function checkChromeRunning(): boolean { try { if (process.platform === "darwin") { - // ps -o comm= returns the full executable path on modern macOS. - // We check for "Google Chrome" anywhere in the path (case-insensitive), - // excluding helper/renderer subprocesses. + // ps -o comm= returns the full executable path on modern macOS, e.g. + // "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser". We check + // for a known Chromium-family name anywhere in the path (case-insensitive), + // excluding helper/renderer/gpu/crashpad/updater subprocesses that linger + // after the user quits the browser. const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); + const browserNames = ["google chrome", "chromium", "microsoft edge", "brave browser"]; return lines.some((l) => { - if (l.includes("helper") || l.includes("renderer") || l.includes("crashpad")) return false; - return l.includes("google chrome") || l.includes("chromium") || l.includes("microsoft edge"); + if ( + l.includes("helper") || + l.includes("renderer") || + l.includes("crashpad") || + l.includes(" gpu") || + l.includes("updater") + ) return false; + return browserNames.some((name) => l.includes(name)); }); } else if (process.platform === "linux") { const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); - const browserComms = ["chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", "google-chrome"]; + const browserComms = [ + "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", + "google-chrome", "brave", "brave-browser", + ]; return lines.some((line) => { const parts = line.split(/\s+/); const comm = parts[0] ?? ""; @@ -112,7 +127,7 @@ function checkChromeRunning(): boolean { }); } else if (process.platform === "win32") { const out = execSync("tasklist", { timeout: 5000 }).toString().toLowerCase(); - return ["chrome.exe", "msedge.exe"].some((n) => out.includes(n)); + return ["chrome.exe", "msedge.exe", "brave.exe"].some((n) => out.includes(n)); } } catch { // best-effort |
