From 736539650a7125e2221a7b1b420baaf87f2a1005 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Sat, 6 Jun 2026 21:30:07 -0400 Subject: [PATCH 1/4] fix: stop injecting --site-per-process into local Chromium launches Stagehand unconditionally added --site-per-process to local browser argv, which overrode user attempts to disable site isolation and made --renderer-process-limit a soft cap. Drop the default so callers can control isolation and renderer pool size via localBrowserLaunchOptions.args. Co-authored-by: Cursor --- .changeset/remove-site-per-process-default.md | 5 +++++ packages/core/lib/v3/launch/local.ts | 1 - .../unit/launch-local-ignore-default-args.test.ts | 10 ++++++++++ packages/evals/core/targets/localChrome.ts | 1 - 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/remove-site-per-process-default.md diff --git a/.changeset/remove-site-per-process-default.md b/.changeset/remove-site-per-process-default.md new file mode 100644 index 0000000000..cac9e51136 --- /dev/null +++ b/.changeset/remove-site-per-process-default.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +Stop injecting `--site-per-process` into local Chromium launches so user-supplied flags like `--disable-features=site-per-process` and `--renderer-process-limit` take effect. diff --git a/packages/core/lib/v3/launch/local.ts b/packages/core/lib/v3/launch/local.ts index 765e4def79..9702833f3f 100644 --- a/packages/core/lib/v3/launch/local.ts +++ b/packages/core/lib/v3/launch/local.ts @@ -12,7 +12,6 @@ const STAGEHAND_DEFAULT_FLAGS = [ "--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", - "--site-per-process", ]; export async function launchLocalChrome( diff --git a/packages/core/tests/unit/launch-local-ignore-default-args.test.ts b/packages/core/tests/unit/launch-local-ignore-default-args.test.ts index 91c486558d..89732522b6 100644 --- a/packages/core/tests/unit/launch-local-ignore-default-args.test.ts +++ b/packages/core/tests/unit/launch-local-ignore-default-args.test.ts @@ -80,6 +80,16 @@ async function getLaunchArgs( } describe("launchLocalChrome ignoreDefaultArgs", () => { + it("does not inject --site-per-process by default", async () => { + const args = await getLaunchArgs({}); + expect(args.chromeFlags).not.toContain("--site-per-process"); + }); + + it("allows users to opt in to --site-per-process via args", async () => { + const args = await getLaunchArgs({ args: ["--site-per-process"] }); + expect(args.chromeFlags).toContain("--site-per-process"); + }); + it("does not set ignoreDefaultFlags when ignoreDefaultArgs is omitted", async () => { const args = await getLaunchArgs({}); expect(args.ignoreDefaultFlags).toBe(false); diff --git a/packages/evals/core/targets/localChrome.ts b/packages/evals/core/targets/localChrome.ts index daa43ff8ca..9cd3b13e81 100644 --- a/packages/evals/core/targets/localChrome.ts +++ b/packages/evals/core/targets/localChrome.ts @@ -118,7 +118,6 @@ export async function launchRunnerProvidedLocalChrome(): Promise<{ "--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", - "--site-per-process", `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, "about:blank", From f9029df42895c07141954d7863b09e85d8572f73 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Sat, 6 Jun 2026 22:00:31 -0400 Subject: [PATCH 2/4] fix: close local browser launch doc and code gaps Document ignoreDefaultArgs and site-isolation/renderer-limit guidance, wire chromiumSandbox:false into launchLocalChrome, and share default Chrome flags between core and evals via STAGEHAND_DEFAULT_FLAGS. Co-authored-by: Cursor --- .changeset/local-browser-launch-gaps.md | 5 +++ packages/core/lib/v3/index.ts | 3 ++ packages/core/lib/v3/launch/defaults.ts | 6 +++ packages/core/lib/v3/launch/local.ts | 10 ++--- .../launch-local-ignore-default-args.test.ts | 12 +++++ .../unit/public-api/export-surface.test.ts | 1 + packages/docs/v3/configuration/browser.mdx | 44 +++++++++++++++++++ packages/evals/core/targets/localChrome.ts | 6 +-- 8 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 .changeset/local-browser-launch-gaps.md create mode 100644 packages/core/lib/v3/launch/defaults.ts diff --git a/.changeset/local-browser-launch-gaps.md b/.changeset/local-browser-launch-gaps.md new file mode 100644 index 0000000000..c1e95a06b4 --- /dev/null +++ b/.changeset/local-browser-launch-gaps.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +Wire `chromiumSandbox: false` into local Chromium launches and document `ignoreDefaultArgs` plus site-isolation/renderer-limit guidance. diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index e2f403e9a4..6e35d742a0 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -34,9 +34,11 @@ import { shouldPersistTrajectory, writeTrajectoryDir, } from "./verifier/index.js"; +import { STAGEHAND_DEFAULT_FLAGS } from "./launch/defaults.js"; export { V3 } from "./v3.js"; export { V3 as Stagehand } from "./v3.js"; +export { STAGEHAND_DEFAULT_FLAGS } from "./launch/defaults.js"; export * from "./types/public/index.js"; export { AnnotatedScreenshotText, LLMClient } from "./llm/LLMClient.js"; @@ -161,6 +163,7 @@ const StagehandDefault = { normalizeRubric, redactInlineImagePayloads, shouldPersistTrajectory, + STAGEHAND_DEFAULT_FLAGS, writeTrajectoryDir, tool, getAISDKLanguageModel, diff --git a/packages/core/lib/v3/launch/defaults.ts b/packages/core/lib/v3/launch/defaults.ts new file mode 100644 index 0000000000..35a92a24c1 --- /dev/null +++ b/packages/core/lib/v3/launch/defaults.ts @@ -0,0 +1,6 @@ +export const STAGEHAND_DEFAULT_FLAGS = [ + "--remote-allow-origins=*", + "--no-first-run", + "--no-default-browser-check", + "--disable-dev-shm-usage", +] as const; diff --git a/packages/core/lib/v3/launch/local.ts b/packages/core/lib/v3/launch/local.ts index 9702833f3f..49ab63fe28 100644 --- a/packages/core/lib/v3/launch/local.ts +++ b/packages/core/lib/v3/launch/local.ts @@ -1,5 +1,6 @@ import { launch, Launcher, LaunchedChrome } from "chrome-launcher"; import WebSocket from "ws"; +import { STAGEHAND_DEFAULT_FLAGS } from "./defaults.js"; import { ConnectionTimeoutError } from "../types/public/sdkErrors.js"; import type { LocalBrowserLaunchOptions } from "../types/public/index.js"; @@ -7,13 +8,6 @@ interface LaunchLocalOptions extends LocalBrowserLaunchOptions { handleSIGINT?: boolean; } -const STAGEHAND_DEFAULT_FLAGS = [ - "--remote-allow-origins=*", - "--no-first-run", - "--no-default-browser-check", - "--disable-dev-shm-usage", -]; - export async function launchLocalChrome( opts: LaunchLocalOptions, ): Promise<{ ws: string; chrome: LaunchedChrome }> { @@ -47,6 +41,8 @@ export async function launchLocalChrome( : undefined, opts.hasTouch ? "--touch-events=enabled" : undefined, opts.ignoreHTTPSErrors ? "--ignore-certificate-errors" : undefined, + opts.chromiumSandbox === false ? "--no-sandbox" : undefined, + opts.chromiumSandbox === false ? "--disable-setuid-sandbox" : undefined, opts.proxy?.server ? `--proxy-server=${opts.proxy.server}` : undefined, opts.proxy?.bypass ? `--proxy-bypass-list=${opts.proxy.bypass}` : undefined, ...(opts.args ?? []), diff --git a/packages/core/tests/unit/launch-local-ignore-default-args.test.ts b/packages/core/tests/unit/launch-local-ignore-default-args.test.ts index 89732522b6..52ea77e6a6 100644 --- a/packages/core/tests/unit/launch-local-ignore-default-args.test.ts +++ b/packages/core/tests/unit/launch-local-ignore-default-args.test.ts @@ -90,6 +90,18 @@ describe("launchLocalChrome ignoreDefaultArgs", () => { expect(args.chromeFlags).toContain("--site-per-process"); }); + it("adds sandbox disable flags when chromiumSandbox is false", async () => { + const args = await getLaunchArgs({ chromiumSandbox: false }); + expect(args.chromeFlags).toContain("--no-sandbox"); + expect(args.chromeFlags).toContain("--disable-setuid-sandbox"); + }); + + it("does not add sandbox disable flags when chromiumSandbox is omitted", async () => { + const args = await getLaunchArgs({}); + expect(args.chromeFlags).not.toContain("--no-sandbox"); + expect(args.chromeFlags).not.toContain("--disable-setuid-sandbox"); + }); + it("does not set ignoreDefaultFlags when ignoreDefaultArgs is omitted", async () => { const args = await getLaunchArgs({}); expect(args.ignoreDefaultFlags).toBe(false); diff --git a/packages/core/tests/unit/public-api/export-surface.test.ts b/packages/core/tests/unit/public-api/export-surface.test.ts index 163fd60094..6090bf72b4 100644 --- a/packages/core/tests/unit/public-api/export-surface.test.ts +++ b/packages/core/tests/unit/public-api/export-surface.test.ts @@ -55,6 +55,7 @@ const publicApiShape = { providerEnvVarMap: Stagehand.providerEnvVarMap, redactInlineImagePayloads: Stagehand.redactInlineImagePayloads, shouldPersistTrajectory: Stagehand.shouldPersistTrajectory, + STAGEHAND_DEFAULT_FLAGS: Stagehand.STAGEHAND_DEFAULT_FLAGS, toGeminiSchema: Stagehand.toGeminiSchema, toJsonSchema: Stagehand.toJsonSchema, tool: Stagehand.tool, diff --git a/packages/docs/v3/configuration/browser.mdx b/packages/docs/v3/configuration/browser.mdx index 9d22c9dae5..f3f9c688ac 100644 --- a/packages/docs/v3/configuration/browser.mdx +++ b/packages/docs/v3/configuration/browser.mdx @@ -258,6 +258,50 @@ const stagehand = new Stagehand({ await stagehand.init(); ``` +### Ignoring Default Chrome Flags + +Use `ignoreDefaultArgs` to remove chrome-launcher's built-in defaults or Stagehand's own launch flags. This is useful when you need extensions enabled, want finer control over Chromium flags, or are running in environments that require custom sandbox settings. + +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; + +const stagehand = new Stagehand({ + env: "LOCAL", + localBrowserLaunchOptions: { + // Remove only chrome-launcher's --disable-extensions default + ignoreDefaultArgs: ["--disable-extensions"], + }, +}); + +await stagehand.init(); +``` + +Set `ignoreDefaultArgs: true` to drop all chrome-launcher defaults. Stagehand's own flags and your `args` are still applied. + +#### Site isolation and renderer limits + +Stagehand does not inject `--site-per-process` by default. To disable site isolation or cap renderer processes on multi-origin workloads, pass flags via `args`: + +```typescript +const stagehand = new Stagehand({ + env: "LOCAL", + localBrowserLaunchOptions: { + args: [ + "--disable-features=site-per-process,IsolateOrigins", + "--renderer-process-limit=6", + ], + }, +}); + +await stagehand.init(); +``` + +Users who need strict per-site isolation can opt in with `args: ["--site-per-process"]`. + + +An explicit `--site-per-process` switch overrides `--disable-features=site-per-process`, regardless of argv order. Avoid passing both. + + ## Advanced Configuration ### Keep Alive diff --git a/packages/evals/core/targets/localChrome.ts b/packages/evals/core/targets/localChrome.ts index 9cd3b13e81..b9ec40174b 100644 --- a/packages/evals/core/targets/localChrome.ts +++ b/packages/evals/core/targets/localChrome.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; import { spawn, type ChildProcess } from "node:child_process"; +import { STAGEHAND_DEFAULT_FLAGS } from "@browserbasehq/stagehand"; export function resolveLocalChromeExecutablePath(): string | undefined { const explicit = process.env.CHROME_PATH; @@ -114,10 +115,7 @@ export async function launchRunnerProvidedLocalChrome(): Promise<{ const userDataDir = await mkdtemp(path.join(os.tmpdir(), "stagehand-evals-")); const args = [ "--headless=new", - "--remote-allow-origins=*", - "--no-first-run", - "--no-default-browser-check", - "--disable-dev-shm-usage", + ...STAGEHAND_DEFAULT_FLAGS, `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, "about:blank", From 736f76eadb7d2f1dfa10831f56daffba4c67096e Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Sat, 6 Jun 2026 23:49:08 -0400 Subject: [PATCH 3/4] feat(cli): expose local browser launch options in browse driver Add --chrome-path, --connect-timeout, and repeatable --chrome-arg flags for managed-local sessions, wire them through to Stagehand launch options, and teach browse doctor to verify the Chrome executable before open. Co-authored-by: Cursor --- packages/cli/src/commands/doctor.ts | 1 + packages/cli/src/commands/open.ts | 2 + packages/cli/src/lib/driver/chrome-path.ts | 30 ++++++ packages/cli/src/lib/driver/command-cli.ts | 6 ++ packages/cli/src/lib/driver/doctor.ts | 28 +++++- packages/cli/src/lib/driver/flags.ts | 19 ++++ packages/cli/src/lib/driver/launch-options.ts | 17 ++++ packages/cli/src/lib/driver/mode.ts | 87 ++++++++++++++++- .../cli/src/lib/driver/session-manager.ts | 2 + packages/cli/src/lib/driver/types.ts | 12 ++- packages/cli/tests/doctor.test.ts | 36 +++++++ packages/cli/tests/launch-options.test.ts | 23 +++++ packages/cli/tests/local-launch-flags.test.ts | 93 +++++++++++++++++++ 13 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/lib/driver/chrome-path.ts create mode 100644 packages/cli/src/lib/driver/launch-options.ts create mode 100644 packages/cli/tests/launch-options.test.ts create mode 100644 packages/cli/tests/local-launch-flags.test.ts diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 497096aed9..cb5026efed 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -19,6 +19,7 @@ export default class Doctor extends BrowseCommand { "browse doctor --auto-connect", "browse doctor --cdp 9222", "browse doctor --session research --json", + "browse doctor --local --chrome-path /opt/chrome/chrome", ]; static override flags = { diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 172ce8c162..0406fc00a7 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -19,6 +19,8 @@ export default class Open extends BrowseCommand { "browse open https://example.com --cdp 9222", "browse open https://example.com --cdp ws://127.0.0.1:9222/devtools/browser/ --target-id ", "browse open https://example.com --session research", + "browse open https://example.com --local --chrome-path /opt/chrome/chrome", + "browse open https://example.com --local --connect-timeout 30000 --chrome-arg --renderer-process-limit=6", "browse open https://example.com --wait networkidle --timeout 45000", ]; diff --git a/packages/cli/src/lib/driver/chrome-path.ts b/packages/cli/src/lib/driver/chrome-path.ts new file mode 100644 index 0000000000..ffbdda53da --- /dev/null +++ b/packages/cli/src/lib/driver/chrome-path.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; + +const DEFAULT_CHROME_CANDIDATES = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", +]; + +export function resolveChromeExecutablePath(options?: { + explicit?: string; + env?: string; +}): string | undefined { + if (options?.explicit) { + return fs.existsSync(options.explicit) ? options.explicit : undefined; + } + + if (options?.env && fs.existsSync(options.env)) { + return options.env; + } + + for (const candidate of DEFAULT_CHROME_CANDIDATES) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} diff --git a/packages/cli/src/lib/driver/command-cli.ts b/packages/cli/src/lib/driver/command-cli.ts index 18c442f559..030ac82af7 100644 --- a/packages/cli/src/lib/driver/command-cli.ts +++ b/packages/cli/src/lib/driver/command-cli.ts @@ -4,6 +4,9 @@ import type { DriverCommandName } from "./commands/types.js"; import { autoConnectFlag, cdpFlag, + chromeArgFlag, + chromePathFlag, + connectTimeoutFlag, headedFlag, headlessFlag, localFlag, @@ -20,7 +23,10 @@ import { runDriverCommandWithTarget } from "./runtime.js"; export const driverCommandFlags = { "auto-connect": autoConnectFlag, + "chrome-arg": chromeArgFlag, + "chrome-path": chromePathFlag, cdp: cdpFlag, + "connect-timeout": connectTimeoutFlag, headed: headedFlag, headless: headlessFlag, local: localFlag, diff --git a/packages/cli/src/lib/driver/doctor.ts b/packages/cli/src/lib/driver/doctor.ts index e107b4a34f..32a4b12e7d 100644 --- a/packages/cli/src/lib/driver/doctor.ts +++ b/packages/cli/src/lib/driver/doctor.ts @@ -13,6 +13,7 @@ import { discoverLocalCdp, type LocalCdpDiscovery, } from "./local-cdp-discovery.js"; +import { resolveChromeExecutablePath } from "./chrome-path.js"; import { hasExplicitDriverTarget, type DriverFlags } from "./command-cli.js"; import { resolveConnectionTarget, targetsCompatible } from "./mode.js"; import { getRemote } from "./remote-binding.js"; @@ -270,10 +271,31 @@ async function checkTargetPrerequisite( deps: DoctorDeps, ): Promise { if (target.kind === "managed-local") { + const executablePath = resolveChromeExecutablePath({ + explicit: target.launch?.executablePath, + env: env.CHROME_PATH, + }); + if (!executablePath) { + return { + fix: "install Chrome/Chromium or pass --chrome-path / set CHROME_PATH", + message: "no Chrome executable found", + name: "browser", + status: "fail", + }; + } + + const mode = target.headless ? "headless" : "headed"; + const details: Record = { executablePath, mode }; + if (target.launch?.connectTimeoutMs !== undefined) { + details.connectTimeoutMs = target.launch.connectTimeoutMs; + } + if (target.launch?.args?.length) { + details.args = target.launch.args; + } + return { - message: target.headless - ? "managed local browser, headless" - : "managed local browser, headed", + details, + message: `managed local browser, ${mode}, ${executablePath}`, name: "browser", status: "ok", }; diff --git a/packages/cli/src/lib/driver/flags.ts b/packages/cli/src/lib/driver/flags.ts index 0693cece7f..6f4ad99f9a 100644 --- a/packages/cli/src/lib/driver/flags.ts +++ b/packages/cli/src/lib/driver/flags.ts @@ -40,6 +40,25 @@ export const targetIdFlag = Flags.string({ helpValue: "", }); +export const chromePathFlag = Flags.string({ + description: + "Path to the Chrome or Chromium executable for managed local sessions. Falls back to CHROME_PATH.", + helpValue: "", +}); + +export const connectTimeoutFlag = Flags.integer({ + description: + "Timeout in milliseconds when launching or connecting to a managed local browser.", + helpValue: "", +}); + +export const chromeArgFlag = Flags.string({ + description: + "Extra Chromium flag for managed local sessions. Repeat for multiple flags.", + helpValue: "", + multiple: true, +}); + export function sessionName(value?: string): string { return value ?? process.env.BROWSE_SESSION ?? "default"; } diff --git a/packages/cli/src/lib/driver/launch-options.ts b/packages/cli/src/lib/driver/launch-options.ts new file mode 100644 index 0000000000..9ab9b801c7 --- /dev/null +++ b/packages/cli/src/lib/driver/launch-options.ts @@ -0,0 +1,17 @@ +import type { LocalBrowserLaunchOptions } from "@browserbasehq/stagehand"; + +import type { ManagedLocalLaunchOptions } from "./types.js"; + +export function buildManagedLocalLaunchOptions( + launch?: ManagedLocalLaunchOptions, +): LocalBrowserLaunchOptions { + return { + ...(launch?.executablePath + ? { executablePath: launch.executablePath } + : {}), + ...(typeof launch?.connectTimeoutMs === "number" + ? { connectTimeoutMs: launch.connectTimeoutMs } + : {}), + ...(launch?.args?.length ? { args: launch.args } : {}), + }; +} diff --git a/packages/cli/src/lib/driver/mode.ts b/packages/cli/src/lib/driver/mode.ts index 8b2ca847ab..d1e9032138 100644 --- a/packages/cli/src/lib/driver/mode.ts +++ b/packages/cli/src/lib/driver/mode.ts @@ -1,11 +1,14 @@ import { fail } from "../errors.js"; import { getRemote } from "./remote-binding.js"; import { resolveWsTarget } from "./resolve-ws.js"; -import type { ConnectionTarget } from "./types.js"; +import type { ConnectionTarget, ManagedLocalLaunchOptions } from "./types.js"; export interface DriverModeFlags { "auto-connect"?: boolean; + "chrome-arg"?: string[]; + "chrome-path"?: string; cdp?: string; + "connect-timeout"?: number; headed?: boolean; headless?: boolean; local?: boolean; @@ -28,6 +31,8 @@ function resolveHeadless( export async function resolveConnectionTarget( flags: DriverModeFlags, ): Promise { + failOnConflictingLocalLaunchFlags(flags); + if (flags.cdp) { failOnConflictingFlags("--cdp", [ flags["auto-connect"] ? "--auto-connect" : null, @@ -70,7 +75,7 @@ export async function resolveConnectionTarget( } if (flags.local) { - return { kind: "managed-local", headless: resolveHeadless(flags) }; + return buildManagedLocalTarget(flags); } const autoRemote = (await getRemote()).autoSelectRemoteTarget(); @@ -82,7 +87,61 @@ export async function resolveConnectionTarget( return autoRemote; } - return { kind: "managed-local", headless: resolveHeadless(flags) }; + return buildManagedLocalTarget(flags); +} + +function buildManagedLocalTarget( + flags: DriverModeFlags, +): Extract { + const launch = resolveManagedLocalLaunch(flags); + return { + headless: resolveHeadless(flags), + kind: "managed-local", + ...(launch ? { launch } : {}), + }; +} + +function resolveManagedLocalLaunch( + flags: DriverModeFlags, +): ManagedLocalLaunchOptions | undefined { + const executablePath = flags["chrome-path"] ?? process.env.CHROME_PATH; + const connectTimeoutMs = flags["connect-timeout"]; + const args = flags["chrome-arg"]?.filter(Boolean); + + if (!executablePath && connectTimeoutMs === undefined && !args?.length) { + return undefined; + } + + return { + ...(executablePath ? { executablePath } : {}), + ...(connectTimeoutMs !== undefined ? { connectTimeoutMs } : {}), + ...(args?.length ? { args } : {}), + }; +} + +function failOnConflictingLocalLaunchFlags(flags: DriverModeFlags): void { + const hasLaunchFlags = Boolean( + flags["chrome-path"] || + flags["connect-timeout"] !== undefined || + flags["chrome-arg"]?.length, + ); + if (!hasLaunchFlags) return; + + failOnConflictingFlags("--chrome-path", [ + flags.cdp ? "--cdp" : null, + flags["auto-connect"] ? "--auto-connect" : null, + flags.remote ? "--remote" : null, + ]); + failOnConflictingFlags("--connect-timeout", [ + flags.cdp ? "--cdp" : null, + flags["auto-connect"] ? "--auto-connect" : null, + flags.remote ? "--remote" : null, + ]); + failOnConflictingFlags("--chrome-arg", [ + flags.cdp ? "--cdp" : null, + flags["auto-connect"] ? "--auto-connect" : null, + flags.remote ? "--remote" : null, + ]); } function failOnConflictingFlags( @@ -101,10 +160,28 @@ export function targetsCompatible( right: ConnectionTarget, ): boolean { if (left.kind !== right.kind) return false; - if (left.kind === "managed-local" && right.kind === "managed-local") - return left.headless === right.headless; + if (left.kind === "managed-local" && right.kind === "managed-local") { + return ( + left.headless === right.headless && + managedLocalLaunchCompatible(left.launch, right.launch) + ); + } if (left.kind === "cdp" && right.kind === "cdp") { return left.endpoint === right.endpoint && left.targetId === right.targetId; } return true; } + +function managedLocalLaunchCompatible( + left?: ManagedLocalLaunchOptions, + right?: ManagedLocalLaunchOptions, +): boolean { + if (!left && !right) return true; + if (!left || !right) return false; + + return ( + left.executablePath === right.executablePath && + left.connectTimeoutMs === right.connectTimeoutMs && + JSON.stringify(left.args ?? []) === JSON.stringify(right.args ?? []) + ); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 8dce61a4e2..5c8664841e 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -7,6 +7,7 @@ import { } from "./commands/selectors.js"; import { executeDriverCommand } from "./commands/registry.js"; import type { DriverCommandName } from "./commands/types.js"; +import { buildManagedLocalLaunchOptions } from "./launch-options.js"; import { discoverLocalCdp } from "./local-cdp-discovery.js"; import { NetworkCapture } from "./network-capture.js"; import { getRemote } from "./remote-binding.js"; @@ -280,6 +281,7 @@ export class DriverSessionManager { env: "LOCAL", localBrowserLaunchOptions: { headless: target.headless, + ...buildManagedLocalLaunchOptions(target.launch), }, verbose: 0, }; diff --git a/packages/cli/src/lib/driver/types.ts b/packages/cli/src/lib/driver/types.ts index 72543c1be2..d37dd40d54 100644 --- a/packages/cli/src/lib/driver/types.ts +++ b/packages/cli/src/lib/driver/types.ts @@ -1,5 +1,15 @@ +export interface ManagedLocalLaunchOptions { + args?: string[]; + connectTimeoutMs?: number; + executablePath?: string; +} + export type ConnectionTarget = - | { kind: "managed-local"; headless: boolean } + | { + kind: "managed-local"; + headless: boolean; + launch?: ManagedLocalLaunchOptions; + } | { kind: "remote" } | { kind: "auto-connect" } | { kind: "cdp"; endpoint: string; targetId?: string }; diff --git a/packages/cli/tests/doctor.test.ts b/packages/cli/tests/doctor.test.ts index abeae483ab..68ec763964 100644 --- a/packages/cli/tests/doctor.test.ts +++ b/packages/cli/tests/doctor.test.ts @@ -284,6 +284,42 @@ describe("doctor report builder", () => { expect(report.next).toBe("browse status"); }); + it("reports a missing Chrome executable for managed-local mode", async () => { + const daemonDir = await tempDaemonDir(); + const report = await buildDoctorReport( + { + flags: { + "chrome-path": "/tmp/does-not-exist/chrome", + local: true, + }, + session: "default", + }, + { + env: { BROWSERBASE_API_KEY: "", CHROME_PATH: "" }, + getDriverStatus: async () => null, + readPackageVersion: async () => "0.0.0-test", + resolveConnectionTarget: async (flags) => ({ + headless: true, + kind: "managed-local", + launch: { + executablePath: flags["chrome-path"], + }, + }), + }, + ); + + expect(report).toMatchObject({ + verdict: "fail", + checks: expect.arrayContaining([ + expect.objectContaining({ + message: "no Chrome executable found", + name: "browser", + status: "fail", + }), + ]), + }); + }); + it("checks auto-connect discovery through injectable dependencies", async () => { const daemonDir = await tempDaemonDir(); const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; diff --git a/packages/cli/tests/launch-options.test.ts b/packages/cli/tests/launch-options.test.ts new file mode 100644 index 0000000000..d0fca4e929 --- /dev/null +++ b/packages/cli/tests/launch-options.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { buildManagedLocalLaunchOptions } from "../src/lib/driver/launch-options.js"; + +describe("buildManagedLocalLaunchOptions", () => { + it("returns an empty object when launch options are omitted", () => { + expect(buildManagedLocalLaunchOptions()).toEqual({}); + }); + + it("maps managed-local launch options into Stagehand options", () => { + expect( + buildManagedLocalLaunchOptions({ + args: ["--renderer-process-limit=6"], + connectTimeoutMs: 30_000, + executablePath: "/opt/chrome/chrome", + }), + ).toEqual({ + args: ["--renderer-process-limit=6"], + connectTimeoutMs: 30_000, + executablePath: "/opt/chrome/chrome", + }); + }); +}); diff --git a/packages/cli/tests/local-launch-flags.test.ts b/packages/cli/tests/local-launch-flags.test.ts new file mode 100644 index 0000000000..5844b02b07 --- /dev/null +++ b/packages/cli/tests/local-launch-flags.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + resolveConnectionTarget, + targetsCompatible, +} from "../src/lib/driver/mode.js"; + +describe("managed-local launch flags", () => { + const previousChromePath = process.env.CHROME_PATH; + + afterEach(() => { + restoreEnv("CHROME_PATH", previousChromePath); + }); + + it("attaches launch options to managed-local targets", async () => { + await expect( + resolveConnectionTarget({ + "chrome-arg": ["--renderer-process-limit=6"], + "chrome-path": "/opt/chrome/chrome", + "connect-timeout": 30_000, + local: true, + }), + ).resolves.toEqual({ + headless: true, + kind: "managed-local", + launch: { + args: ["--renderer-process-limit=6"], + connectTimeoutMs: 30_000, + executablePath: "/opt/chrome/chrome", + }, + }); + }); + + it("uses CHROME_PATH when --chrome-path is omitted", async () => { + process.env.CHROME_PATH = "/env/chrome"; + await expect(resolveConnectionTarget({ local: true })).resolves.toEqual({ + headless: true, + kind: "managed-local", + launch: { + executablePath: "/env/chrome", + }, + }); + }); + + it("rejects launch flags with remote mode", async () => { + await expect( + resolveConnectionTarget({ + "chrome-path": "/opt/chrome/chrome", + remote: true, + }), + ).rejects.toThrow("--chrome-path cannot be combined with --remote"); + }); + + it("requires matching launch options for managed-local compatibility", () => { + expect( + targetsCompatible( + { + headless: true, + kind: "managed-local", + launch: { executablePath: "/opt/chrome/chrome" }, + }, + { + headless: true, + kind: "managed-local", + launch: { executablePath: "/opt/chrome/chrome" }, + }, + ), + ).toBe(true); + + expect( + targetsCompatible( + { + headless: true, + kind: "managed-local", + launch: { connectTimeoutMs: 15_000 }, + }, + { + headless: true, + kind: "managed-local", + launch: { connectTimeoutMs: 30_000 }, + }, + ), + ).toBe(false); + }); +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} From 2257434d4fe478fc5a4cefe3981aa31bb9e20c49 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Sun, 7 Jun 2026 09:22:22 -0400 Subject: [PATCH 4/4] docs: clarify ignoreDefaultArgs removes Stagehand defaults when true ignoreDefaultArgs: true drops both chrome-launcher and STAGEHAND_DEFAULT_FLAGS; the previous docs incorrectly said Stagehand flags were still applied. Co-authored-by: Cursor --- packages/core/lib/v3/launch/local.ts | 5 +++-- packages/docs/v3/configuration/browser.mdx | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/core/lib/v3/launch/local.ts b/packages/core/lib/v3/launch/local.ts index 49ab63fe28..01a67b692b 100644 --- a/packages/core/lib/v3/launch/local.ts +++ b/packages/core/lib/v3/launch/local.ts @@ -48,8 +48,9 @@ export async function launchLocalChrome( ...(opts.args ?? []), ].filter((f): f is string => typeof f === "string"); - // Handle ignoreDefaultArgs: selectively remove chrome-launcher's built-in - // defaults while keeping Stagehand's own flags (already in chromeFlags). + // ignoreDefaultArgs: true skips chrome-launcher defaults entirely. + // ignoreDefaultArgs: string[] skips chrome-launcher defaults, then re-adds + // unlisted ones. Stagehand default flags are handled separately above. let ignoreDefaultFlags = false; if (opts.ignoreDefaultArgs === true) { ignoreDefaultFlags = true; diff --git a/packages/docs/v3/configuration/browser.mdx b/packages/docs/v3/configuration/browser.mdx index f3f9c688ac..711d392908 100644 --- a/packages/docs/v3/configuration/browser.mdx +++ b/packages/docs/v3/configuration/browser.mdx @@ -260,7 +260,14 @@ await stagehand.init(); ### Ignoring Default Chrome Flags -Use `ignoreDefaultArgs` to remove chrome-launcher's built-in defaults or Stagehand's own launch flags. This is useful when you need extensions enabled, want finer control over Chromium flags, or are running in environments that require custom sandbox settings. +Use `ignoreDefaultArgs` to control which launch flags Stagehand and [chrome-launcher](https://github.com/GoogleChrome/chrome-launcher) inject. Stagehand adds its own defaults on top of chrome-launcher's: + +- `--remote-allow-origins=*` +- `--no-first-run` +- `--no-default-browser-check` +- `--disable-dev-shm-usage` + +**Array** — exclude specific flags by name. Matching entries are removed from Stagehand's defaults above. chrome-launcher's built-in defaults are skipped, then every default *except* the listed flags is re-added. This is useful when you need extensions enabled: ```typescript import { Stagehand } from "@browserbasehq/stagehand"; @@ -276,7 +283,16 @@ const stagehand = new Stagehand({ await stagehand.init(); ``` -Set `ignoreDefaultArgs: true` to drop all chrome-launcher defaults. Stagehand's own flags and your `args` are still applied. +**`true`** — drop *both* chrome-launcher defaults and Stagehand's default flags above. Your `args` and option-derived flags (for example `headless`, `chromiumSandbox: false`) are still applied. Re-add any Stagehand defaults you still need via `args`: + +```typescript +localBrowserLaunchOptions: { + ignoreDefaultArgs: true, + args: ["--remote-allow-origins=*"], // opt back in if CDP connections require it +}, +``` + +**Omitted or `false`** — apply all Stagehand and chrome-launcher defaults. #### Site isolation and renderer limits