Skip to content

Fix moz-extension:// navigation hang by bypassing geckodriver#87

Merged
juliandescottes merged 5 commits into
mozilla:mainfrom
csamu:fix/moz-extension
May 28, 2026
Merged

Fix moz-extension:// navigation hang by bypassing geckodriver#87
juliandescottes merged 5 commits into
mozilla:mainfrom
csamu:fix/moz-extension

Conversation

@csamu

@csamu csamu commented May 17, 2026

Copy link
Copy Markdown
Contributor

The problem

TLDR: Navigating to moz-extension:// URLs doesn't work. driver.get() hangs because the underlying BiDi command never gets a response.

When navigating to a moz-extension:// URL, the PageManagement.navigate() method uses Selenium to send a HTTP request to Geckodriver that sends a WebDriver BiDi request to Firefox (the BiDi Remote Agent server) to load the extension page. The Remote Agent recives the message and Firefox changes the page. The problem is that Firefox never triggers the "i am done" event when loading extension pages, thus never triggers the Remote Agent to send a response back to Geckodriver, leaving Geckodriver listening for a response indefinitely. There is no timeout.

This is the same class of bug as the zombie-geckodriver fix: geckodriver blocks indefinitely when Firefox doesn't respond. In the zombie case, Firefox is gone entirely. Here, Firefox is alive but the Remote Agent never responds.

So there are actually two upstream bugs, one in Geckodriver and one in the Remote Agent.
This fix is just a workaround that makes the MCP work anyway. When the actual bugs are fixed, this becomes unecessary.

The solution

TLDR: Bypass Geckodriver entirely and send a manual BiDi request over WebSocket to Firefox.

Instead of using driver.get() to query Firefox, which uses Geckodriver, we instead send a manual WebDriver BiDi command browsingContext.navigate with wait: "none" using FirefoxCore.sendBiDiCommand, causing the Remote Agent to load the page and respond immediately.

The downside is that there is no timing guarantee. The extension might not actually have finished loading when the MCP tool call returns. But in an AI agent context, it likely doesn't matter in practice

Files changed

  • Edit src/firefox/index.ts - Pass sendBiDiCommand callback to PageManagement
  • Edit src/firefox/pages.ts - Add isPrivilegedUrl(). Add BiDi navigation to navigate(). Use navigate() in createNewPage()

Tests

  • tests/firefox/pages.test.ts - Unit tests for isPrivilegedUrl, navigate and the createNewPage delegation
  • tests/integration/moz-extension.integration.test.ts - Integration tests with real Firefox: installs a fixture extension, resolves its UUID to construct valid moz-extension:// URLs, and verifies navigation without hanging
  • tests/fixtures/test-extension/ - Minimal extension fixture for integration tests

@freema

freema commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Verified locally on a worktree of this PR. The bypass is legit: skips geckodriver entirely and goes straight over the BiDi WebSocket via FirefoxCore.sendBiDiCommand with wait: 'none'....

@freema freema self-requested a review May 18, 2026 10:15

@freema freema left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up — the Format check step on CI is red:

https://github.com/mozilla/firefox-devtools-mcp/actions/runs/25980005541/job/76504149200?pr=87

Prettier flags two of the new test files:

tests/firefox/pages.test.ts
tests/integration/moz-extension.integration.test.ts

Should be a one-shot npm run format and re-push.

@juliandescottes

Copy link
Copy Markdown
Collaborator

@freema please hold off merging this, I am checking whether navigating to moz-extension is something we can allow here. We are currently restricting navigation to non-http/https pages unless you request privileged access, so we should be careful with workarounds here.

