Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 <package>` 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 <package>` 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 <serial> --android-wipe-app-data
npx @mozilla/firefox-devtools-mcp --android-device <serial>

# Use Firefox Nightly instead
npx @mozilla/firefox-devtools-mcp --android-device <serial> --android-package org.mozilla.fenix --android-wipe-app-data
npx @mozilla/firefox-devtools-mcp --android-device <serial> --android-package org.mozilla.fenix
```

Port forwarding between the host and device is handled automatically by geckodriver.
Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
75 changes: 61 additions & 14 deletions src/firefox/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -150,6 +151,36 @@ async function findGeckodriver(): Promise<string> {
return found;
}

const keepAppDataSupport = new Map<string, boolean>();

/**
* 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;
Expand All @@ -168,36 +199,49 @@ 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 <package>" 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 {
log('Launching Firefox via Selenium WebDriver BiDi...');
}

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<string, unknown> = { androidPackage };
const deviceSerial = this.options.androidDevice;
if (deviceSerial && deviceSerial !== 'auto') {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/firefox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions tests/firefox/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -296,13 +350,35 @@ 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',
});

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
Expand Down