From 027802789232224a46581b9b51f9dfb26ad6e09a Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Thu, 19 Feb 2026 10:05:13 -0500 Subject: [PATCH] fix: inject polyfill via onCommitted to guarantee early availability and bypass CSP The manifest content_scripts entry (world: MAIN, document_start) can lose the race against inline page scripts and may be wrapped as an ES module by CRXJS in dev mode. Additionally, injecting via an ISOLATED world loader with script.textContent is blocked by strict page CSP. Add a webNavigation.onCommitted handler that injects the polyfill programmatically via chrome.scripting.executeScript with files:[] and injectImmediately: true. This bypasses page CSP and runs at document_start-equivalent timing. The manifest entry is kept as a belt-and-suspenders fallback; the polyfill's own guard prevents double-initialization. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/content-scripts/webmcp-polyfill.js | 1 - src/lib/webmcp/lifecycle.ts | 42 ++++++++++++++- tests/integration/webmcp-basic.test.ts | 1 + tests/integration/webmcp-e2e-flow.test.ts | 3 ++ tests/integration/webmcp-integration.test.ts | 3 ++ tests/integration/webmcp-navigation.test.ts | 6 +++ .../webmcp-script-injection-timing.test.ts | 52 +++++++++++++++---- .../integration/webmcp-tool-execution.test.ts | 3 ++ tests/webmcp-lifecycle.test.ts | 7 +++ 9 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/content-scripts/webmcp-polyfill.js b/src/content-scripts/webmcp-polyfill.js index 58a3b54..7b9147a 100644 --- a/src/content-scripts/webmcp-polyfill.js +++ b/src/content-scripts/webmcp-polyfill.js @@ -17,7 +17,6 @@ // Guard: already initialized (either by us or native browser support) if ('modelContext' in navigator) { - console.log('[WebMCP] Native navigator.modelContext detected, skipping polyfill'); // Still set up window.agent alias for backward compat if not present if (!('agent' in window)) { Object.defineProperty(window, 'agent', { diff --git a/src/lib/webmcp/lifecycle.ts b/src/lib/webmcp/lifecycle.ts index 4cc28a5..08343ee 100644 --- a/src/lib/webmcp/lifecycle.ts +++ b/src/lib/webmcp/lifecycle.ts @@ -323,6 +323,43 @@ export class TabManager { log.debug(`[WebMCP Lifecycle] Navigation starting for tab ${details.tabId}`); }); + // Navigation committed - inject polyfill ASAP before any page scripts run. + // onCommitted fires when the server responds and the new document is about to be created, + // before any HTML is parsed. Combined with injectImmediately + files:[], this bypasses + // page CSP and runs at the equivalent of document_start. + // The manifest content_scripts entry (world: MAIN, document_start) is kept as a fallback + // but may lose the race on some pages or be wrapped by CRXJS in dev mode. + chrome.webNavigation.onCommitted.addListener(async (details) => { + if (details.frameId !== 0) return; + + // Skip restricted URLs + const url = details.url; + if ( + url.startsWith('chrome://') || + url.startsWith('chrome-extension://') || + url.startsWith('edge://') || + url.startsWith('about:') + ) { + return; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId: details.tabId, frameIds: [0] }, + world: 'MAIN', + injectImmediately: true, + files: ['content-scripts/webmcp-polyfill.js'], + }); + log.debug(`[WebMCP Lifecycle] Early polyfill injected into tab ${details.tabId}`); + } catch (error) { + // Non-fatal: manifest content_scripts fallback may still work + log.debug( + `[WebMCP Lifecycle] Early polyfill injection failed for tab ${details.tabId}:`, + error + ); + } + }); + // DOM is ready - inject our scripts chrome.webNavigation.onDOMContentLoaded.addListener(async (details) => { if (details.frameId !== 0) return; // Main frame only @@ -396,8 +433,9 @@ export class TabManager { log.debug(`[WebMCP Lifecycle] Injecting scripts into tab ${tabId} (${tab.url})`); - // Polyfill is injected via manifest content_scripts (world: MAIN, run_at: document_start) - // so it's guaranteed to be available before any page scripts run. + // Polyfill is injected early via onCommitted + injectImmediately (see setupNavigationMonitor). + // The manifest content_scripts entry acts as a belt-and-suspenders fallback. + // The polyfill's own guard (`if ('modelContext' in navigator)`) prevents double-init. // 1. Inject the relay content script (isolated world) // CRITICAL: Must be first so it's listening when bridge sends initial snapshot diff --git a/tests/integration/webmcp-basic.test.ts b/tests/integration/webmcp-basic.test.ts index aef0f3c..5dafed1 100644 --- a/tests/integration/webmcp-basic.test.ts +++ b/tests/integration/webmcp-basic.test.ts @@ -23,6 +23,7 @@ describe('WebMCP Basic Integration', () => { }, webNavigation: { onBeforeNavigate: { addListener: vi.fn() }, + onCommitted: { addListener: vi.fn() }, onDOMContentLoaded: { addListener: vi.fn() }, }, tabs: { diff --git a/tests/integration/webmcp-e2e-flow.test.ts b/tests/integration/webmcp-e2e-flow.test.ts index 2704ea8..6d694b0 100644 --- a/tests/integration/webmcp-e2e-flow.test.ts +++ b/tests/integration/webmcp-e2e-flow.test.ts @@ -60,6 +60,9 @@ describe('WebMCP E2E Message Flow', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/integration/webmcp-integration.test.ts b/tests/integration/webmcp-integration.test.ts index e0d3039..aa98d0d 100644 --- a/tests/integration/webmcp-integration.test.ts +++ b/tests/integration/webmcp-integration.test.ts @@ -44,6 +44,9 @@ describe('WebMCP Integration - Sidebar ↔ Tab Communication', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/integration/webmcp-navigation.test.ts b/tests/integration/webmcp-navigation.test.ts index 1a1a36f..5b07ab6 100644 --- a/tests/integration/webmcp-navigation.test.ts +++ b/tests/integration/webmcp-navigation.test.ts @@ -11,6 +11,7 @@ describe('WebMCP Navigation Integration', () => { let lifecycleManager: TabManager; let navigationHandlers: { onBeforeNavigate?: Function; + onCommitted?: Function; onDOMContentLoaded?: Function; }; @@ -58,6 +59,11 @@ describe('WebMCP Navigation Integration', () => { navigationHandlers.onBeforeNavigate = handler; }), }, + onCommitted: { + addListener: vi.fn((handler: Function) => { + navigationHandlers.onCommitted = handler; + }), + }, onDOMContentLoaded: { addListener: vi.fn((handler: Function) => { navigationHandlers.onDOMContentLoaded = handler; diff --git a/tests/integration/webmcp-script-injection-timing.test.ts b/tests/integration/webmcp-script-injection-timing.test.ts index 56f3623..4485579 100644 --- a/tests/integration/webmcp-script-injection-timing.test.ts +++ b/tests/integration/webmcp-script-injection-timing.test.ts @@ -86,6 +86,9 @@ describe('WebMCP Script Injection Timing', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, @@ -153,18 +156,29 @@ describe('WebMCP Script Injection Timing', () => { }); }); - it('should inject polyfill via manifest content_scripts (not programmatically)', async () => { + it('should inject polyfill programmatically via onCommitted', async () => { const tabId = 456; - await lifecycleManager.ensureContentScriptReady(tabId); + // Capture the onCommitted handler + const onCommittedHandler = + mockChrome.webNavigation.onCommitted.addListener.mock.calls[0]?.[0]; + expect(onCommittedHandler).toBeDefined(); + + // Simulate onCommitted firing (this triggers polyfill injection) + await onCommittedHandler({ tabId, frameId: 0, url: 'https://example.com' }); - // Polyfill is no longer injected programmatically — it comes from manifest content_scripts. - // Verify no executeScript call references the polyfill. + // Verify the polyfill was injected programmatically via onCommitted const allCalls = mockChrome.scripting.executeScript.mock.calls; const polyfillCalls = allCalls.filter( (call: any) => call[0]?.files?.[0] === 'content-scripts/webmcp-polyfill.js' ); - expect(polyfillCalls).toHaveLength(0); + expect(polyfillCalls).toHaveLength(1); + expect(polyfillCalls[0][0]).toEqual({ + target: { tabId, frameIds: [0] }, + world: 'MAIN', + injectImmediately: true, + files: ['content-scripts/webmcp-polyfill.js'], + }); }); it('should inject relay early for message passing setup', async () => { @@ -452,6 +466,9 @@ describe('WebMCP Script Injection Timing', () => { mockChrome.webNavigation.onBeforeNavigate.addListener = vi.fn((handler) => { navigationHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navigationHandlers.onCommitted = handler; + }); mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navigationHandlers.onDOMContentLoaded = handler; }); @@ -476,6 +493,15 @@ describe('WebMCP Script Injection Timing', () => { }); } + // onCommitted fires — injects polyfill + if (navigationHandlers.onCommitted) { + await navigationHandlers.onCommitted({ + tabId, + frameId: 0, + url: 'https://example.com/new-page', + }); + } + // DOM ready triggers re-injection if (navigationHandlers.onDOMContentLoaded) { await navigationHandlers.onDOMContentLoaded({ @@ -485,13 +511,14 @@ describe('WebMCP Script Injection Timing', () => { }); } - // Should inject same number of scripts - expect(injectionLog.length).toBe(initialInjectionCount); + // Should inject: polyfill (from onCommitted) + same scripts as initial injection + expect(injectionLog.length).toBe(initialInjectionCount + 1); - // Order: relay → tools → bridge (polyfill via manifest content_scripts) - expect(injectionLog[0].files).toEqual(['content-scripts/relay.js']); - expect(injectionLog[1]?.files?.[0]).toMatch(/^tools\/.*\.js$/); - expect(injectionLog[2].files).toEqual(['content-scripts/page-bridge.js']); + // Order: polyfill (onCommitted) → relay → tools → bridge + expect(injectionLog[0].files).toEqual(['content-scripts/webmcp-polyfill.js']); + expect(injectionLog[1].files).toEqual(['content-scripts/relay.js']); + expect(injectionLog[2]?.files?.[0]).toMatch(/^tools\/.*\.js$/); + expect(injectionLog[3].files).toEqual(['content-scripts/page-bridge.js']); }); it('should handle rapid navigations without double injection', async () => { @@ -501,6 +528,9 @@ describe('WebMCP Script Injection Timing', () => { mockChrome.webNavigation.onBeforeNavigate.addListener = vi.fn((handler) => { navigationHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navigationHandlers.onCommitted = handler; + }); mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navigationHandlers.onDOMContentLoaded = handler; }); diff --git a/tests/integration/webmcp-tool-execution.test.ts b/tests/integration/webmcp-tool-execution.test.ts index 798b6d8..5bc4698 100644 --- a/tests/integration/webmcp-tool-execution.test.ts +++ b/tests/integration/webmcp-tool-execution.test.ts @@ -50,6 +50,9 @@ describe('WebMCP Tool Execution Integration', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/webmcp-lifecycle.test.ts b/tests/webmcp-lifecycle.test.ts index 03de394..7a1bfef 100644 --- a/tests/webmcp-lifecycle.test.ts +++ b/tests/webmcp-lifecycle.test.ts @@ -31,6 +31,9 @@ const mockChrome = { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, @@ -85,6 +88,10 @@ describe('TabManager', () => { navHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navHandlers.onCommitted = handler; + }); + mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navHandlers.onDOMContentLoaded = handler; });