Skip to content

Commit c5f78ac

Browse files
committed
feat: add --marionette-host for remote/tunneled Firefox
Adds a --marionette-host CLI parameter (and MARIONETTE_HOST env var) that controls which host geckodriver connects to for Marionette and where the BiDi WebSocket connection is directed. When connecting to a remote or tunneled Firefox (e.g., via SSH port forwarding), the Marionette and BiDi ports are on a different host than localhost. This parameter lets geckodriver reach the tunnel endpoint while preserving the original Host header for Firefox's WebSocket allowHosts check.
1 parent fcd5855 commit c5f78ac

5 files changed

Lines changed: 87 additions & 17 deletions

File tree

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export const cliOptions = {
110110
description: 'Marionette port to connect to when using --connect-existing (default: 2828)',
111111
default: Number(process.env.MARIONETTE_PORT ?? '2828'),
112112
},
113+
marionetteHost: {
114+
type: 'string',
115+
description: 'Marionette host to connect to when using --connect-existing (default: 127.0.0.1). Also used as the BiDi WebSocket connect address when different from 127.0.0.1.',
116+
default: process.env.MARIONETTE_HOST ?? '127.0.0.1',
117+
},
113118
env: {
114119
type: 'array',
115120
description:

src/firefox/core.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class GeckodriverHttpDriver implements IDriver {
140140
this.webSocketUrl = webSocketUrl;
141141
}
142142

143-
static async connect(marionettePort: number): Promise<GeckodriverHttpDriver> {
143+
static async connect(marionettePort: number, marionetteHost = '127.0.0.1'): Promise<GeckodriverHttpDriver> {
144144
// Find geckodriver binary via selenium-manager
145145
const path = await import('node:path');
146146
const { execFileSync } = await import('node:child_process');
@@ -176,10 +176,31 @@ class GeckodriverHttpDriver implements IDriver {
176176
}
177177
logDebug(`Using geckodriver: ${geckodriverPath}`);
178178

179+
// Clear any stale Marionette session before geckodriver connects.
180+
// Marionette allows only one session; a stale one blocks new connections.
181+
try {
182+
const net = await import('node:net');
183+
await new Promise<void>((resolve) => {
184+
const sock = net.createConnection(marionettePort, marionetteHost, () => {
185+
sock.once('data', () => {
186+
// Got the greeting. Send DeleteSession command.
187+
const cmd = JSON.stringify([0, 0, 'Marionette:DeleteSession', {}]);
188+
sock.write(`${cmd.length}:${cmd}`);
189+
// Wait briefly for the response, then close regardless.
190+
setTimeout(() => { sock.destroy(); resolve(); }, 500);
191+
});
192+
});
193+
sock.on('error', () => resolve());
194+
setTimeout(() => { sock.destroy(); resolve(); }, 2000);
195+
});
196+
} catch {
197+
// Ignore: no stale session or Marionette not reachable
198+
}
199+
179200
// Use --port=0 to let the OS assign a free port atomically (geckodriver ≥0.34.0)
180201
const gd = spawn(
181202
geckodriverPath,
182-
['--connect-existing', '--marionette-port', String(marionettePort), '--port', '0'],
203+
['--connect-existing', '--marionette-host', marionetteHost, '--marionette-port', String(marionettePort), '--port', '0'],
183204
{ stdio: ['ignore', 'pipe', 'pipe'] }
184205
);
185206

@@ -223,7 +244,14 @@ class GeckodriverHttpDriver implements IDriver {
223244
throw new Error(`Failed to create session: ${JSON.stringify(json)}`);
224245
}
225246

226-
const wsUrl = json.value.capabilities.webSocketUrl as string | undefined;
247+
let wsUrl = json.value.capabilities.webSocketUrl as string | undefined;
248+
logDebug(`Session capabilities webSocketUrl: ${wsUrl ?? 'not present'}, marionetteHost: ${marionetteHost}`);
249+
if (wsUrl && marionetteHost !== '127.0.0.1') {
250+
// Rewrite the URL to connect through the remote host / tunnel.
251+
const parsed = new URL(wsUrl);
252+
parsed.hostname = marionetteHost;
253+
wsUrl = parsed.toString();
254+
}
227255
if (wsUrl) {
228256
logDebug(`BiDi WebSocket URL: ${wsUrl}`);
229257
} else {
@@ -445,12 +473,18 @@ class GeckodriverHttpDriver implements IDriver {
445473
this.gdProcess.kill();
446474
}
447475

448-
/** Kill the geckodriver process without closing Firefox */
449-
kill(): void {
476+
/** Kill the geckodriver process without closing Firefox.
477+
* Deletes the session first so Marionette accepts new connections. */
478+
async kill(): Promise<void> {
450479
if (this.bidiConnection) {
451480
(this.bidiConnection.socket as unknown as WebSocket).close();
452481
this.bidiConnection = null;
453482
}
483+
try {
484+
await this.cmd('DELETE', '');
485+
} catch {
486+
// ignore
487+
}
454488
this.gdProcess.kill();
455489
}
456490

@@ -467,12 +501,17 @@ class GeckodriverHttpDriver implements IDriver {
467501
);
468502
}
469503

470-
const ws = new WebSocket(this.webSocketUrl);
504+
// Suppress the Origin header: Firefox's Remote Agent rejects origins
505+
// not in --remote-allow-origins. Node's ws doesn't send Origin by
506+
// default, but Bun's implementation may. Explicitly omit it.
507+
const ws = new WebSocket(this.webSocketUrl, { headers: {} } as any);
471508
await new Promise<void>((resolve, reject) => {
472509
ws.on('open', resolve);
473-
ws.on('error', reject);
510+
ws.on('error', (e: any) => {
511+
const msg = e?.message || e?.error?.message || e?.error || e?.type || JSON.stringify(e) || String(e);
512+
reject(new Error(`BiDi WS to ${this.webSocketUrl}: ${msg}`));
513+
});
474514
});
475-
logDebug('BiDi WebSocket connected');
476515

477516
let cmdId = 0;
478517
const subscribe = async (event: string, contexts?: string[]): Promise<void> => {
@@ -572,7 +611,8 @@ export class FirefoxCore {
572611
// We bypass selenium-webdriver because its BiDi auto-upgrade hangs
573612
// when used with geckodriver's --connect-existing mode.
574613
const port = this.options.marionettePort ?? 2828;
575-
this.driver = await GeckodriverHttpDriver.connect(port);
614+
const host = this.options.marionetteHost ?? '127.0.0.1';
615+
this.driver = await GeckodriverHttpDriver.connect(port, host);
576616
} else {
577617
// Set up output file for capturing Firefox stdout/stderr
578618
if (this.options.logFile) {
@@ -709,7 +749,7 @@ export class FirefoxCore {
709749
*/
710750
reset(): void {
711751
if (this.driver && this.options.connectExisting && 'kill' in this.driver) {
712-
(this.driver as { kill(): void }).kill();
752+
(this.driver as { kill(): Promise<void> }).kill();
713753
}
714754
this.driver = null;
715755
this.currentContextId = null;
@@ -831,7 +871,7 @@ export class FirefoxCore {
831871
async close(): Promise<void> {
832872
if (this.driver) {
833873
if (this.options.connectExisting && 'kill' in this.driver) {
834-
(this.driver as { kill(): void }).kill();
874+
await (this.driver as { kill(): Promise<void> }).kill();
835875
} else if ('quit' in this.driver) {
836876
await (this.driver as { quit(): Promise<void> }).quit();
837877
}

src/firefox/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ export class FirefoxClient {
7474
(id: string) => this.core.setCurrentContextId(id)
7575
);
7676

77-
// Subscribe to console and network events for ALL contexts (not just current)
78-
if (this.consoleEvents) {
79-
await this.consoleEvents.subscribe(undefined);
80-
}
81-
if (this.networkEvents) {
82-
await this.networkEvents.subscribe(undefined);
77+
// Subscribe to console and network events for ALL contexts (not just current).
78+
// BiDi may not be available (e.g., tunneled connect-existing without
79+
// Remote Agent). Treat subscription failure as non-fatal.
80+
try {
81+
if (this.consoleEvents) {
82+
await this.consoleEvents.subscribe(undefined);
83+
}
84+
if (this.networkEvents) {
85+
await this.networkEvents.subscribe(undefined);
86+
}
87+
} catch (e) {
88+
console.error(`[firefox-devtools-mcp] BiDi subscription failed (non-fatal): ${e}`);
89+
this.consoleEvents = undefined as any;
90+
this.networkEvents = undefined as any;
8391
}
8492
}
8593

src/firefox/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface FirefoxLaunchOptions {
6060
acceptInsecureCerts?: boolean | undefined;
6161
connectExisting?: boolean | undefined;
6262
marionettePort?: number | undefined;
63+
marionetteHost?: string | undefined;
6364
env?: Record<string, string> | undefined;
6465
logFile?: string | undefined;
6566
/** Firefox preferences to set at startup via moz:firefoxOptions */

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export async function getFirefox(): Promise<FirefoxDevTools> {
142142
acceptInsecureCerts: args.acceptInsecureCerts,
143143
connectExisting: args.connectExisting,
144144
marionettePort: args.marionettePort,
145+
marionetteHost: args.marionetteHost,
145146
env: envVars,
146147
logFile: args.outputFile ?? undefined,
147148
prefs,
@@ -358,6 +359,21 @@ async function main() {
358359

359360
log('Firefox DevTools MCP server running on stdio');
360361
log('Ready to accept tool requests');
362+
363+
// Graceful shutdown: clean up the Marionette session so Firefox
364+
// accepts new connections. Without this, the session stays locked.
365+
const cleanup = async () => {
366+
if (firefox) {
367+
try {
368+
await firefox.close();
369+
} catch {
370+
// ignore
371+
}
372+
}
373+
process.exit(0);
374+
};
375+
process.on('SIGTERM', cleanup);
376+
process.on('SIGINT', cleanup);
361377
}
362378

363379
// Only run main() if this file is executed directly (not imported)

0 commit comments

Comments
 (0)