aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-13 00:47:35 +0530
committerGitHub <noreply@github.com>2026-07-13 00:47:35 +0530
commit7de0beaf2cf713afc50c573e941fa279ec6df9d9 (patch)
treeeae649843603d06053c82b319e1ae010b4e2dca6 /src/cdp
parentcf92c10eda651cdad882aa050439c80d2f40b4b1 (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>
Diffstat (limited to 'src/cdp')
-rw-r--r--src/cdp/discovery.ts180
1 files changed, 171 insertions, 9 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"));
};