Skip to content

Commit 78bcde8

Browse files
seanmcguire12CalebBarnesMastra Code (anthropic/claude-opus-4-6)
authored
[feat]: forward ignoreDefaultArgs to chrome-launcher (browserbase#2127)
thanks @CalebBarnes for the contribution! (original PR: browserbase#2074) # why When running Stagehand locally, chrome-launcher adds its own set of default flags (e.g. `--disable-extensions`). There's currently no way for consumers to selectively remove these flags — `localBrowserLaunchOptions.chromeFlags` can only *add* flags, not remove chrome-launcher's built-in defaults. This is needed when users want to run Chrome with extensions enabled, or need to remove other chrome-launcher defaults for their use case. # what changed - Added `ignoreDefaultArgs` option to `LaunchLocalOptions` in `local.ts` - Forwarded `localBrowserLaunchOptions.ignoreDefaultArgs` from `v3.ts` through to `launchLocalChrome()` - When `true`: drops all chrome-launcher defaults (only Stagehand's own flags and user-supplied `chromeFlags` are used) - When `string[]`: selectively removes only the listed flags from chrome-launcher defaults, keeping the rest This mirrors the behavior of Playwright's [`ignoreDefaultArgs`](https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-ignore-default-args) option. # test plan - Verified locally that setting `ignoreDefaultArgs: ["--disable-extensions"]` successfully removes the flag from the launched Chrome instance (confirmed via `chrome://version/`) - Verified that other chrome-launcher defaults are preserved when using the array form - Verified that `ignoreDefaultArgs: true` drops all chrome-launcher defaults <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds `ignoreDefaultArgs` to local Chrome launch so you can drop all or selected default flags from `chrome-launcher` with exact-match control (including Stagehand defaults), while keeping your own `args`. - **New Features** - Added `ignoreDefaultArgs` to `LaunchLocalOptions` and moved flag assembly into `launchLocalChrome()`, forwarding to `chrome-launcher` via `ignoreDefaultFlags`. - `true` drops all `chrome-launcher` defaults; `string[]` removes only exact-matched defaults and re-adds the rest from `Launcher.defaultFlags()`. Stagehand defaults and user `args` stay unless explicitly listed. <sup>Written for commit e04da99. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/2127?utm_source=github">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. --> --------- --------- Co-authored-by: Caleb Barnes <caleb.d.barnes@gmail.com> Co-authored-by: Caleb Barnes <CalebBarnes@users.noreply.github.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
1 parent ebbdcd3 commit 78bcde8

4 files changed

Lines changed: 256 additions & 64 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": minor
3+
---
4+
5+
Add `ignoreDefaultArgs` option to selectively remove chrome-launcher's built-in default flags (e.g. `--disable-extensions`) when running locally

packages/core/lib/v3/launch/local.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import { launch, LaunchedChrome } from "chrome-launcher";
1+
import { launch, Launcher, LaunchedChrome } from "chrome-launcher";
22
import WebSocket from "ws";
33
import { ConnectionTimeoutError } from "../types/public/sdkErrors.js";
4+
import type { LocalBrowserLaunchOptions } from "../types/public/index.js";
45

5-
interface LaunchLocalOptions {
6-
chromePath?: string;
7-
chromeFlags?: string[];
8-
headless?: boolean;
9-
userDataDir?: string;
10-
port?: number;
11-
connectTimeoutMs?: number;
6+
interface LaunchLocalOptions extends LocalBrowserLaunchOptions {
127
handleSIGINT?: boolean;
138
}
149

10+
const STAGEHAND_DEFAULT_FLAGS = [
11+
"--remote-allow-origins=*",
12+
"--no-first-run",
13+
"--no-default-browser-check",
14+
"--disable-dev-shm-usage",
15+
"--site-per-process",
16+
];
17+
1518
export async function launchLocalChrome(
1619
opts: LaunchLocalOptions,
1720
): Promise<{ ws: string; chrome: LaunchedChrome }> {
@@ -23,22 +26,58 @@ export async function launchLocalChrome(
2326
Math.ceil(connectTimeoutMs / connectionPollInterval),
2427
);
2528
const headless = opts.headless ?? false;
29+
const ignoredDefaults = Array.isArray(opts.ignoreDefaultArgs)
30+
? opts.ignoreDefaultArgs
31+
: null;
32+
const stagehandDefaultFlags =
33+
opts.ignoreDefaultArgs === true
34+
? []
35+
: ignoredDefaults
36+
? STAGEHAND_DEFAULT_FLAGS.filter((f) => !ignoredDefaults.includes(f))
37+
: [...STAGEHAND_DEFAULT_FLAGS];
2638
const chromeFlags = [
39+
...stagehandDefaultFlags,
2740
headless ? "--headless=new" : undefined,
28-
"--remote-allow-origins=*",
29-
"--no-first-run",
30-
"--no-default-browser-check",
31-
"--disable-dev-shm-usage",
32-
"--site-per-process",
33-
...(opts.chromeFlags ?? []),
41+
opts.devtools ? "--auto-open-devtools-for-tabs" : undefined,
42+
opts.locale ? `--lang=${opts.locale}` : undefined,
43+
opts.viewport?.width && opts.viewport?.height
44+
? `--window-size=${opts.viewport.width},${opts.viewport.height + 87}`
45+
: undefined,
46+
typeof opts.deviceScaleFactor === "number"
47+
? `--force-device-scale-factor=${Math.max(0.1, opts.deviceScaleFactor)}`
48+
: undefined,
49+
opts.hasTouch ? "--touch-events=enabled" : undefined,
50+
opts.ignoreHTTPSErrors ? "--ignore-certificate-errors" : undefined,
51+
opts.proxy?.server ? `--proxy-server=${opts.proxy.server}` : undefined,
52+
opts.proxy?.bypass ? `--proxy-bypass-list=${opts.proxy.bypass}` : undefined,
53+
...(opts.args ?? []),
3454
].filter((f): f is string => typeof f === "string");
3555

56+
// Handle ignoreDefaultArgs: selectively remove chrome-launcher's built-in
57+
// defaults while keeping Stagehand's own flags (already in chromeFlags).
58+
let ignoreDefaultFlags = false;
59+
if (opts.ignoreDefaultArgs === true) {
60+
ignoreDefaultFlags = true;
61+
} else if (
62+
Array.isArray(opts.ignoreDefaultArgs) &&
63+
opts.ignoreDefaultArgs.length > 0
64+
) {
65+
// Tell chrome-launcher to skip ALL its defaults, then re-add the ones
66+
// the user did NOT ask to exclude.
67+
ignoreDefaultFlags = true;
68+
const excludeArgs = opts.ignoreDefaultArgs;
69+
const clDefaults = Launcher.defaultFlags?.() ?? [];
70+
const kept = clDefaults.filter((f) => !excludeArgs.includes(f));
71+
chromeFlags.unshift(...kept);
72+
}
73+
3674
const chrome = await launch({
37-
chromePath: opts.chromePath,
75+
chromePath: opts.executablePath,
3876
chromeFlags,
3977
port: opts.port,
4078
userDataDir: opts.userDataDir,
4179
handleSIGINT: opts.handleSIGINT,
80+
ignoreDefaultFlags,
4281
connectionPollInterval,
4382
maxConnectionRetries,
4483
});

packages/core/lib/v3/v3.ts

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -944,62 +944,14 @@ export class V3 {
944944
createdTemp = true;
945945
}
946946

947-
// Build chrome flags
948-
const defaults = [
949-
"--remote-allow-origins=*",
950-
"--no-first-run",
951-
"--no-default-browser-check",
952-
"--disable-dev-shm-usage",
953-
"--site-per-process",
954-
];
955-
let chromeFlags: string[];
956-
const ignore = lbo.ignoreDefaultArgs;
957-
if (ignore === true) {
958-
// drop defaults
959-
chromeFlags = [];
960-
} else if (Array.isArray(ignore)) {
961-
chromeFlags = defaults.filter(
962-
(f) => !ignore.some((ex) => f.includes(ex)),
963-
);
964-
} else {
965-
chromeFlags = [...defaults];
966-
}
967-
968-
// headless handled by launchLocalChrome
969-
if (lbo.devtools) chromeFlags.push("--auto-open-devtools-for-tabs");
970-
if (lbo.locale) chromeFlags.push(`--lang=${lbo.locale}`);
971947
if (!lbo.viewport) {
972948
lbo.viewport = DEFAULT_VIEWPORT;
973949
}
974-
if (lbo.viewport?.width && lbo.viewport?.height) {
975-
chromeFlags.push(
976-
`--window-size=${lbo.viewport.width},${lbo.viewport.height + 87}`, // Added pixels to the window to account for the address bar
977-
);
978-
}
979-
if (typeof lbo.deviceScaleFactor === "number") {
980-
chromeFlags.push(
981-
`--force-device-scale-factor=${Math.max(0.1, lbo.deviceScaleFactor)}`,
982-
);
983-
}
984-
if (lbo.hasTouch) chromeFlags.push("--touch-events=enabled");
985-
if (lbo.ignoreHTTPSErrors)
986-
chromeFlags.push("--ignore-certificate-errors");
987-
if (lbo.proxy?.server)
988-
chromeFlags.push(`--proxy-server=${lbo.proxy.server}`);
989-
if (lbo.proxy?.bypass)
990-
chromeFlags.push(`--proxy-bypass-list=${lbo.proxy.bypass}`);
991-
992-
// add user-supplied args last
993-
if (Array.isArray(lbo.args)) chromeFlags.push(...lbo.args);
994950

995951
const keepAlive = this.keepAlive === true;
996952
const { ws, chrome } = await launchLocalChrome({
997-
chromePath: lbo.executablePath,
998-
chromeFlags,
999-
port: lbo.port,
1000-
headless: lbo.headless,
953+
...lbo,
1001954
userDataDir,
1002-
connectTimeoutMs: lbo.connectTimeoutMs,
1003955
handleSIGINT: !keepAlive,
1004956
});
1005957
if (keepAlive) {
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
vi,
6+
beforeEach,
7+
afterEach,
8+
type Mock,
9+
} from "vitest";
10+
11+
/**
12+
* Unit tests for the `ignoreDefaultArgs` option in `launchLocalChrome`.
13+
*
14+
* Strategy: mock `chrome-launcher` and `ws` so `launchLocalChrome` returns
15+
* immediately, then assert the flags passed to `chrome-launcher.launch()`.
16+
*/
17+
18+
const FAKE_WS_URL = "ws://127.0.0.1:9222/devtools/browser/fake";
19+
const FAKE_CHROME_LAUNCHER_DEFAULTS = [
20+
"--disable-extensions",
21+
"--disable-component-extensions-with-background-pages",
22+
"--disable-background-networking",
23+
"--disable-sync",
24+
"--mute-audio",
25+
];
26+
27+
vi.mock("chrome-launcher", () => ({
28+
launch: vi.fn().mockResolvedValue({
29+
port: 9222,
30+
kill: vi.fn(),
31+
pid: 12345,
32+
}),
33+
Launcher: {
34+
defaultFlags: () => [...FAKE_CHROME_LAUNCHER_DEFAULTS],
35+
},
36+
}));
37+
38+
// Mock ws: the probe calls `new WebSocket(url)` then `.once("open", cb)`.
39+
// Use EventEmitter so "open" fires after the listener is attached.
40+
vi.mock("ws", async () => {
41+
const { EventEmitter } =
42+
await vi.importActual<typeof import("node:events")>("node:events");
43+
44+
class MockWebSocket extends EventEmitter {
45+
constructor() {
46+
super();
47+
process.nextTick(() => this.emit("open"));
48+
}
49+
terminate() {}
50+
}
51+
return { default: MockWebSocket, WebSocket: MockWebSocket };
52+
});
53+
54+
let launchMock: Mock;
55+
56+
beforeEach(async () => {
57+
vi.stubGlobal(
58+
"fetch",
59+
vi.fn().mockResolvedValue({
60+
ok: true,
61+
json: async () => ({ webSocketDebuggerUrl: FAKE_WS_URL }),
62+
}),
63+
);
64+
65+
const chromeLauncher = await import("chrome-launcher");
66+
launchMock = chromeLauncher.launch as Mock;
67+
launchMock.mockClear();
68+
});
69+
70+
afterEach(() => {
71+
vi.unstubAllGlobals();
72+
});
73+
74+
async function getLaunchArgs(
75+
opts: Record<string, unknown>,
76+
): Promise<{ chromeFlags: string[]; ignoreDefaultFlags: boolean }> {
77+
const { launchLocalChrome } = await import("../../lib/v3/launch/local.js");
78+
await launchLocalChrome(opts);
79+
return launchMock.mock.calls[0][0];
80+
}
81+
82+
describe("launchLocalChrome ignoreDefaultArgs", () => {
83+
it("does not set ignoreDefaultFlags when ignoreDefaultArgs is omitted", async () => {
84+
const args = await getLaunchArgs({});
85+
expect(args.ignoreDefaultFlags).toBe(false);
86+
});
87+
88+
it("does not set ignoreDefaultFlags when ignoreDefaultArgs is false", async () => {
89+
const args = await getLaunchArgs({ ignoreDefaultArgs: false });
90+
expect(args.ignoreDefaultFlags).toBe(false);
91+
});
92+
93+
it("sets ignoreDefaultFlags=true when ignoreDefaultArgs is true", async () => {
94+
const args = await getLaunchArgs({ ignoreDefaultArgs: true });
95+
expect(args.ignoreDefaultFlags).toBe(true);
96+
expect(args.chromeFlags).not.toContain("--remote-allow-origins=*");
97+
expect(args.chromeFlags).not.toContain("--no-first-run");
98+
// No chrome-launcher defaults should be prepended
99+
for (const flag of FAKE_CHROME_LAUNCHER_DEFAULTS) {
100+
expect(args.chromeFlags).not.toContain(flag);
101+
}
102+
});
103+
104+
it("selectively removes only the listed flag", async () => {
105+
const args = await getLaunchArgs({
106+
ignoreDefaultArgs: ["--disable-extensions"],
107+
});
108+
109+
expect(args.ignoreDefaultFlags).toBe(true);
110+
expect(args.chromeFlags).not.toContain("--disable-extensions");
111+
112+
// Other defaults should be preserved (exact match, not substring)
113+
expect(args.chromeFlags).toContain(
114+
"--disable-component-extensions-with-background-pages",
115+
);
116+
expect(args.chromeFlags).toContain("--disable-background-networking");
117+
expect(args.chromeFlags).toContain("--disable-sync");
118+
expect(args.chromeFlags).toContain("--mute-audio");
119+
});
120+
121+
it("uses exact matching, not substring matching", async () => {
122+
// "--disable-component" is a substring of
123+
// "--disable-component-extensions-with-background-pages",
124+
// but exact matching should NOT remove it
125+
const args = await getLaunchArgs({
126+
ignoreDefaultArgs: ["--disable-component"],
127+
});
128+
129+
expect(args.chromeFlags).toContain(
130+
"--disable-component-extensions-with-background-pages",
131+
);
132+
expect(args.chromeFlags).toContain("--disable-extensions");
133+
expect(args.chromeFlags).toContain("--mute-audio");
134+
});
135+
136+
it("uses exact matching for Stagehand defaults too", async () => {
137+
const args = await getLaunchArgs({
138+
ignoreDefaultArgs: ["--no-first"],
139+
});
140+
141+
expect(args.chromeFlags).toContain("--no-first-run");
142+
expect(args.chromeFlags).toContain("--remote-allow-origins=*");
143+
});
144+
145+
it("preserves all defaults when ignoreDefaultArgs is an empty array", async () => {
146+
const args = await getLaunchArgs({ ignoreDefaultArgs: [] });
147+
expect(args.ignoreDefaultFlags).toBe(false);
148+
});
149+
150+
it("keeps Stagehand's own flags when selectively removing defaults", async () => {
151+
const args = await getLaunchArgs({
152+
ignoreDefaultArgs: ["--mute-audio"],
153+
});
154+
155+
// Spot-check a couple of Stagehand's flags — not the full list,
156+
// so this test doesn't break if Stagehand adds/removes its own flags.
157+
expect(args.chromeFlags).toContain("--remote-allow-origins=*");
158+
expect(args.chromeFlags).toContain("--no-first-run");
159+
160+
expect(args.chromeFlags).not.toContain("--mute-audio");
161+
expect(args.chromeFlags).toContain("--disable-extensions");
162+
});
163+
164+
it("merges user chromeFlags with re-added defaults", async () => {
165+
const args = await getLaunchArgs({
166+
args: ["--custom-flag"],
167+
ignoreDefaultArgs: ["--disable-sync"],
168+
});
169+
170+
expect(args.chromeFlags).toContain("--custom-flag");
171+
expect(args.chromeFlags).not.toContain("--disable-sync");
172+
// Other defaults should still be present
173+
expect(args.chromeFlags).toContain("--disable-extensions");
174+
});
175+
176+
it("does not deduplicate when user chromeFlags overlap with defaults", async () => {
177+
const args = await getLaunchArgs({
178+
args: ["--disable-sync"],
179+
ignoreDefaultArgs: ["--mute-audio"],
180+
});
181+
182+
// "--disable-sync" appears in both user flags and re-added defaults
183+
const count = args.chromeFlags.filter((f) => f === "--disable-sync").length;
184+
expect(count).toBe(2);
185+
});
186+
187+
it("selectively removes Stagehand defaults by exact match", async () => {
188+
const args = await getLaunchArgs({
189+
ignoreDefaultArgs: ["--no-first-run"],
190+
});
191+
192+
expect(args.chromeFlags).not.toContain("--no-first-run");
193+
expect(args.chromeFlags).toContain("--remote-allow-origins=*");
194+
expect(args.chromeFlags).toContain("--disable-extensions");
195+
});
196+
});

0 commit comments

Comments
 (0)