Comment thread src/firefox/pages.ts Outdated
Comment on lines +39 to +52
if (isPrivilegedUrl(url) && this.sendBiDiCommand) {
const contextId = this.getCurrentContextId();
if (!contextId) {
throw new Error(`Cannot navigate to privileged URL ${url}: no browsing context ID`);
}
await this.sendBiDiCommand('browsingContext.navigate', {
context: contextId,
url,
wait: 'none',
});
logDebug(`BiDi navigate (wait:none) to: ${url}`);
} else {
await this.driver.get(url);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of switching between BiDi and Classic, I would rather suggest using BiDi in all cases, and simply specifying wait=none when the URL is privileged

@juliandescottes

Copy link
Copy Markdown
Collaborator

Navigation to non http/https is likely to be restricted on the Firefox side to only work with sessions are started with --remote-allow-system-access, which maps to the --enable-privileged-context CLI argument here.

@csamu can you describe your scenario here? I suspect we should allow this only when --enable-privileged-context is passed, which will ultimately match how Firefox will allow such navigations. But maybe there are cases where this makes sense for a regular usage, having more context would be helpful.

@csamu

csamu commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Navigation to non http/https is likely to be restricted on the Firefox side to only work with sessions are started with --remote-allow-system-access, which maps to the --enable-privileged-context CLI argument here.

@csamu can you describe your scenario here? I suspect we should allow this only when --enable-privileged-context is passed, which will ultimately match how Firefox will allow such navigations. But maybe there are cases where this makes sense for a regular usage, having more context would be helpful.

It's useful during extension development with agents.

  1. Agent calls install_extension which doesn't give you the UUID.
  2. Agent must now do list_extensions to get the UUID (but requires --enable-privileged-context)
  3. Agent navigates to moz-extension://UUID.

So fetching the UUIDs is already gated behind --enable-privileged-context. But if you somehow get a hold of one, Firefox actually allows moz-extension://UUID without --remote-allow-system-access, but as you said, "likely to be restricted". So you know these things better than me...

Before this fix, I used a trick to test extensions through the MCP, even without --enable-privileged-context:

  1. Start the MCP and let it install the extension.
  2. Let the MCP navigate to file://path/to/empty.html#open-extension
  3. Firefox opens the empty html file, but the extension content script detects the #open-extension and sends a message to the extension background script to change the tab to browser.runtime.getURL("src/hello.html") which magically resolves to moz-extension://UUID/hello.html.
  4. The MCP can now interact with the extension, even if the tab URL is moz-extension://UUID/hello.html.

Regardless, if a restriction should be added, a proposal would be to put args.enablePrivilegedContext in the options to the FirefoxDevTools constructor, then pass it to the PageManagement constructor like: private hasPrivilegedContext?: () => boolean and then use it in navigate() and throw on restriction. Something like that.

@juliandescottes

juliandescottes commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Alright, thanks for the feedback, supporting webextension development makes a lot of sense. That's not an area where webdriver classic / bidi offers a lot, compared to the actual Firefox DevTools.

In the long run, maybe it would make sense to have tools dedicated to webextensions so that you don't have to get the UUID and build the URL. For now it sounds fine to add a way to allow the navigation when enablePrivilegedContext is true.

I think for now it's fine to modify the navigate tool to automatically switch to wait=none when the scheme is not http/https/data/blob/file. Firefox can be responsible for now to fail the navigation or not (and once the implementation has settled on the Firefox side, we can update the MCP to have a dedicated command for "privileged" protocols).

Can you update the PR to:

  • consistently use BiDi for navigation, and use wait=none when needed
  • instead of checking if the scheme is moz-extension, I would rather check if it's not http/https/data/blob/file (it's more or less a safe subset of schemes that should work in the long run)

Thanks!

Replace isPrivilegedUrl with isCommonScheme (negation check).
Navigate always uses BiDi browsingContext.navigate: common schemes
(http/https/data/blob/file) get wait:interactive, uncommon schemes
(moz-extension:/about:/etc.) get wait:none. sendBiDiCommand is now
required. Trim unit tests. Add afterEach reset and tab cleanup to
integration tests.

@juliandescottes juliandescottes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update! That looks good to me, I would just simplify a bit the test and remove raceWithTimeout. I will merge a bit later, if you have time to update it before that great, if not I can follow up.

Comment thread src/firefox/pages.ts
throw new Error(`Cannot navigate: no browsing context ID`);
}

// Default wait time is "interactive" (DOMContentLoaded).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be completely transparent here, maybe complete would be a more faithful translation of the current implementation. However BiDi is much more strict than Classic when it comes to navigation, so I prefer to stick with interactive for now.

// Non-standard schemes use wait:"none" because the Remote Agent
// doesn't emit navigation completion events for extension contexts.
// A 5s timeout proves no hang while tolerating slow CI.
const result = await raceWithTimeout(firefox.navigate(extensionUrl), 5000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the raceWithTimeout is necessary here. The test timeout is set to 15s so it's not a significant difference compared to 5s.

I would remove the two call sites and the helper.

@juliandescottes

Copy link
Copy Markdown
Collaborator

Thanks for the update, all tests seem to be passing, I'm going to merge this.

@juliandescottes juliandescottes merged commit 4c55ffc into mozilla:main May 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants