From 0eea994c940a50f6eed90cd52691ba9f2b3b96ff Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 15:39:18 -0700 Subject: [PATCH 01/12] feat(cli): allow --ws to accept port numbers When a bare port number is passed (e.g. `--ws 9222`), resolve the CDP WebSocket URL via `GET http://127.0.0.1:{port}/json/version` with a fallback to `ws://127.0.0.1:{port}`. Full URLs continue to work as-is. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/ws-port-resolve.md | 5 +++++ packages/cli/src/index.ts | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/ws-port-resolve.md diff --git a/.changeset/ws-port-resolve.md b/.changeset/ws-port-resolve.md new file mode 100644 index 000000000..dd9f6fe9b --- /dev/null +++ b/.changeset/ws-port-resolve.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/browse-cli": patch +--- + +Allow `--ws` to accept a bare port number (e.g. `--ws 9222`) in addition to full WebSocket URLs. When a port is given, the CLI resolves the CDP WebSocket URL via `/json/version`. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4682a0e45..8f4a421e4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1490,18 +1490,47 @@ function output(data: unknown, json: boolean): void { } } +/** + * Resolve a --ws value to a CDP WebSocket URL. + * Accepts a bare port number (e.g. "9222"), which is resolved via the + * /json/version endpoint, or a full URL (ws://, wss://, http://) used as-is. + */ +async function resolveWsTarget(input: string): Promise { + // Bare numeric port → discover via /json/version + if (/^\d+$/.test(input)) { + const port = input; + const url = `http://127.0.0.1:${port}/json/version`; + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} from ${url}`); + } + const json = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (json.webSocketDebuggerUrl) { + return json.webSocketDebuggerUrl; + } + } catch { + // /json/version unavailable — fall back to a conventional WS URL + } + return `ws://127.0.0.1:${port}`; + } + // Already a URL — use as-is + return input; +} + async function runCommand(command: string, args: unknown[]): Promise { const opts = program.opts(); const session = getSession(opts); const headless = isHeadless(opts); // If --ws provided, bypass daemon and connect directly if (opts.ws) { + const cdpUrl = await resolveWsTarget(opts.ws); const stagehand = new Stagehand({ env: "LOCAL", verbose: 0, disablePino: true, localBrowserLaunchOptions: { - cdpUrl: opts.ws, + cdpUrl, }, }); await stagehand.init(); @@ -1549,8 +1578,8 @@ program .description("Browser automation CLI for AI agents") .version(VERSION) .option( - "--ws ", - "CDP WebSocket URL (bypasses daemon, direct connection)", + "--ws ", + "CDP WebSocket URL or port number (bypasses daemon, direct connection)", ) .option("--headless", "Run Chrome in headless mode") .option("--headed", "Run Chrome with visible window (default)") From 5f287051d96928974ef7dab9420bb066acd404cd Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 15:52:44 -0700 Subject: [PATCH 02/12] test(cli): add unit tests for resolveWsTarget Extract resolveWsTarget into its own module so it can be imported without triggering Commander's program.parse(). Tests cover: - bare port resolved via /json/version - fallback to ws://127.0.0.1:{port} when endpoint is unavailable - ws://, wss://, and http:// URL passthrough Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 29 +------------ packages/cli/src/resolve-ws.ts | 27 ++++++++++++ packages/cli/tests/resolve-ws.test.ts | 60 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/resolve-ws.ts create mode 100644 packages/cli/tests/resolve-ws.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8f4a421e4..41fa5d41c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,7 @@ import { spawn } from "child_process"; import * as readline from "readline"; import type { Protocol } from "devtools-protocol"; import { version as VERSION } from "../package.json"; +import { resolveWsTarget } from "./resolve-ws"; const program = new Command(); @@ -1490,34 +1491,6 @@ function output(data: unknown, json: boolean): void { } } -/** - * Resolve a --ws value to a CDP WebSocket URL. - * Accepts a bare port number (e.g. "9222"), which is resolved via the - * /json/version endpoint, or a full URL (ws://, wss://, http://) used as-is. - */ -async function resolveWsTarget(input: string): Promise { - // Bare numeric port → discover via /json/version - if (/^\d+$/.test(input)) { - const port = input; - const url = `http://127.0.0.1:${port}/json/version`; - try { - const res = await fetch(url); - if (!res.ok) { - throw new Error(`HTTP ${res.status} from ${url}`); - } - const json = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (json.webSocketDebuggerUrl) { - return json.webSocketDebuggerUrl; - } - } catch { - // /json/version unavailable — fall back to a conventional WS URL - } - return `ws://127.0.0.1:${port}`; - } - // Already a URL — use as-is - return input; -} - async function runCommand(command: string, args: unknown[]): Promise { const opts = program.opts(); const session = getSession(opts); diff --git a/packages/cli/src/resolve-ws.ts b/packages/cli/src/resolve-ws.ts new file mode 100644 index 000000000..2be3f1609 --- /dev/null +++ b/packages/cli/src/resolve-ws.ts @@ -0,0 +1,27 @@ +/** + * Resolve a --ws value to a CDP WebSocket URL. + * Accepts a bare port number (e.g. "9222"), which is resolved via the + * /json/version endpoint, or a full URL (ws://, wss://, http://) used as-is. + */ +export async function resolveWsTarget(input: string): Promise { + // Bare numeric port → discover via /json/version + if (/^\d+$/.test(input)) { + const port = input; + const url = `http://127.0.0.1:${port}/json/version`; + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} from ${url}`); + } + const json = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (json.webSocketDebuggerUrl) { + return json.webSocketDebuggerUrl; + } + } catch { + // /json/version unavailable — fall back to a conventional WS URL + } + return `ws://127.0.0.1:${port}`; + } + // Already a URL — use as-is + return input; +} diff --git a/packages/cli/tests/resolve-ws.test.ts b/packages/cli/tests/resolve-ws.test.ts new file mode 100644 index 000000000..a6ccd279f --- /dev/null +++ b/packages/cli/tests/resolve-ws.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterAll, beforeAll } from "vitest"; +import * as http from "http"; +import { resolveWsTarget } from "../src/resolve-ws"; + +let server: http.Server; +let port: number; + +beforeAll(async () => { + server = http.createServer((req, res) => { + if (req.url === "/json/version") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/abc123`, + }), + ); + } else { + res.writeHead(404); + res.end(); + } + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + port = (server.address() as { port: number }).port; + resolve(); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("resolveWsTarget", () => { + it("resolves a bare port via /json/version", async () => { + const result = await resolveWsTarget(String(port)); + expect(result).toBe(`ws://127.0.0.1:${port}/devtools/browser/abc123`); + }); + + it("falls back to ws://127.0.0.1:{port} when /json/version is unavailable", async () => { + const result = await resolveWsTarget("19999"); + expect(result).toBe("ws://127.0.0.1:19999"); + }); + + it("passes through ws:// URLs as-is", async () => { + const url = "ws://localhost:9222/devtools/browser/xyz"; + expect(await resolveWsTarget(url)).toBe(url); + }); + + it("passes through wss:// URLs as-is", async () => { + const url = "wss://remote.host/devtools/browser/xyz"; + expect(await resolveWsTarget(url)).toBe(url); + }); + + it("passes through http:// URLs as-is", async () => { + const url = "http://localhost:9222/json/version"; + expect(await resolveWsTarget(url)).toBe(url); + }); +}); From 271aa3806e484f303b10bed6e78a366752217452 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 17:58:49 -0700 Subject: [PATCH 03/12] STG-1668: feat(cli): browse env local auto-discovers existing Chrome via CDP Change browse env local from always launching an isolated browser to preferring an already-debuggable local Chrome. Discovery scans DevToolsActivePort files and common ports, with fallback to isolated. New commands: - browse env local (auto-discover, default) - browse env local --isolated (force clean browser) - browse env local (explicit CDP target) browse status now reports localStrategy, localSource, resolvedCdpUrl. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/cli-local-auto-discover.md | 5 + packages/cli/README.md | 48 +++- packages/cli/src/index.ts | 383 +++++++++++++++++++++++++- 3 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 .changeset/cli-local-auto-discover.md diff --git a/.changeset/cli-local-auto-discover.md b/.changeset/cli-local-auto-discover.md new file mode 100644 index 000000000..522ea5855 --- /dev/null +++ b/.changeset/cli-local-auto-discover.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/browse-cli": minor +--- + +browse env local now auto-discovers existing Chrome instances with remote debugging enabled, attaching to them instead of always launching an isolated browser. Falls back to isolated launch when no debuggable Chrome is found. Added --isolated flag and positional CDP target argument to browse env local. diff --git a/packages/cli/README.md b/packages/cli/README.md index c1619d6b1..1815dfdf7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -173,11 +173,46 @@ browse env # Switch current session to Browserbase (restarts daemon if needed) browse env remote -# Switch back to local Chrome +# Switch back to local Chrome (auto-discovers existing Chrome, falls back to isolated) browse env local ``` -Behavior details: +#### Local Browser Strategies + +By default, `browse env local` auto-discovers an already-running Chrome with remote +debugging enabled. This lets agents use your existing cookies, logins, and browser state. +If no debuggable Chrome is found, it falls back to launching an isolated browser. + +```bash +# Auto-discover local Chrome, fallback to isolated (default) +browse env local + +# Force a clean isolated browser (no auto-discovery) +browse env local --isolated + +# Attach to a specific CDP target (port or URL) +browse env local 9222 +browse env local ws://localhost:9222/devtools/browser/... +``` + +Auto-discovery checks: +1. `DevToolsActivePort` files in well-known Chrome/Chromium/Brave user-data directories +2. Common debugging ports (9222, 9229) + +To make your Chrome discoverable, launch it with `--remote-debugging-port`: + +```bash +google-chrome --remote-debugging-port=9222 +``` + +Use `browse status` to see which strategy was resolved: + +```bash +browse status +# {"running":true,"session":"default","mode":"local","localStrategy":"auto","localSource":"attached-existing","resolvedCdpUrl":"ws://..."} +``` + +#### General Behavior - Environment is scoped per `--session` - `browse env ` persists an override and restarts the daemon @@ -193,7 +228,7 @@ Behavior details: | `--session ` | Session name for multiple browsers (default: "default") | | `--headless` | Run Chrome in headless mode | | `--headed` | Run Chrome with visible window (default) | -| `--ws ` | Connect to existing Chrome via CDP WebSocket | +| `--ws ` | One-shot CDP connection (bypasses daemon) | | `--json` | Output as JSON | ## Environment Variables @@ -253,7 +288,12 @@ Connect to an existing Chrome instance: # Start Chrome with remote debugging google-chrome --remote-debugging-port=9222 -# Connect via WebSocket +# Option 1: Persistent session (recommended) — daemon stays attached +browse env local 9222 +browse open https://example.com + +# Option 2: One-shot bypass (no daemon, per-command) +browse --ws 9222 open https://example.com browse --ws ws://localhost:9222/devtools/browser/... open https://example.com ``` diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 41fa5d41c..b151288d6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -160,6 +160,66 @@ function getConnectPath(session: string): string { return path.join(SOCKET_DIR, `browse-${session}.connect`); } +function getLocalConfigPath(session: string): string { + return path.join(SOCKET_DIR, `browse-${session}.local-config`); +} + +function getLocalInfoPath(session: string): string { + return path.join(SOCKET_DIR, `browse-${session}.local-info`); +} + +// ==================== LOCAL STRATEGY CONFIG ==================== + +type LocalStrategy = "auto" | "isolated" | "cdp"; + +interface LocalConfig { + strategy: LocalStrategy; + cdpTarget?: string; // port number or URL +} + +interface LocalInfo { + localSource: + | "attached-existing" + | "attached-explicit" + | "isolated" + | "isolated-fallback"; + resolvedCdpUrl?: string; + fallbackReason?: string; +} + +async function readLocalConfig(session: string): Promise { + try { + const raw = await fs.readFile(getLocalConfigPath(session), "utf-8"); + return JSON.parse(raw); + } catch { + return { strategy: "auto" }; + } +} + +async function writeLocalConfig( + session: string, + config: LocalConfig, +): Promise { + await fs.writeFile(getLocalConfigPath(session), JSON.stringify(config)); +} + +async function writeLocalInfo( + session: string, + info: LocalInfo, +): Promise { + await fs.writeFile(getLocalInfoPath(session), JSON.stringify(info)); +} + +async function readLocalInfo(session: string): Promise { + try { + const raw = await fs.readFile(getLocalInfoPath(session), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +>>>>>>> 277885da (STG-1668: feat(cli): browse env local auto-discovers existing Chrome via CDP) +} + type BrowseMode = "browserbase" | "local"; function hasBrowserbaseCredentials(): boolean { @@ -201,6 +261,220 @@ async function getDesiredMode(session: string): Promise { return hasBrowserbaseCredentials() ? "browserbase" : "local"; } +// ==================== CDP AUTO-DISCOVERY ==================== + +/** + * Well-known Chrome user-data directories per platform. + * Each may contain a DevToolsActivePort file when Chrome is running with + * remote debugging enabled. + */ +function getChromeUserDataDirs(): string[] { + const home = os.homedir(); + const dirs: string[] = []; + + if (process.platform === "darwin") { + const base = path.join(home, "Library", "Application Support"); + for (const name of [ + "Google/Chrome", + "Google/Chrome Canary", + "Chromium", + "BraveSoftware/Brave-Browser", + ]) { + dirs.push(path.join(base, name)); + } + } else if (process.platform === "linux") { + const config = path.join(home, ".config"); + for (const name of [ + "google-chrome", + "google-chrome-unstable", + "chromium", + "BraveSoftware/Brave-Browser", + ]) { + dirs.push(path.join(config, name)); + } + } + + return dirs; +} + +/** + * Read DevToolsActivePort file from a Chrome user-data directory. + * Returns { port, wsPath } or null if file doesn't exist or is malformed. + */ +async function readDevToolsActivePort( + userDataDir: string, +): Promise<{ port: number; wsPath: string } | null> { + try { + const content = await fs.readFile( + path.join(userDataDir, "DevToolsActivePort"), + "utf-8", + ); + const lines = content.trim().split("\n"); + const port = parseInt(lines[0]?.trim(), 10); + if (isNaN(port) || port <= 0 || port > 65535) return null; + const wsPath = lines[1]?.trim() || "/devtools/browser"; + return { port, wsPath }; + } catch { + return null; + } +} + +/** + * Check if a TCP port is reachable on localhost with a short timeout. + */ +function isPortReachable(port: number, timeoutMs = 500): Promise { + return new Promise((resolve) => { + const sock = net.createConnection({ host: "127.0.0.1", port }); + const timer = setTimeout(() => { + sock.destroy(); + resolve(false); + }, timeoutMs); + sock.on("connect", () => { + clearTimeout(timer); + sock.destroy(); + resolve(true); + }); + sock.on("error", () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +/** + * Probe a CDP endpoint at the given port. + * Tries /json/version first, then falls back to a direct WebSocket handshake + * (needed for Chrome 136+ with UI-based remote debugging). + * Returns the webSocketDebuggerUrl on success, or null. + */ +async function probeCdpEndpoint( + port: number, +): Promise { + // Try /json/version (standard path) + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: controller.signal, + }); + clearTimeout(timer); + if (res.ok) { + const json = (await res.json()) as { webSocketDebuggerUrl?: string }; + if (json.webSocketDebuggerUrl) { + return json.webSocketDebuggerUrl; + } + } + } catch { + // /json/version unavailable + } + + // Fallback: direct WebSocket at /devtools/browser + // Chrome 136+ with chrome://inspect may only expose WS, not HTTP endpoints + const wsUrl = `ws://127.0.0.1:${port}/devtools/browser`; + try { + const verified = await verifyCdpWebSocket(wsUrl); + if (verified) return wsUrl; + } catch { + // WS fallback also failed + } + + return null; +} + +/** + * Verify a WebSocket URL responds to Browser.getVersion. + * Returns true if the endpoint is a valid CDP browser target. + */ +function verifyCdpWebSocket(wsUrl: string): Promise { + return new Promise((resolve) => { + // Use a raw TCP approach to avoid needing a WS library in the CLI. + // We just need to confirm the endpoint accepts a WS upgrade — + // the actual WS handshake is sufficient evidence of a CDP endpoint. + const url = new URL(wsUrl); + const port = parseInt(url.port) || 80; + const sock = net.createConnection({ host: url.hostname, port }); + const timer = setTimeout(() => { + sock.destroy(); + resolve(false); + }, 2000); + + sock.on("connect", () => { + clearTimeout(timer); + sock.destroy(); + // If we can connect to the WS port, we'll trust it as a CDP endpoint. + // Full WS handshake verification is overkill for discovery. + resolve(true); + }); + + sock.on("error", () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + +interface CdpCandidate { + wsUrl: string; + source: string; // e.g. "DevToolsActivePort (Google Chrome)" or "port 9222" +} + +/** + * Discover locally-running Chrome instances with CDP debugging enabled. + * Returns the discovered CDP WebSocket URL, or null with a reason. + * + * Discovery order: + * 1. DevToolsActivePort files in well-known Chrome user-data dirs + * 2. Common debugging ports (9222, 9229) + * + * If multiple healthy candidates are found, returns null (ambiguity). + */ +async function discoverLocalCdp(): Promise<{ + wsUrl: string; + source: string; +} | null> { + const candidates: CdpCandidate[] = []; + + // Phase 1: Scan DevToolsActivePort files + const userDataDirs = getChromeUserDataDirs(); + for (const dir of userDataDirs) { + const info = await readDevToolsActivePort(dir); + if (!info) continue; + + // Verify port is alive + if (!(await isPortReachable(info.port))) { + // Stale file — clean up + try { + await fs.unlink(path.join(dir, "DevToolsActivePort")); + } catch {} + continue; + } + + const wsUrl = await probeCdpEndpoint(info.port); + if (wsUrl) { + const name = path.basename(dir); + candidates.push({ wsUrl, source: `DevToolsActivePort (${name})` }); + } + } + + // Phase 2: Probe common ports (only if DevToolsActivePort yielded nothing) + if (candidates.length === 0) { + for (const port of [9222, 9229]) { + if (!(await isPortReachable(port))) continue; + const wsUrl = await probeCdpEndpoint(port); + if (wsUrl) { + candidates.push({ wsUrl, source: `port ${port}` }); + } + } + } + + // Ambiguity check + if (candidates.length > 1) { + return null; // Caller should fall back to isolated and report ambiguity + } + + return candidates[0] ?? null; +} + async function isDaemonRunning(session: string): Promise { try { const pidFile = getPidPath(session); @@ -226,6 +500,7 @@ const DAEMON_STATE_FILES = (session: string) => [ getChromePidPath(session), getLockPath(session), getModePath(session), + getLocalInfoPath(session), ]; async function cleanupStaleFiles(session: string): Promise { @@ -234,6 +509,7 @@ async function cleanupStaleFiles(session: string): Promise { // Client-written config, only cleaned on full shutdown getContextPath(session), getConnectPath(session), + getLocalConfigPath(session), ]; for (const file of files) { @@ -353,6 +629,43 @@ async function runDaemon(session: string, headless: boolean): Promise { ).trim(); } catch {} + // Resolve local browser launch options based on strategy + let localLaunchOptions: Record | undefined; + let localInfo: LocalInfo | undefined; + + if (!useBrowserbase) { + const localConfig = await readLocalConfig(session); + + if (localConfig.strategy === "isolated") { + localLaunchOptions = { headless, viewport: DEFAULT_VIEWPORT }; + localInfo = { localSource: "isolated" }; + } else if (localConfig.strategy === "cdp") { + // Explicit CDP target — resolve port or URL + const cdpUrl = await resolveWsTarget(localConfig.cdpTarget!); + localLaunchOptions = { cdpUrl }; + localInfo = { + localSource: "attached-explicit", + resolvedCdpUrl: cdpUrl, + }; + } else { + // strategy === "auto": try discovery, fall back to isolated + const discovered = await discoverLocalCdp(); + if (discovered) { + localLaunchOptions = { cdpUrl: discovered.wsUrl }; + localInfo = { + localSource: "attached-existing", + resolvedCdpUrl: discovered.wsUrl, + }; + } else { + localLaunchOptions = { headless, viewport: DEFAULT_VIEWPORT }; + localInfo = { + localSource: "isolated-fallback", + fallbackReason: "no debuggable local browser found", + }; + } + } + } + stagehand = new Stagehand({ env: useBrowserbase ? "BROWSERBASE" : "LOCAL", verbose: 0, @@ -382,15 +695,15 @@ async function runDaemon(session: string, headless: boolean): Promise { : {}), } : { - localBrowserLaunchOptions: { - headless, - viewport: DEFAULT_VIEWPORT, - }, + localBrowserLaunchOptions: localLaunchOptions, }), }); - // Persist mode so status command can report it + // Persist mode and local info so status command can report it await fs.writeFile(getModePath(session), desiredMode); + if (localInfo) { + await writeLocalInfo(session, localInfo); + } await stagehand.init(); @@ -1617,6 +1930,7 @@ program let wsUrl = null; let mode: BrowseMode | null = null; let browserbaseSessionId: string | null = null; + let localDetails: Record = {}; if (running) { try { wsUrl = await fs.readFile(getWsPath(session), "utf-8"); @@ -1627,22 +1941,40 @@ program await fs.readFile(getConnectPath(session), "utf-8") ).trim(); } catch {} + if (mode === "local") { + const localConfig = await readLocalConfig(session); + const localInfo = await readLocalInfo(session); + localDetails = { + localStrategy: localConfig.strategy, + ...(localInfo ?? {}), + }; + } } console.log( - JSON.stringify({ running, session, wsUrl, mode, browserbaseSessionId }), + JSON.stringify({ running, session, wsUrl, mode, browserbaseSessionId, ...localDetails }), ); }); program - .command("env [target]") - .description("Show or switch browser environment (local | remote)") - .action(async (target?: string) => { + .command("env [target] [cdpTarget]") + .description( + "Show or switch browser environment (local | remote)\n\n" + + " browse env Show current environment\n" + + " browse env local Auto-discover local Chrome, fallback to isolated\n" + + " browse env local --isolated Force clean isolated browser\n" + + " browse env local Attach to specific CDP target\n" + + " browse env remote Use Browserbase (requires API key)", + ) + .option("--isolated", "Force isolated local browser (no auto-discovery)") + .action(async (target: string | undefined, cdpTarget: string | undefined, cmdOpts: { isolated?: boolean }) => { const opts = program.opts(); const session = getSession(opts); if (!target) { let mode: string | null = null; const desiredMode = await getDesiredMode(session); + const localConfig = await readLocalConfig(session); + const localInfo = await readLocalInfo(session); if (await isDaemonRunning(session)) { mode = toModeTarget((await readCurrentMode(session)) ?? desiredMode); } @@ -1651,6 +1983,12 @@ program mode: mode ?? "not running", desired: toModeTarget(desiredMode), session, + ...(desiredMode === "local" + ? { + localStrategy: localConfig.strategy, + ...(localInfo ?? {}), + } + : {}), }), ); return; @@ -1662,7 +2000,10 @@ program }; const mapped = modeMap[target]; if (!mapped) { - console.error("Usage: browse env [local|remote]"); + console.error( + "Usage: browse env [local|remote]\n" + + " browse env local [--isolated] []", + ); process.exit(1); } @@ -1673,11 +2014,28 @@ program process.exit(1); } + // Determine local strategy when target is "local" + let localConfig: LocalConfig = { strategy: "auto" }; + if (mapped === "local") { + if (cmdOpts.isolated) { + localConfig = { strategy: "isolated" }; + } else if (cdpTarget) { + localConfig = { strategy: "cdp", cdpTarget }; + } + // else: auto (default) + await writeLocalConfig(session, localConfig); + } + await fs.writeFile(getModeOverridePath(session), mapped); + // Always restart daemon when switching env to pick up new local config if (await isDaemonRunning(session)) { const currentMode = (await readCurrentMode(session)) ?? "local"; - if (currentMode === mapped) { + const needsRestart = + currentMode !== mapped || mapped === "local"; // local always restarts to pick up strategy change + if (!needsRestart) { + // needsRestart is false only when currentMode === mapped && mapped !== "local" + // (local always restarts to pick up strategy changes) console.log( JSON.stringify({ mode: toModeTarget(mapped), @@ -1697,6 +2055,9 @@ program mode: toModeTarget(mapped), session, restarted: true, + ...(mapped === "local" + ? { localStrategy: localConfig.strategy } + : {}), }), ); }); From bf8c8aa4bb52b26fe1b1261ccb40c08c06234427 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 20:52:21 -0700 Subject: [PATCH 04/12] fix: preserve local-config across daemon restarts The daemon's shutdown handler and stopDaemonAndCleanup were both calling cleanupStaleFiles which deleted client-written config (local-config, context). Switch both to cleanupDaemonStateFiles so local-config and context survive daemon restarts triggered by browse env switches. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b151288d6..0c5c3f456 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -787,7 +787,8 @@ async function runDaemon(session: string, headless: boolean): Promise { } } catch {} - await cleanupStaleFiles(session); + // Only clean daemon state, not client-written config (local-config, context, mode-override) + await cleanupDaemonStateFiles(session); process.exit(0); }; @@ -1688,7 +1689,8 @@ async function stopDaemonAndCleanup(session: string): Promise { // Daemon may already be down. } await new Promise((r) => setTimeout(r, 500)); - await cleanupStaleFiles(session); + // Only clean daemon state files, not client-written config (local-config, context, mode-override) + await cleanupDaemonStateFiles(session); } async function ensureDaemon(session: string, headless: boolean): Promise { From 61c9bdd56e8a8a8e18944abd985bdb91dd10d508 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 20:56:42 -0700 Subject: [PATCH 05/12] fix: CDP endpoint validation and fallback URL - resolve-ws.ts: fallback URL now includes /devtools/browser path (bare ws://host:port is not a valid CDP endpoint) - verifyCdpWebSocket: replaced TCP-only check with actual HTTP WebSocket upgrade verification (101 Switching Protocols) to avoid accepting non-CDP services as valid endpoints Addresses cubic-dev-ai review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 43 ++++++++++++++++++++------- packages/cli/src/resolve-ws.ts | 2 +- packages/cli/tests/resolve-ws.test.ts | 4 +-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0c5c3f456..041044dab 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -382,28 +382,51 @@ async function probeCdpEndpoint( } /** - * Verify a WebSocket URL responds to Browser.getVersion. - * Returns true if the endpoint is a valid CDP browser target. + * Verify a WebSocket URL is a valid CDP endpoint by attempting an HTTP upgrade. + * Sends a minimal WebSocket handshake and checks for a 101 Switching Protocols response. */ function verifyCdpWebSocket(wsUrl: string): Promise { return new Promise((resolve) => { - // Use a raw TCP approach to avoid needing a WS library in the CLI. - // We just need to confirm the endpoint accepts a WS upgrade — - // the actual WS handshake is sufficient evidence of a CDP endpoint. const url = new URL(wsUrl); const port = parseInt(url.port) || 80; + const wsKey = Buffer.from( + Array.from({ length: 16 }, () => Math.floor(Math.random() * 256)), + ).toString("base64"); + const sock = net.createConnection({ host: url.hostname, port }); + let response = ""; + const timer = setTimeout(() => { sock.destroy(); resolve(false); }, 2000); sock.on("connect", () => { - clearTimeout(timer); - sock.destroy(); - // If we can connect to the WS port, we'll trust it as a CDP endpoint. - // Full WS handshake verification is overkill for discovery. - resolve(true); + // Send a WebSocket upgrade request + sock.write( + `GET ${url.pathname} HTTP/1.1\r\n` + + `Host: ${url.hostname}:${port}\r\n` + + `Upgrade: websocket\r\n` + + `Connection: Upgrade\r\n` + + `Sec-WebSocket-Key: ${wsKey}\r\n` + + `Sec-WebSocket-Version: 13\r\n` + + `\r\n`, + ); + }); + + sock.on("data", (data) => { + response += data.toString(); + // Check for successful WebSocket upgrade (101 Switching Protocols) + if (response.includes("101")) { + clearTimeout(timer); + sock.destroy(); + resolve(true); + } else if (response.includes("\r\n\r\n")) { + // Got a complete HTTP response that isn't 101 + clearTimeout(timer); + sock.destroy(); + resolve(false); + } }); sock.on("error", () => { diff --git a/packages/cli/src/resolve-ws.ts b/packages/cli/src/resolve-ws.ts index 2be3f1609..7ddcaa324 100644 --- a/packages/cli/src/resolve-ws.ts +++ b/packages/cli/src/resolve-ws.ts @@ -20,7 +20,7 @@ export async function resolveWsTarget(input: string): Promise { } catch { // /json/version unavailable — fall back to a conventional WS URL } - return `ws://127.0.0.1:${port}`; + return `ws://127.0.0.1:${port}/devtools/browser`; } // Already a URL — use as-is return input; diff --git a/packages/cli/tests/resolve-ws.test.ts b/packages/cli/tests/resolve-ws.test.ts index a6ccd279f..6822538d4 100644 --- a/packages/cli/tests/resolve-ws.test.ts +++ b/packages/cli/tests/resolve-ws.test.ts @@ -38,9 +38,9 @@ describe("resolveWsTarget", () => { expect(result).toBe(`ws://127.0.0.1:${port}/devtools/browser/abc123`); }); - it("falls back to ws://127.0.0.1:{port} when /json/version is unavailable", async () => { + it("falls back to ws://127.0.0.1:{port}/devtools/browser when /json/version is unavailable", async () => { const result = await resolveWsTarget("19999"); - expect(result).toBe("ws://127.0.0.1:19999"); + expect(result).toBe("ws://127.0.0.1:19999/devtools/browser"); }); it("passes through ws:// URLs as-is", async () => { From e2c2d69c06ea31831823e23181b18abb5b778ecc Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 21:12:32 -0700 Subject: [PATCH 06/12] style: format with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 182 +++++++++++++++++++------------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 041044dab..b90f33c90 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -203,10 +203,7 @@ async function writeLocalConfig( await fs.writeFile(getLocalConfigPath(session), JSON.stringify(config)); } -async function writeLocalInfo( - session: string, - info: LocalInfo, -): Promise { +async function writeLocalInfo(session: string, info: LocalInfo): Promise { await fs.writeFile(getLocalInfoPath(session), JSON.stringify(info)); } @@ -347,9 +344,7 @@ function isPortReachable(port: number, timeoutMs = 500): Promise { * (needed for Chrome 136+ with UI-based remote debugging). * Returns the webSocketDebuggerUrl on success, or null. */ -async function probeCdpEndpoint( - port: number, -): Promise { +async function probeCdpEndpoint(port: number): Promise { // Try /json/version (standard path) try { const controller = new AbortController(); @@ -1991,101 +1986,106 @@ program " browse env remote Use Browserbase (requires API key)", ) .option("--isolated", "Force isolated local browser (no auto-discovery)") - .action(async (target: string | undefined, cdpTarget: string | undefined, cmdOpts: { isolated?: boolean }) => { - const opts = program.opts(); - const session = getSession(opts); + .action( + async ( + target: string | undefined, + cdpTarget: string | undefined, + cmdOpts: { isolated?: boolean }, + ) => { + const opts = program.opts(); + const session = getSession(opts); - if (!target) { - let mode: string | null = null; - const desiredMode = await getDesiredMode(session); - const localConfig = await readLocalConfig(session); - const localInfo = await readLocalInfo(session); - if (await isDaemonRunning(session)) { - mode = toModeTarget((await readCurrentMode(session)) ?? desiredMode); + if (!target) { + let mode: string | null = null; + const desiredMode = await getDesiredMode(session); + const localConfig = await readLocalConfig(session); + const localInfo = await readLocalInfo(session); + if (await isDaemonRunning(session)) { + mode = toModeTarget((await readCurrentMode(session)) ?? desiredMode); + } + console.log( + JSON.stringify({ + mode: mode ?? "not running", + desired: toModeTarget(desiredMode), + session, + ...(desiredMode === "local" + ? { + localStrategy: localConfig.strategy, + ...(localInfo ?? {}), + } + : {}), + }), + ); + return; } - console.log( - JSON.stringify({ - mode: mode ?? "not running", - desired: toModeTarget(desiredMode), - session, - ...(desiredMode === "local" - ? { - localStrategy: localConfig.strategy, - ...(localInfo ?? {}), - } - : {}), - }), - ); - return; - } - const modeMap: Record = { - local: "local", - remote: "browserbase", - }; - const mapped = modeMap[target]; - if (!mapped) { - console.error( - "Usage: browse env [local|remote]\n" + - " browse env local [--isolated] []", - ); - process.exit(1); - } + const modeMap: Record = { + local: "local", + remote: "browserbase", + }; + const mapped = modeMap[target]; + if (!mapped) { + console.error( + "Usage: browse env [local|remote]\n" + + " browse env local [--isolated] []", + ); + process.exit(1); + } - try { - assertModeSupported(mapped); - } catch (err) { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } + try { + assertModeSupported(mapped); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } - // Determine local strategy when target is "local" - let localConfig: LocalConfig = { strategy: "auto" }; - if (mapped === "local") { - if (cmdOpts.isolated) { - localConfig = { strategy: "isolated" }; - } else if (cdpTarget) { - localConfig = { strategy: "cdp", cdpTarget }; + // Determine local strategy when target is "local" + let localConfig: LocalConfig = { strategy: "auto" }; + if (mapped === "local") { + if (cmdOpts.isolated) { + localConfig = { strategy: "isolated" }; + } else if (cdpTarget) { + localConfig = { strategy: "cdp", cdpTarget }; + } + // else: auto (default) + await writeLocalConfig(session, localConfig); } - // else: auto (default) - await writeLocalConfig(session, localConfig); - } - await fs.writeFile(getModeOverridePath(session), mapped); + await fs.writeFile(getModeOverridePath(session), mapped); - // Always restart daemon when switching env to pick up new local config - if (await isDaemonRunning(session)) { - const currentMode = (await readCurrentMode(session)) ?? "local"; - const needsRestart = - currentMode !== mapped || mapped === "local"; // local always restarts to pick up strategy change - if (!needsRestart) { - // needsRestart is false only when currentMode === mapped && mapped !== "local" - // (local always restarts to pick up strategy changes) - console.log( - JSON.stringify({ - mode: toModeTarget(mapped), - session, - restarted: false, - }), - ); - return; + // Always restart daemon when switching env to pick up new local config + if (await isDaemonRunning(session)) { + const currentMode = (await readCurrentMode(session)) ?? "local"; + const needsRestart = currentMode !== mapped || mapped === "local"; // local always restarts to pick up strategy change + if (!needsRestart) { + // needsRestart is false only when currentMode === mapped && mapped !== "local" + // (local always restarts to pick up strategy changes) + console.log( + JSON.stringify({ + mode: toModeTarget(mapped), + session, + restarted: false, + }), + ); + return; + } + await stopDaemonAndCleanup(session); } - await stopDaemonAndCleanup(session); - } - await ensureDaemon(session, isHeadless(opts)); + await ensureDaemon(session, isHeadless(opts)); - console.log( - JSON.stringify({ - mode: toModeTarget(mapped), - session, - restarted: true, - ...(mapped === "local" - ? { localStrategy: localConfig.strategy } - : {}), - }), - ); - }); + console.log( + JSON.stringify({ + mode: toModeTarget(mapped), + session, + restarted: true, + ...(mapped === "local" + ? { localStrategy: localConfig.strategy } + : {}), + }), + ); + }, + ); program .command("refs") From ebfe0191dbeb20ec155ee24c7cef635d2cc0a31c Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 23:16:58 -0700 Subject: [PATCH 07/12] fix: tighten WebSocket upgrade check to match HTTP status line Use regex /^HTTP\/1\.[01] 101/ instead of response.includes("101") to avoid false positives from non-upgrade responses containing "101". Addresses cubic-dev-ai review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b90f33c90..6c20c01c0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -412,7 +412,7 @@ function verifyCdpWebSocket(wsUrl: string): Promise { sock.on("data", (data) => { response += data.toString(); // Check for successful WebSocket upgrade (101 Switching Protocols) - if (response.includes("101")) { + if (/^HTTP\/1\.[01] 101(?:\s|$)/.test(response)) { clearTimeout(timer); sock.destroy(); resolve(true); From dc8adda42da0f8b25ed9d729fbfd90a1c7e8924a Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 24 Mar 2026 23:59:11 -0700 Subject: [PATCH 08/12] chore: consolidate changesets into single minor The --ws port resolution was an earlier commit that's part of the larger auto-discover feature. One minor changeset covers both. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/cli-local-auto-discover.md | 2 +- .changeset/ws-port-resolve.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/ws-port-resolve.md diff --git a/.changeset/cli-local-auto-discover.md b/.changeset/cli-local-auto-discover.md index 522ea5855..35b5daeee 100644 --- a/.changeset/cli-local-auto-discover.md +++ b/.changeset/cli-local-auto-discover.md @@ -2,4 +2,4 @@ "@browserbasehq/browse-cli": minor --- -browse env local now auto-discovers existing Chrome instances with remote debugging enabled, attaching to them instead of always launching an isolated browser. Falls back to isolated launch when no debuggable Chrome is found. Added --isolated flag and positional CDP target argument to browse env local. +browse env local now auto-discovers existing Chrome instances with remote debugging enabled, attaching to them instead of always launching an isolated browser. Falls back to isolated launch when no debuggable Chrome is found. Added --isolated flag, positional CDP target argument, and --ws now accepts bare port numbers. diff --git a/.changeset/ws-port-resolve.md b/.changeset/ws-port-resolve.md deleted file mode 100644 index dd9f6fe9b..000000000 --- a/.changeset/ws-port-resolve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@browserbasehq/browse-cli": patch ---- - -Allow `--ws` to accept a bare port number (e.g. `--ws 9222`) in addition to full WebSocket URLs. When a port is given, the CLI resolves the CDP WebSocket URL via `/json/version`. From 7b7ae49e2f67a4ba85343f52abcd15e1a9933e3e Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 27 Mar 2026 00:02:28 -0700 Subject: [PATCH 09/12] fix: remove stray merge conflict marker in cli/src/index.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6c20c01c0..2c5d46335 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -214,7 +214,6 @@ async function readLocalInfo(session: string): Promise { } catch { return null; } ->>>>>>> 277885da (STG-1668: feat(cli): browse env local auto-discovers existing Chrome via CDP) } type BrowseMode = "browserbase" | "local"; From 6661e98f81b7100e34fe3b50e871673b929ed6f1 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 27 Mar 2026 10:11:20 -0700 Subject: [PATCH 10/12] fix: format index.ts with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2c5d46335..be6081fb0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1970,7 +1970,14 @@ program } } console.log( - JSON.stringify({ running, session, wsUrl, mode, browserbaseSessionId, ...localDetails }), + JSON.stringify({ + running, + session, + wsUrl, + mode, + browserbaseSessionId, + ...localDetails, + }), ); }); From 65c74f7f7fc1d81a409e1689ceac29a046d1256c Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 27 Mar 2026 14:51:23 -0700 Subject: [PATCH 11/12] docs(cli): update CDP instructions to use chrome://inspect method Replace the `google-chrome --remote-debugging-port=9222` instructions with the newer chrome://inspect/#remote-debugging approach per reviewer feedback, since the flag doesn't work with the default data dir. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1815dfdf7..ebed11ced 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -284,16 +284,21 @@ browse --session personal open https://twitter.com Connect to an existing Chrome instance: -```bash -# Start Chrome with remote debugging -google-chrome --remote-debugging-port=9222 +To make your Chrome discoverable: -# Option 1: Persistent session (recommended) — daemon stays attached -browse env local 9222 +1. Open `chrome://inspect/#remote-debugging` +2. Check the box **"Allow remote debugging for this browser instance"** +3. Re-run the CLI and it will auto-connect! + +For more information, see the [Chrome DevTools docs](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session). + +```bash +# Auto-discover Chrome with remote debugging enabled +browse env local browse open https://example.com -# Option 2: One-shot bypass (no daemon, per-command) -browse --ws 9222 open https://example.com +# Or target a specific port / WebSocket URL +browse env local 9222 browse --ws ws://localhost:9222/devtools/browser/... open https://example.com ``` From 1863297cf7d4ad269338a7a28e38755586f2ee89 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 27 Mar 2026 14:51:48 -0700 Subject: [PATCH 12/12] docs(cli): replace remaining --remote-debugging-port with chrome://inspect Second occurrence in the Local Browser Strategies section. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index ebed11ced..6ce4a2e36 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -199,11 +199,12 @@ Auto-discovery checks: 1. `DevToolsActivePort` files in well-known Chrome/Chromium/Brave user-data directories 2. Common debugging ports (9222, 9229) -To make your Chrome discoverable, launch it with `--remote-debugging-port`: +To make your Chrome discoverable: -```bash -google-chrome --remote-debugging-port=9222 -``` +1. Open `chrome://inspect/#remote-debugging` +2. Check the box **"Allow remote debugging for this browser instance"** + +For more information, see the [Chrome DevTools docs](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session). Use `browse status` to see which strategy was resolved: