From c550a14ff47dd7f7322ec737b6c76bc236ccc2fe Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Thu, 20 Aug 2026 17:59:09 +0200 Subject: [PATCH] Bug 2065207 - [firefox-devtools-mcp] Update Firefox devtools mcp to use --android-keep-app-data when available --- README.md | 34 +++++++++++------ src/cli.ts | 5 ++- src/firefox/core.ts | 75 ++++++++++++++++++++++++++++++------- src/firefox/types.ts | 2 +- tests/firefox/core.test.ts | 76 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7d496fe..25c3f2d 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ You can pass flags or environment variables (names on the right): - `--enable-script` — _deprecated, use `--tool-preset developer` or `--tools ... script debugging`._ Selects the `developer` tool preset. (`ENABLE_SCRIPT=true`) - `--enable-privileged-context` — _deprecated, use `--tool-preset mozilla` or `--tools ... privileged prefs`._ Selects the `mozilla` tool preset. Requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` (`ENABLE_PRIVILEGED_CONTEXT=true`) - `--android-device` — enable Firefox for Android mode; value is the ADB device serial (e.g. `emulator-5554`). Run `adb devices` to list connected devices. Omit the value or use `auto` to select the single connected device automatically. -- `--android-wipe-app-data` — confirm that Android mode wipes all data of the target app. Required together with `--android-device`. (`ANDROID_WIPE_APP_DATA=true`) +- `--android-wipe-app-data` — wipe all data of the target Android app before each session. Required together with `--android-device` when geckodriver does not support `--android-keep-app-data`. (`ANDROID_WIPE_APP_DATA=true`) - `--android-package` — Android app package name, default `org.mozilla.firefox`. Other packages: `org.mozilla.firefox_beta` for Firefox Beta, `org.mozilla.fenix` for Firefox Nightly, `org.mozilla.fenix.debug` for Firefox Nightly Debug, `org.mozilla.geckoview_example` for geckoview (`ANDROID_PACKAGE`) - `--unrestricted-save-paths` — let the `saveTo` parameter write anywhere on disk instead of the default roots. See [Saving bulky output to disk](#saving-bulky-output-to-disk) and the security note in [SECURITY.md](SECURITY.md). (`UNRESTRICTED_SAVE_PATHS=true`) - `--log-file` — write MCP server logs to a file instead of stderr. Useful for debugging sessions with MCP clients that hide server output. Set `DEBUG=*` to also include verbose debug logs. Example: `--log-file /tmp/firefox-mcp.log` @@ -187,27 +187,37 @@ logs a warning naming the modules it dropped. Use `--android-device` to automate Firefox running on an Android device. Requires `adb` on your PATH and geckodriver, which is managed automatically. -> **Warning:** Android mode wipes all data of the target app before every session. -> Tabs, history, bookmarks, passwords, cookies and settings are all lost. geckodriver runs -> `adb shell pm clear ` when creating the session and offers no way to skip it, -> then runs the session on its own temporary profile which is deleted afterwards. -> Because of this, `--android-device` requires `--android-wipe-app-data`, and you should -> install a build dedicated to automation rather than automating the browser you use. -> [Bug 2064088](https://bugzilla.mozilla.org/show_bug.cgi?id=2064088) tracks adding an -> option to geckodriver to keep the existing app data. +By default geckodriver runs `adb shell pm clear ` when creating the session, which +wipes all data of the target app: tabs, history, bookmarks, passwords, cookies and settings +are all lost. [Bug 2064088](https://bugzilla.mozilla.org/show_bug.cgi?id=2064088) added the +`--android-keep-app-data` option to skip that step, and the MCP server passes it whenever the +geckodriver it uses supports it. + +The session still runs on a temporary profile pushed to the device, which is deleted afterwards, +but that only isolates part of the browser state. Firefox for Android keeps history, bookmarks, +passwords and the tab list in the app data directory rather than in the Gecko profile, so a +session reads and writes the data of the app it connects to: pages visited during a session end +up in the real history. Cookies, local storage and caches are profile level, so each session +starts logged out of every site and leaves nothing behind. + +> **Warning:** with a geckodriver that does not support `--android-keep-app-data`, connecting +> destroys the data of the target app. In that case `--android-device` requires +> `--android-wipe-app-data` to confirm, and you should install a build dedicated to automation +> rather than automating the browser you use. Pass `--android-wipe-app-data` explicitly to opt +> back into wiping the app data on every session. ```bash # List connected devices adb devices # Launch Firefox for Android on the single connected device -npx @mozilla/firefox-devtools-mcp --android-device auto --android-wipe-app-data +npx @mozilla/firefox-devtools-mcp --android-device auto # Target a specific device -npx @mozilla/firefox-devtools-mcp --android-device --android-wipe-app-data +npx @mozilla/firefox-devtools-mcp --android-device # Use Firefox Nightly instead -npx @mozilla/firefox-devtools-mcp --android-device --android-package org.mozilla.fenix --android-wipe-app-data +npx @mozilla/firefox-devtools-mcp --android-device --android-package org.mozilla.fenix ``` Port forwarding between the host and device is handled automatically by geckodriver. diff --git a/src/cli.ts b/src/cli.ts index e7b1a5f..7129b6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -175,8 +175,9 @@ export const cliOptions = { androidWipeAppData: { type: 'boolean', description: - 'Confirm that connecting to Firefox for Android wipes all data of the target app (tabs, ' + - 'history, bookmarks, passwords, settings). Required with --android-device.', + 'Wipe all data of the target Android app (tabs, history, bookmarks, passwords, settings) ' + + 'before each session. Required with --android-device when geckodriver does not support ' + + '--android-keep-app-data.', default: (process.env.ANDROID_WIPE_APP_DATA ?? 'false') === 'true', }, logFile: { diff --git a/src/firefox/core.ts b/src/firefox/core.ts index 39ccadd..13e29cc 100644 --- a/src/firefox/core.ts +++ b/src/firefox/core.ts @@ -13,6 +13,7 @@ import { statSync, readFileSync, } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { connect as netConnect } from 'node:net'; import { homedir } from 'node:os'; import { join, delimiter } from 'node:path'; @@ -150,6 +151,36 @@ async function findGeckodriver(): Promise { return found; } +const keepAppDataSupport = new Map(); + +/** + * Detects whether a geckodriver binary supports --android-keep-app-data (bug 2064088). + * Probes --help rather than --version because local and try builds report the same + * version as the release they branched from. Unknown arguments are fatal for + * geckodriver, so the flag can only be passed when the probe confirms it. + */ +function supportsAndroidKeepAppData(geckodriverPath: string): boolean { + const cached = keepAppDataSupport.get(geckodriverPath); + if (cached !== undefined) { + return cached; + } + + let supported = false; + try { + const help = execFileSync(geckodriverPath, ['--help'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + supported = help.includes('--android-keep-app-data'); + } catch (error) { + logDebug(`Failed to probe geckodriver --help: ${(error as Error).message}`); + } + + keepAppDataSupport.set(geckodriverPath, supported); + return supported; +} + export class FirefoxCore { private currentContextId: string | null = null; private driver: WebDriver | null = null; @@ -168,23 +199,42 @@ export class FirefoxCore { const isAndroid = this.options.androidDevice !== undefined; const androidPackage = this.options.androidPackage ?? 'org.mozilla.firefox'; - if (isAndroid && !this.options.androidWipeAppData) { + // Resolve geckodriver up front on Android: whether the session wipes the app data + // depends on the flags supported by that specific binary. + // Pre-setting the path also makes selenium-webdriver skip getBinaryPaths(), which + // would otherwise discover the desktop Firefox binary and inject it into + // moz:firefoxOptions.binary - conflicting with androidPackage. + let androidGeckodriverPath = ''; + let keepAppData = false; + if (isAndroid) { + androidGeckodriverPath = await findGeckodriver(); + logDebug(`Using geckodriver: ${androidGeckodriverPath}`); + keepAppData = + !this.options.androidWipeAppData && supportsAndroidKeepAppData(androidGeckodriverPath); + } + + if (isAndroid && !this.options.androidWipeAppData && !keepAppData) { // geckodriver runs "adb shell pm clear " before every Android session - // (AndroidHandler::prepare) and offers no way to opt out, so launching wipes the - // data of the target app instead of only using its own temporary profile. - // Bug 2064088 tracks adding an opt-out to geckodriver. + // (AndroidHandler::prepare), so launching wipes the data of the target app instead + // of only using its own temporary profile. Bug 2064088 added --android-keep-app-data + // as an opt-out, but this geckodriver does not support it yet. throw new Error( `Firefox for Android mode wipes all data of ${androidPackage} ` + 'on the device: tabs, history, bookmarks, passwords, cookies and settings are all lost, ' + - 'because geckodriver clears the app data before every session and cannot be configured to skip it. ' + - 'Pass --android-wipe-app-data (or ANDROID_WIPE_APP_DATA=true) to confirm. ' + + 'because this geckodriver clears the app data before every session and does not support ' + + '--android-keep-app-data. Upgrade geckodriver, or pass --android-wipe-app-data ' + + '(or ANDROID_WIPE_APP_DATA=true) to confirm. ' + 'Prefer a build dedicated to automation, for instance --android-package org.mozilla.fenix for Nightly.' ); } if (isAndroid) { log('Launching Firefox for Android via ADB...'); - log(`Wiping all data of ${androidPackage} on the device`); + if (keepAppData) { + log(`Keeping the existing data of ${androidPackage} on the device`); + } else { + log(`Wiping all data of ${androidPackage} on the device`); + } } else if (this.options.connectExisting) { log('Connecting to existing Firefox via Marionette...'); } else { @@ -192,12 +242,6 @@ export class FirefoxCore { } if (isAndroid) { - // Pre-set the geckodriver path so selenium-webdriver skips getBinaryPaths(), - // which would otherwise discover the desktop Firefox binary and inject it into - // moz:firefoxOptions.binary — conflicting with androidPackage. - const geckodriverPath = await findGeckodriver(); - logDebug(`Using geckodriver: ${geckodriverPath}`); - const mozOptions: Record = { androidPackage }; const deviceSerial = this.options.androidDevice; if (deviceSerial && deviceSerial !== 'auto') { @@ -214,7 +258,10 @@ export class FirefoxCore { caps.set('acceptInsecureCerts', true); } - const serviceBuilder = new firefox.ServiceBuilder(geckodriverPath); + const serviceBuilder = new firefox.ServiceBuilder(androidGeckodriverPath); + if (keepAppData) { + serviceBuilder.addArguments('--android-keep-app-data'); + } this.driver = firefox.Driver.createSession(caps, serviceBuilder.build()); } else if (this.options.connectExisting) { let port = this.options.marionettePort ?? 2828; diff --git a/src/firefox/types.ts b/src/firefox/types.ts index a447631..7e1f4c6 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -97,7 +97,7 @@ export interface FirefoxLaunchOptions { androidDevice?: string | undefined; /** Android app package name (default: org.mozilla.firefox) */ androidPackage?: string | undefined; - /** Acknowledge that Android mode wipes all data of the target app; required to launch on Android */ + /** Wipe all data of the target app on Android; required when geckodriver lacks --android-keep-app-data */ androidWipeAppData?: boolean | undefined; /** Capture network request/response bodies via BiDi data collectors (default: true) */ captureNetworkBodies?: boolean | undefined; diff --git a/tests/firefox/core.test.ts b/tests/firefox/core.test.ts index d6518b7..b5e4c8d 100644 --- a/tests/firefox/core.test.ts +++ b/tests/firefox/core.test.ts @@ -287,7 +287,61 @@ describe('FirefoxCore', () => { }); describe('FirefoxCore connect() Android app data wipe opt-in', () => { + const mockAddArguments = vi.fn(); + const mockCreateSession = vi.fn(); + + // Builds the module mocks, with a geckodriver --help output that either advertises + // --android-keep-app-data or not. + const mockAndroidEnv = (keepAppDataSupported: boolean) => { + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn( + () => + `Options:\n${keepAppDataSupported ? ' --android-keep-app-data\n' : ''} -h, --help\n` + ), + })); + + vi.doMock('node:fs', () => ({ + existsSync: vi.fn((p: unknown) => String(p).includes('geckodriver')), + mkdirSync: vi.fn(), + openSync: vi.fn().mockReturnValue(3), + closeSync: vi.fn(), + readdirSync: vi.fn(() => []), + statSync: vi.fn(), + readFileSync: vi.fn(), + })); + + vi.doMock('selenium-webdriver/firefox.js', () => ({ + default: { + ServiceBuilder: class { + addArguments = mockAddArguments; + build = vi.fn(); + }, + Driver: { createSession: mockCreateSession }, + }, + })); + + vi.doMock('selenium-webdriver', () => ({ + Capabilities: class { + set = vi.fn(); + }, + Builder: class {}, + Browser: { FIREFOX: 'firefox' }, + })); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mockCreateSession.mockReturnValue({ + getCapabilities: vi.fn(() => ({ get: vi.fn(() => '123.4') })), + getWindowHandle: vi.fn().mockResolvedValue('mock-context-id'), + get: vi.fn().mockResolvedValue(undefined), + }); + }); + it('should refuse to launch on Android without androidWipeAppData', async () => { + mockAndroidEnv(false); + const { FirefoxCore } = await import('@/firefox/core.js'); const core = new FirefoxCore({ androidDevice: 'auto' }); await expect(core.connect()).rejects.toThrow( @@ -296,6 +350,8 @@ describe('FirefoxCore connect() Android app data wipe opt-in', () => { }); it('should name the target package in the error', async () => { + mockAndroidEnv(false); + const { FirefoxCore } = await import('@/firefox/core.js'); const core = new FirefoxCore({ androidDevice: 'emulator-5554', androidPackage: 'org.mozilla.fenix', @@ -303,6 +359,26 @@ describe('FirefoxCore connect() Android app data wipe opt-in', () => { await expect(core.connect()).rejects.toThrow(/wipes all data of org\.mozilla\.fenix/); }); + + it('should launch without androidWipeAppData when geckodriver supports --android-keep-app-data', async () => { + mockAndroidEnv(true); + const { FirefoxCore } = await import('@/firefox/core.js'); + const core = new FirefoxCore({ androidDevice: 'auto' }); + + await core.connect(); + + expect(mockAddArguments).toHaveBeenCalledWith('--android-keep-app-data'); + }); + + it('should not pass --android-keep-app-data when androidWipeAppData is set', async () => { + mockAndroidEnv(true); + const { FirefoxCore } = await import('@/firefox/core.js'); + const core = new FirefoxCore({ androidDevice: 'auto', androidWipeAppData: true }); + + await core.connect(); + + expect(mockAddArguments).not.toHaveBeenCalled(); + }); }); // Tests for connect() behavior with mocked Selenium