From b7c14279ab905dba083f5d85ff3a62b1e5d52c65 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 1 Apr 2026 14:02:48 -0700 Subject: [PATCH 1/3] fix(cli): default browse env local to isolated --- packages/cli/README.md | 23 +- packages/cli/src/index.ts | 282 +++++++++++----------- packages/cli/src/local-strategy.ts | 97 ++++++++ packages/cli/tests/cli.test.ts | 2 + packages/cli/tests/connect.test.ts | 2 + packages/cli/tests/local-strategy.test.ts | 105 ++++++++ packages/cli/tests/mode.test.ts | 69 ++++++ 7 files changed, 423 insertions(+), 157 deletions(-) create mode 100644 packages/cli/src/local-strategy.ts create mode 100644 packages/cli/tests/local-strategy.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 6ce4a2e361..2aa41f0c75 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -173,22 +173,23 @@ browse env # Switch current session to Browserbase (restarts daemon if needed) browse env remote -# Switch back to local Chrome (auto-discovers existing Chrome, falls back to isolated) +# Switch back to local Chrome (clean isolated browser by default) browse env local ``` #### 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. +By default, `browse env local` launches a clean isolated local browser. +Use `browse env local --auto-connect` to opt into reusing an already-running +Chrome with remote debugging enabled. If no debuggable Chrome is found, it +falls back to launching an isolated browser. ```bash -# Auto-discover local Chrome, fallback to isolated (default) +# Use a clean isolated browser (default) browse env local -# Force a clean isolated browser (no auto-discovery) -browse env local --isolated +# Auto-discover local Chrome, fallback to isolated +browse env local --auto-connect # Attach to a specific CDP target (port or URL) browse env local 9222 @@ -210,7 +211,7 @@ 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://..."} +# {"running":true,"session":"default","mode":"local","localStrategy":"isolated","localSource":"isolated"} ``` #### General Behavior @@ -283,19 +284,19 @@ browse --session personal open https://twitter.com ## Direct CDP Connection -Connect to an existing Chrome instance: +Opt into using an existing Chrome instance: To make your Chrome discoverable: 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! +3. Re-run the CLI with auto-connect enabled. 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 env local --auto-connect browse open https://example.com # Or target a specific port / WebSocket URL diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e20c06791c..38ce5aa526 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,7 +8,7 @@ * Multiple sessions can run simultaneously using --session or BROWSE_SESSION env var. */ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { Stagehand, type Page as BrowsePage } from "@browserbasehq/stagehand"; import { promises as fs } from "fs"; import * as path from "path"; @@ -18,6 +18,14 @@ import { spawn } from "child_process"; import * as readline from "readline"; import type { Protocol } from "devtools-protocol"; import { version as VERSION } from "../package.json"; +import { + DEFAULT_LOCAL_CONFIG, + type LocalBrowserLaunchOptions, + type LocalCdpDiscovery, + type LocalConfig, + type LocalInfo, + resolveLocalStrategy, +} from "./local-strategy"; import { resolveWsTarget } from "./resolve-ws"; import { NodeHtmlMarkdown } from "node-html-markdown"; @@ -171,29 +179,12 @@ function getLocalInfoPath(session: string): string { // ==================== 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" }; + return { ...DEFAULT_LOCAL_CONFIG }; } } @@ -446,10 +437,7 @@ interface CdpCandidate { * * If multiple healthy candidates are found, returns null (ambiguity). */ -async function discoverLocalCdp(): Promise<{ - wsUrl: string; - source: string; -} | null> { +async function discoverLocalCdp(): Promise { const candidates: CdpCandidate[] = []; // Phase 1: Scan DevToolsActivePort files @@ -648,40 +636,19 @@ async function runDaemon(session: string, headless: boolean): Promise { } catch {} // Resolve local browser launch options based on strategy - let localLaunchOptions: Record | undefined; + let localLaunchOptions: LocalBrowserLaunchOptions | 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", - }; - } - } + const resolvedLocalStrategy = await resolveLocalStrategy({ + localConfig: await readLocalConfig(session), + headless, + defaultViewport: DEFAULT_VIEWPORT, + discoverLocalCdp, + resolveWsTarget, + }); + localLaunchOptions = resolvedLocalStrategy.localLaunchOptions; + localInfo = resolvedLocalStrategy.localInfo; } stagehand = new Stagehand({ @@ -1987,117 +1954,140 @@ program ); }); -program +const envUsage = + "Usage: browse env [local|remote]\n" + + " browse env local [--auto-connect|]"; + +const envCommand = program .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)", + " browse env Show current environment\n" + + " browse env local Use clean isolated local browser (default)\n" + + " browse env local --auto-connect Auto-discover local Chrome, fallback to isolated\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); + .option( + "--auto-connect", + "Auto-discover an existing local Chrome instance via CDP", + ); - 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; +envCommand.addOption( + new Option( + "--isolated", + "Deprecated alias for the default isolated local browser", + ).hideHelp(), +); + +envCommand.action( + async ( + target: string | undefined, + cdpTarget: string | undefined, + cmdOpts: { autoConnect?: boolean; 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); } + 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) { + const modeMap: Record = { + local: "local", + remote: "browserbase", + }; + const mapped = modeMap[target]; + if (!mapped) { + console.error(envUsage); + process.exit(1); + } + + try { + assertModeSupported(mapped); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + let localConfig: LocalConfig = { ...DEFAULT_LOCAL_CONFIG }; + if (mapped === "local") { + const selectedLocalStrategies = [ + Boolean(cmdOpts.autoConnect), + Boolean(cmdOpts.isolated), + Boolean(cdpTarget), + ].filter(Boolean); + + if (selectedLocalStrategies.length > 1) { + console.error(envUsage); console.error( - "Usage: browse env [local|remote]\n" + - " browse env local [--isolated] []", + "Use only one of --auto-connect, --isolated, or .", ); process.exit(1); } - try { - assertModeSupported(mapped); - } catch (err) { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); + if (cmdOpts.autoConnect) { + localConfig = { strategy: "auto" }; + } 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); - } + 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; - } - await stopDaemonAndCleanup(session); + // 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 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") diff --git a/packages/cli/src/local-strategy.ts b/packages/cli/src/local-strategy.ts new file mode 100644 index 0000000000..8788282b82 --- /dev/null +++ b/packages/cli/src/local-strategy.ts @@ -0,0 +1,97 @@ +export type LocalStrategy = "auto" | "isolated" | "cdp"; + +export interface LocalConfig { + strategy: LocalStrategy; + cdpTarget?: string; +} + +export interface LocalInfo { + localSource: + | "attached-existing" + | "attached-explicit" + | "isolated" + | "isolated-fallback"; + resolvedCdpUrl?: string; + fallbackReason?: string; +} + +export interface LocalBrowserLaunchOptions { + cdpUrl?: string; + headless?: boolean; + viewport?: { + width: number; + height: number; + }; +} + +export interface LocalCdpDiscovery { + wsUrl: string; + source: string; +} + +interface ResolveLocalStrategyOptions { + localConfig: LocalConfig; + headless: boolean; + defaultViewport: { + width: number; + height: number; + }; + discoverLocalCdp: () => Promise; + resolveWsTarget: (input: string) => Promise; +} + +export interface ResolvedLocalStrategy { + localLaunchOptions: LocalBrowserLaunchOptions; + localInfo: LocalInfo; +} + +export const DEFAULT_LOCAL_CONFIG: LocalConfig = { strategy: "isolated" }; + +export async function resolveLocalStrategy({ + localConfig, + headless, + defaultViewport, + discoverLocalCdp, + resolveWsTarget, +}: ResolveLocalStrategyOptions): Promise { + if (localConfig.strategy === "isolated") { + return { + localLaunchOptions: { headless, viewport: defaultViewport }, + localInfo: { localSource: "isolated" }, + }; + } + + if (localConfig.strategy === "cdp") { + if (!localConfig.cdpTarget) { + throw new Error("Local CDP strategy requires a cdpTarget"); + } + + const cdpUrl = await resolveWsTarget(localConfig.cdpTarget); + return { + localLaunchOptions: { cdpUrl }, + localInfo: { + localSource: "attached-explicit", + resolvedCdpUrl: cdpUrl, + }, + }; + } + + const discovered = await discoverLocalCdp(); + if (discovered) { + return { + localLaunchOptions: { cdpUrl: discovered.wsUrl }, + localInfo: { + localSource: "attached-existing", + resolvedCdpUrl: discovered.wsUrl, + }, + }; + } + + return { + localLaunchOptions: { headless, viewport: defaultViewport }, + localInfo: { + localSource: "isolated-fallback", + fallbackReason: "no debuggable local browser found", + }, + }; +} diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 299548f872..08337fbf74 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -62,6 +62,8 @@ async function cleanupSession(session: string): Promise { `browse-${session}.chrome.pid`, `browse-${session}.mode`, `browse-${session}.mode-override`, + `browse-${session}.local-config`, + `browse-${session}.local-info`, ]; for (const pattern of patterns) { diff --git a/packages/cli/tests/connect.test.ts b/packages/cli/tests/connect.test.ts index 95d815150c..461d7dad6e 100644 --- a/packages/cli/tests/connect.test.ts +++ b/packages/cli/tests/connect.test.ts @@ -45,6 +45,8 @@ async function cleanupSession(session: string): Promise { `browse-${session}.mode-override`, `browse-${session}.context`, `browse-${session}.connect`, + `browse-${session}.local-config`, + `browse-${session}.local-info`, ]; for (const pattern of patterns) { diff --git a/packages/cli/tests/local-strategy.test.ts b/packages/cli/tests/local-strategy.test.ts new file mode 100644 index 0000000000..07e83fd602 --- /dev/null +++ b/packages/cli/tests/local-strategy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_LOCAL_CONFIG, + resolveLocalStrategy, +} from "../src/local-strategy"; + +const DEFAULT_VIEWPORT = { width: 1288, height: 711 }; + +describe("resolveLocalStrategy", () => { + it("uses an isolated browser by default", async () => { + const discoverLocalCdp = vi.fn().mockResolvedValue(null); + const resolveWsTarget = vi.fn(); + + const result = await resolveLocalStrategy({ + localConfig: DEFAULT_LOCAL_CONFIG, + headless: true, + defaultViewport: DEFAULT_VIEWPORT, + discoverLocalCdp, + resolveWsTarget, + }); + + expect(result.localLaunchOptions).toEqual({ + headless: true, + viewport: DEFAULT_VIEWPORT, + }); + expect(result.localInfo).toEqual({ localSource: "isolated" }); + expect(discoverLocalCdp).not.toHaveBeenCalled(); + expect(resolveWsTarget).not.toHaveBeenCalled(); + }); + + it("auto-connects to a discovered local browser when requested", async () => { + const discoverLocalCdp = vi.fn().mockResolvedValue({ + wsUrl: "ws://127.0.0.1:9222/devtools/browser/abc123", + source: "port 9222", + }); + const resolveWsTarget = vi.fn(); + + const result = await resolveLocalStrategy({ + localConfig: { strategy: "auto" }, + headless: true, + defaultViewport: DEFAULT_VIEWPORT, + discoverLocalCdp, + resolveWsTarget, + }); + + expect(result.localLaunchOptions).toEqual({ + cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc123", + }); + expect(result.localInfo).toEqual({ + localSource: "attached-existing", + resolvedCdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc123", + }); + expect(discoverLocalCdp).toHaveBeenCalledTimes(1); + expect(resolveWsTarget).not.toHaveBeenCalled(); + }); + + it("falls back to isolated launch when auto-connect finds nothing", async () => { + const discoverLocalCdp = vi.fn().mockResolvedValue(null); + const resolveWsTarget = vi.fn(); + + const result = await resolveLocalStrategy({ + localConfig: { strategy: "auto" }, + headless: false, + defaultViewport: DEFAULT_VIEWPORT, + discoverLocalCdp, + resolveWsTarget, + }); + + expect(result.localLaunchOptions).toEqual({ + headless: false, + viewport: DEFAULT_VIEWPORT, + }); + expect(result.localInfo).toEqual({ + localSource: "isolated-fallback", + fallbackReason: "no debuggable local browser found", + }); + expect(discoverLocalCdp).toHaveBeenCalledTimes(1); + expect(resolveWsTarget).not.toHaveBeenCalled(); + }); + + it("resolves an explicit CDP target without discovery", async () => { + const discoverLocalCdp = vi.fn(); + const resolveWsTarget = vi + .fn() + .mockResolvedValue("ws://127.0.0.1:9229/devtools/browser/xyz789"); + + const result = await resolveLocalStrategy({ + localConfig: { strategy: "cdp", cdpTarget: "9229" }, + headless: true, + defaultViewport: DEFAULT_VIEWPORT, + discoverLocalCdp, + resolveWsTarget, + }); + + expect(result.localLaunchOptions).toEqual({ + cdpUrl: "ws://127.0.0.1:9229/devtools/browser/xyz789", + }); + expect(result.localInfo).toEqual({ + localSource: "attached-explicit", + resolvedCdpUrl: "ws://127.0.0.1:9229/devtools/browser/xyz789", + }); + expect(discoverLocalCdp).not.toHaveBeenCalled(); + expect(resolveWsTarget).toHaveBeenCalledWith("9229"); + }); +}); diff --git a/packages/cli/tests/mode.test.ts b/packages/cli/tests/mode.test.ts index 3f803da4a7..56e6268d79 100644 --- a/packages/cli/tests/mode.test.ts +++ b/packages/cli/tests/mode.test.ts @@ -43,6 +43,8 @@ async function cleanupSession(session: string): Promise { `browse-${session}.chrome.pid`, `browse-${session}.mode`, `browse-${session}.mode-override`, + `browse-${session}.local-config`, + `browse-${session}.local-info`, ]; for (const pattern of patterns) { @@ -77,6 +79,21 @@ describe("Browse CLI env command", () => { expect(["local", "remote"]).toContain(data.desired); }); + it("shows isolated local strategy by default when local is desired", async () => { + const result = await browse("env", { + env: { + ...process.env, + BROWSERBASE_API_KEY: "", + }, + }); + expect(result.exitCode).toBe(0); + + const data = parseJson(result.stdout); + expect(data.mode).toBe("not running"); + expect(data.desired).toBe("local"); + expect(data.localStrategy).toBe("isolated"); + }); + it("rejects unsupported env target", async () => { const result = await browse("env invalid-target"); expect(result.exitCode).not.toBe(0); @@ -93,4 +110,56 @@ describe("Browse CLI env command", () => { expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Remote mode requires BROWSERBASE_API_KEY"); }); + + it("defaults browse env local to isolated strategy", async () => { + const result = await browse("env local"); + expect(result.exitCode).toBe(0); + + const data = parseJson(result.stdout); + expect(data.mode).toBe("local"); + expect(data.localStrategy).toBe("isolated"); + + const status = parseJson((await browse("status")).stdout); + expect(status.running).toBe(true); + expect(status.mode).toBe("local"); + expect(status.localStrategy).toBe("isolated"); + }); + + it("uses auto strategy only when --auto-connect is passed", async () => { + const result = await browse("env local --auto-connect"); + expect(result.exitCode).toBe(0); + + const data = parseJson(result.stdout); + expect(data.mode).toBe("local"); + expect(data.localStrategy).toBe("auto"); + + const status = parseJson((await browse("status")).stdout); + expect(status.running).toBe(true); + expect(status.mode).toBe("local"); + expect(status.localStrategy).toBe("auto"); + }); + + it("stores explicit CDP strategy when a target is provided", async () => { + const result = await browse("env local 9222"); + expect(result.exitCode).toBe(0); + + const data = parseJson(result.stdout); + expect(data.mode).toBe("local"); + expect(data.localStrategy).toBe("cdp"); + + const status = parseJson((await browse("status")).stdout); + expect(status.running).toBe(true); + expect(status.mode).toBe("local"); + expect(status.localStrategy).toBe("cdp"); + }); + + it("rejects conflicting local strategy options", async () => { + const withAlias = await browse("env local --auto-connect --isolated"); + expect(withAlias.exitCode).not.toBe(0); + expect(withAlias.stderr).toContain("Use only one of"); + + const withTarget = await browse("env local --auto-connect 9222"); + expect(withTarget.exitCode).not.toBe(0); + expect(withTarget.stderr).toContain("Use only one of"); + }); }); From a4164ee75351b0ebd0697d3682a039c2752b8587 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 1 Apr 2026 14:06:22 -0700 Subject: [PATCH 2/3] chore(changeset): add browse-cli minor release note --- .changeset/soft-zebras-teach.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-zebras-teach.md diff --git a/.changeset/soft-zebras-teach.md b/.changeset/soft-zebras-teach.md new file mode 100644 index 0000000000..171bbd4295 --- /dev/null +++ b/.changeset/soft-zebras-teach.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/browse-cli": minor +--- + +Default `browse env local` back to an isolated browser, add `--auto-connect` as the opt-in path for attaching to an existing debuggable Chrome, and keep explicit CDP attach via `browse env local `. From a132cb182c5387cd58ae31f366f93bcf1f5cceab Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 1 Apr 2026 14:22:19 -0700 Subject: [PATCH 3/3] fix(cli): add local env mode hints --- packages/cli/src/index.ts | 36 ++++++++++++++++++++- packages/cli/src/local-strategy.ts | 31 ++++++++++++++++++ packages/cli/tests/local-strategy.test.ts | 39 +++++++++++++++++++++++ packages/cli/tests/mode.test.ts | 37 +++++++++++++++++++-- 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 38ce5aa526..778b6a18f2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ import type { Protocol } from "devtools-protocol"; import { version as VERSION } from "../package.json"; import { DEFAULT_LOCAL_CONFIG, + getLocalModeHint, type LocalBrowserLaunchOptions, type LocalCdpDiscovery, type LocalConfig, @@ -208,6 +209,33 @@ async function readLocalInfo(session: string): Promise { } } +async function waitForLocalInfo( + session: string, + timeoutMs: number = 1500, +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const localInfo = await readLocalInfo(session); + if (localInfo) { + return localInfo; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + return readLocalInfo(session); +} + +function logLocalModeHint( + localConfig: LocalConfig, + localInfo?: LocalInfo | null, +): void { + const hint = getLocalModeHint(localConfig, localInfo); + if (hint) { + console.error(hint); + } +} + type BrowseMode = "browserbase" | "local"; function hasBrowserbaseCredentials(): boolean { @@ -1935,7 +1963,9 @@ program } catch {} if (mode === "local") { const localConfig = await readLocalConfig(session); - const localInfo = await readLocalInfo(session); + const localInfo = + (await readLocalInfo(session)) ?? (await waitForLocalInfo(session)); + logLocalModeHint(localConfig, localInfo); localDetails = { localStrategy: localConfig.strategy, ...(localInfo ?? {}), @@ -2078,6 +2108,10 @@ envCommand.action( await ensureDaemon(session, isHeadless(opts)); + if (mapped === "local") { + logLocalModeHint(localConfig, await waitForLocalInfo(session)); + } + console.log( JSON.stringify({ mode: toModeTarget(mapped), diff --git a/packages/cli/src/local-strategy.ts b/packages/cli/src/local-strategy.ts index 8788282b82..f6e2f68f24 100644 --- a/packages/cli/src/local-strategy.ts +++ b/packages/cli/src/local-strategy.ts @@ -47,6 +47,37 @@ export interface ResolvedLocalStrategy { export const DEFAULT_LOCAL_CONFIG: LocalConfig = { strategy: "isolated" }; +const ISOLATED_MODE_HINT = + "Hint: Run `browse env local --auto-connect` to reuse your local browsing credentials and cookies."; +const ATTACHED_EXISTING_HINT = + "Hint: Run `browse env local` without `--auto-connect` to switch back to an isolated Chromium browser."; + +export function getLocalModeHint( + localConfig: LocalConfig, + localInfo?: LocalInfo | null, +): string | null { + if (localInfo?.localSource === "attached-existing") { + return ATTACHED_EXISTING_HINT; + } + + if (localInfo?.localSource === "isolated-fallback") { + return null; + } + + if (localConfig.strategy === "auto" && !localInfo) { + return ATTACHED_EXISTING_HINT; + } + + if ( + localInfo?.localSource === "isolated" || + (localConfig.strategy === "isolated" && !localInfo) + ) { + return ISOLATED_MODE_HINT; + } + + return null; +} + export async function resolveLocalStrategy({ localConfig, headless, diff --git a/packages/cli/tests/local-strategy.test.ts b/packages/cli/tests/local-strategy.test.ts index 07e83fd602..2244cb95de 100644 --- a/packages/cli/tests/local-strategy.test.ts +++ b/packages/cli/tests/local-strategy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_LOCAL_CONFIG, + getLocalModeHint, resolveLocalStrategy, } from "../src/local-strategy"; @@ -103,3 +104,41 @@ describe("resolveLocalStrategy", () => { expect(resolveWsTarget).toHaveBeenCalledWith("9229"); }); }); + +describe("getLocalModeHint", () => { + it("suggests auto-connect when using isolated local mode", () => { + expect(getLocalModeHint({ strategy: "isolated" })).toContain( + "browse env local --auto-connect", + ); + }); + + it("suggests switching back to isolated when attached to an existing browser", () => { + expect( + getLocalModeHint( + { strategy: "auto" }, + { + localSource: "attached-existing", + resolvedCdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc123", + }, + ), + ).toContain("without `--auto-connect`"); + }); + + it("suggests switching back to isolated for auto-connect before local info is available", () => { + expect(getLocalModeHint({ strategy: "auto" })).toContain( + "without `--auto-connect`", + ); + }); + + it("does not suggest auto-connect after an auto-connect fallback", () => { + expect( + getLocalModeHint( + { strategy: "auto" }, + { + localSource: "isolated-fallback", + fallbackReason: "no debuggable local browser found", + }, + ), + ).toBeNull(); + }); +}); diff --git a/packages/cli/tests/mode.test.ts b/packages/cli/tests/mode.test.ts index 56e6268d79..3afba7bb0b 100644 --- a/packages/cli/tests/mode.test.ts +++ b/packages/cli/tests/mode.test.ts @@ -114,12 +114,16 @@ describe("Browse CLI env command", () => { it("defaults browse env local to isolated strategy", async () => { const result = await browse("env local"); expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("browse env local --auto-connect"); const data = parseJson(result.stdout); expect(data.mode).toBe("local"); expect(data.localStrategy).toBe("isolated"); - const status = parseJson((await browse("status")).stdout); + const statusResult = await browse("status"); + expect(statusResult.stderr).toContain("browse env local --auto-connect"); + + const status = parseJson(statusResult.stdout); expect(status.running).toBe(true); expect(status.mode).toBe("local"); expect(status.localStrategy).toBe("isolated"); @@ -128,12 +132,16 @@ describe("Browse CLI env command", () => { it("uses auto strategy only when --auto-connect is passed", async () => { const result = await browse("env local --auto-connect"); expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("without `--auto-connect`"); const data = parseJson(result.stdout); expect(data.mode).toBe("local"); expect(data.localStrategy).toBe("auto"); - const status = parseJson((await browse("status")).stdout); + const statusResult = await browse("status"); + expect(statusResult.stderr).toContain("without `--auto-connect`"); + + const status = parseJson(statusResult.stdout); expect(status.running).toBe(true); expect(status.mode).toBe("local"); expect(status.localStrategy).toBe("auto"); @@ -153,6 +161,31 @@ describe("Browse CLI env command", () => { expect(status.localStrategy).toBe("cdp"); }); + it("shows an isolated-browser hint when status reports an attached existing browser", async () => { + await browse("env local"); + + const tmpDir = os.tmpdir(); + await fs.writeFile( + path.join(tmpDir, `browse-${TEST_SESSION}.local-config`), + JSON.stringify({ strategy: "auto" }), + ); + await fs.writeFile( + path.join(tmpDir, `browse-${TEST_SESSION}.local-info`), + JSON.stringify({ + localSource: "attached-existing", + resolvedCdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc123", + }), + ); + + const statusResult = await browse("status"); + expect(statusResult.exitCode).toBe(0); + expect(statusResult.stderr).toContain("without `--auto-connect`"); + + const status = parseJson(statusResult.stdout); + expect(status.localStrategy).toBe("auto"); + expect(status.localSource).toBe("attached-existing"); + }); + it("rejects conflicting local strategy options", async () => { const withAlias = await browse("env local --auto-connect --isolated"); expect(withAlias.exitCode).not.toBe(0);