From fb89fb4d6156d11919ae2317ed3dcb26d72612c0 Mon Sep 17 00:00:00 2001 From: Noorez Kassam Date: Tue, 19 May 2026 12:22:36 -0600 Subject: [PATCH 1/2] feat: add WebExtension debugging support via extensionPath config Adds an `extensionPath` property to Chrome/Edge launch and attach configs that enables debugging unpacked browser extensions (MV2/MV3) without manual target picking or workarounds. When set: - Launch: auto-injects --load-extension and filters CDP targets to the extension's service worker or background page - Attach: same target filter, works with web-ext or any externally launched Chrome - Source maps: chrome-extension:///path resolves to extensionPath/path using a UUID-ignoring regex (same pattern as vscode-firefox-debug) After the main target is attached, the resolved extension ID is pinned in the path resolver so subsequent source URLs are matched only against that exact extension origin, preventing any other loaded extensions from being incorrectly resolved or attached to. Also fixes a race condition where Inspector.workerScriptLoaded arrives on the service worker's CDP session before createSession() is called, which previously threw an unhandled error. Adds a "Chrome: Launch Extension" configuration snippet for discoverability in the Add Configuration menu. Closes #945 (WebExtension debugging support) Co-Authored-By: Claude Opus 4.7 (1M context) --- OPTIONS.md | 18 +++++++++++------ package.nls.json | 3 +++ src/build/generate-contributions.ts | 16 +++++++++++++++ src/cdp/connection.ts | 10 ++++++++-- src/configuration.ts | 11 +++++++++++ src/targets/browser/browserAttacher.ts | 6 ++++++ src/targets/browser/browserLauncher.ts | 23 +++++++++++++++++++++- src/targets/browser/browserPathResolver.ts | 23 ++++++++++++++++++++++ src/targets/sourcePathResolverFactory.ts | 3 +++ 9 files changed, 104 insertions(+), 9 deletions(-) diff --git a/OPTIONS.md b/OPTIONS.md index f59a48a3f..6c54fbbbd 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -231,7 +231,8 @@
Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

Default value:
true

env

Optional dictionary of environment key/value pairs for the browser.

-
Default value:
{}

file

A local html file to open in the browser

+
Default value:
{}

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

file

A local html file to open in the browser

Default value:
null

includeDefaultArgs

Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.

Default value:
true

includeLaunchArgs

Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with --remote-debugging-pipe.

Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

@@ -287,7 +288,8 @@
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

-
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
true

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

Default value:
[
   "${workspaceFolder}/**/*.(m|c|)js",
@@ -340,7 +342,8 @@
 
Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

Default value:
true

env

Optional dictionary of environment key/value pairs for the browser.

-
Default value:
{}

file

A local html file to open in the browser

+
Default value:
{}

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

file

A local html file to open in the browser

Default value:
null

includeDefaultArgs

Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.

Default value:
true

includeLaunchArgs

Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with --remote-debugging-pipe.

Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

@@ -397,7 +400,8 @@
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

-
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
true

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

Default value:
[
   "${workspaceFolder}/**/*.(m|c|)js",
@@ -446,7 +450,8 @@
 
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

-
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
true

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

Default value:
[
   "${workspaceFolder}/**/*.(m|c|)js",
@@ -491,7 +496,8 @@
 
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

-
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
true

extensionPath

Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.

+
Default value:
null

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

Default value:
[
   "${workspaceFolder}/**/*.(m|c|)js",
diff --git a/package.nls.json b/package.nls.json
index e19e9f450..ebb70b2a0 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -26,6 +26,7 @@
   "browser.profileStartup.description": "If true, will start profiling soon as the process launches",
   "browser.restart": "Whether to reconnect if the browser connection is closed",
   "browser.revealPage": "Focus Tab",
+  "browser.extensionPath.description": "Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.",
   "browser.runtimeArgs.description": "Optional arguments passed to the runtime executable.",
   "browser.runtimeExecutable.description": "Either 'canary', 'stable', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or CHROME_PATH environment variable.",
   "browser.runtimeExecutable.edge.description": "Either 'canary', 'stable', 'dev', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or EDGE_PATH environment variable.",
@@ -47,6 +48,8 @@
   "chrome.label": "Web App (Chrome)",
   "chrome.launch.description": "Launch Chrome to debug a URL",
   "chrome.launch.label": "Chrome: Launch",
+  "chrome.launch.extension.description": "Launch Chrome to debug an unpacked browser extension",
+  "chrome.launch.extension.label": "Chrome: Launch Extension",
   "editorBrowser.attach.description": "Attach to an open VS Code integrated browser",
   "editorBrowser.attach.label": "Integrated Browser: Attach",
   "editorBrowser.label": "Web App (Integrated Browser)",
diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts
index e27f9b5b9..a7dea0dcc 100644
--- a/src/build/generate-contributions.ts
+++ b/src/build/generate-contributions.ts
@@ -793,6 +793,11 @@ const chromiumBaseConfigurationAttributes: ConfigurationAttributes = {
         webRoot: '^"${2:\\${workspaceFolder\\}}"',
       },
     },
+    {
+      label: refString('chrome.launch.extension.label'),
+      description: refString('chrome.launch.extension.description'),
+      body: {
+        type: DebugType.Chrome,
+        request: 'launch',
+        name: 'Launch Chrome Extension',
+        extensionPath: '^"${1:\\${workspaceFolder\\}}"',
+        webRoot: '^"${2:\\${workspaceFolder\\}}"',
+      },
+    },
   ],
   configurationAttributes: {
     ...chromiumBaseConfigurationAttributes,
diff --git a/src/cdp/connection.ts b/src/cdp/connection.ts
index ade3b967d..c5fc282a4 100644
--- a/src/cdp/connection.ts
+++ b/src/cdp/connection.ts
@@ -105,9 +105,15 @@ export default class Connection {
     if (!session) {
       const disposedDate = this._disposedSessions.get(object.sessionId);
       if (!disposedDate) {
-        throw new Error(
-          `Unknown session id: ${object.sessionId} while processing: ${object.method}`,
+        // This can happen when Chrome sends events on a new session (e.g. an
+        // extension service worker) before our createSession() call has run —
+        // a race between Target.attachToTarget's response and early CDP events.
+        this.logger.warn(
+          LogTag.Internal,
+          `Got message for unknown session, ignoring`,
+          { sessionId: object.sessionId, method: object.method },
         );
+        return;
       } else {
         const secondsAgo = (Date.now() - disposedDate.getTime()) / 1000.0;
         this.logger.warn(
diff --git a/src/configuration.ts b/src/configuration.ts
index f311d499d..bb83373d4 100644
--- a/src/configuration.ts
+++ b/src/configuration.ts
@@ -454,6 +454,14 @@ export interface INodeLaunchConfiguration extends INodeBaseConfiguration, IConfi
 export type PathMapping = Readonly<{ [key: string]: string }>;
 
 export interface IChromiumBaseConfiguration extends IBaseConfiguration {
+  /**
+   * Absolute path to the root directory of an unpacked browser extension to
+   * load and debug. When set, the debugger automatically passes
+   * --load-extension to Chrome/Edge (launch only) and attaches to the
+   * extension's background script or service worker rather than a web page.
+   */
+  extensionPath: string | null;
+
   /**
    * Controls whether to skip the network cache for each request.
    */
@@ -959,6 +967,7 @@ export const chromeAttachConfigDefaults: IChromeAttachConfiguration = {
   request: 'attach',
   address: 'localhost',
   port: 0,
+  extensionPath: null,
   disableNetworkCache: true,
   pathMapping: {},
   url: null,
@@ -996,6 +1005,7 @@ export const chromeLaunchConfigDefaults: IChromeLaunchConfiguration = {
   profileStartup: false,
   cleanUp: 'wholeBrowser',
   killBehavior: KillBehavior.Forceful,
+  extensionPath: null,
 };
 
 export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
@@ -1006,6 +1016,7 @@ export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
 
 const editorBrowserBaseDefaults: IChromiumBaseConfiguration = {
   ...baseDefaults,
+  extensionPath: null,
   disableNetworkCache: true,
   pathMapping: {},
   url: null,
diff --git a/src/targets/browser/browserAttacher.ts b/src/targets/browser/browserAttacher.ts
index 92c9b2f22..b321b670f 100644
--- a/src/targets/browser/browserAttacher.ts
+++ b/src/targets/browser/browserAttacher.ts
@@ -182,6 +182,12 @@ export class BrowserAttacher<
     manager: BrowserTargetManager,
     params: AnyChromiumAttachConfiguration,
   ): Promise {
+    if (params.extensionPath) {
+      return (t: { url: string; type: string }) =>
+        t.url.startsWith('chrome-extension://')
+        && (t.type === BrowserTargetType.ServiceWorker || t.type === BrowserTargetType.Page);
+    }
+
     const rawFilter = createTargetFilterForConfig(params);
     const baseFilter = requirePageTarget(rawFilter);
     if (params.targetSelection !== 'pick') {
diff --git a/src/targets/browser/browserLauncher.ts b/src/targets/browser/browserLauncher.ts
index 24fa2dfb4..48c2ad78d 100644
--- a/src/targets/browser/browserLauncher.ts
+++ b/src/targets/browser/browserLauncher.ts
@@ -29,6 +29,7 @@ import { ProtocolError } from '../../dap/protocolError';
 import { FS, FsPromises, IInitializeParams, StoragePath } from '../../ioc-extras';
 import { ITelemetryReporter } from '../../telemetry/telemetryReporter';
 import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets';
+import { BrowserSourcePathResolver } from './browserPathResolver';
 import { BrowserTargetManager } from './browserTargetManager';
 import { BrowserTarget, BrowserTargetType } from './browserTargets';
 import * as launcher from './launcher';
@@ -88,6 +89,7 @@ export abstract class BrowserLauncher
       cleanUp,
       launchUnelevated: launchUnelevated,
       killBehavior,
+      extensionPath,
     }: T,
     dap: Dap.Api,
     cancellationToken: CancellationToken,
@@ -117,6 +119,10 @@ export abstract class BrowserLauncher
       resolvedDataDir = fs.realpathSync(resolvedDataDir);
     }
 
+    const effectiveRuntimeArgs = extensionPath
+      ? [...(runtimeArgs || []), `--load-extension=${extensionPath}`]
+      : runtimeArgs || [];
+
     return await launcher.launch(
       dap,
       executablePath,
@@ -132,7 +138,7 @@ export abstract class BrowserLauncher
         hasUserNavigation: !!(url || file),
         cwd: cwd || webRoot || undefined,
         env: EnvironmentVars.merge(EnvironmentVars.processEnv(), env),
-        args: runtimeArgs || [],
+        args: effectiveRuntimeArgs,
         userDataDir: resolvedDataDir,
         connection: port || (inspectUri ? 0 : 'pipe'), // We don't default to pipe if we are using an inspectUri
         launchUnelevated: launchUnelevated,
@@ -147,6 +153,11 @@ export abstract class BrowserLauncher
   }
 
   protected getFilterForTarget(params: T) {
+    if (params.extensionPath) {
+      return (t: { url: string; type: string }) =>
+        t.url.startsWith('chrome-extension://')
+        && (t.type === BrowserTargetType.ServiceWorker || t.type === BrowserTargetType.Page);
+    }
     return requirePageTarget(createTargetFilterForConfig(params, ['about:blank']));
   }
 
@@ -294,6 +305,16 @@ export abstract class BrowserLauncher
       throw new ProtocolError(targetPageNotFound());
     }
 
+    // Once we know which target we attached to, pin the extension ID in the
+    // path resolver so subsequent source URL resolutions only accept that exact
+    // extension and not any other chrome-extension:// origin.
+    if (params.extensionPath) {
+      const idMatch = mainTarget.fileName()?.match(/^chrome-extension:\/\/([a-z0-9]{32})\//);
+      if (idMatch) {
+        (this.pathResolver as BrowserSourcePathResolver).pinExtensionId(idMatch[1]);
+      }
+    }
+
     return mainTarget;
   }
 
diff --git a/src/targets/browser/browserPathResolver.ts b/src/targets/browser/browserPathResolver.ts
index f64a735a9..ee798be47 100644
--- a/src/targets/browser/browserPathResolver.ts
+++ b/src/targets/browser/browserPathResolver.ts
@@ -31,6 +31,9 @@ export interface IOptions extends ISourcePathResolverOptions {
   pathMapping: PathMapping;
   clientID: string | undefined;
   remoteFilePrefix: string | undefined;
+  extensionPath?: string;
+  /** Pinned extension ID once discovered from the attached target's URL. */
+  extensionId?: string;
 }
 
 const enum Suffix {
@@ -51,6 +54,11 @@ export class BrowserSourcePathResolver extends SourcePathResolverBase
     super(options, logger);
   }
 
+  /** Pins the resolved extension ID so path resolution only accepts that exact origin. */
+  public pinExtensionId(id: string) {
+    (this.options as IOptions).extensionId = id;
+  }
+
   /** @override */
   private absolutePathToUrlPath(absolutePath: string): { url: string; needsWildcard: boolean } {
     absolutePath = path.normalize(absolutePath);
@@ -108,6 +116,21 @@ export class BrowserSourcePathResolver extends SourcePathResolverBase
     // URIs (vscode-dwarf-debugging-ext#7)
     url = this.sourceMapOverrides.apply(url);
 
+    // Map chrome-extension:///path → extensionPath/path.
+    // If extensionId is known (pinned after attaching to the target) we match
+    // only that exact ID. Otherwise we accept any 32-char ID so the very first
+    // resolution works before we know the ID.
+    if (this.options.extensionPath && url.startsWith('chrome-extension://')) {
+      const idPattern = this.options.extensionId
+        ? this.options.extensionId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+        : '[a-z0-9]{32}';
+      const match = url.match(new RegExp(`^chrome-extension://${idPattern}(/.*)?\$`));
+      if (match) {
+        const relPath = (match[1] ?? '/').replace(/^\//, '');
+        return path.join(this.options.extensionPath, relPath || 'index.html');
+      }
+    }
+
     // If we have a file URL, we know it's absolute already and points
     // to a location on disk.
     if (utils.isFileUrl(url)) {
diff --git a/src/targets/sourcePathResolverFactory.ts b/src/targets/sourcePathResolverFactory.ts
index 6f6f636e4..04f61b230 100644
--- a/src/targets/sourcePathResolverFactory.ts
+++ b/src/targets/sourcePathResolverFactory.ts
@@ -92,6 +92,9 @@ export class SourcePathResolverFactory implements ISourcePathResolverFactory {
           sourceMapOverrides: c.sourceMapPathOverrides,
           clientID: this.initializeParams.clientID,
           remoteFilePrefix: c.__remoteFilePrefix,
+          extensionPath: 'extensionPath' in c && typeof c.extensionPath === 'string'
+            ? c.extensionPath
+            : undefined,
         },
         logger,
       );

From 65a7ce17f61223de92cedd63e34b46c694d211e7 Mon Sep 17 00:00:00 2001
From: Noorez Kassam 
Date: Wed, 20 May 2026 09:21:16 -0600
Subject: [PATCH 2/2] refine: tighten unknown-session handling to events only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The previous fix suppressed all messages for unregistered sessions.
A command *response* arriving on an unknown session would indicate a
real bug (we only send commands after createSession()) and should still
throw so it isn't silently swallowed. Only CDP events (object.method is
set) are safe to drop — they are fire-and-forget with no waiting caller.
---
 src/cdp/connection.ts | 25 +++++++++++++++++--------
 1 file changed, 17 insertions(+), 8 deletions(-)

diff --git a/src/cdp/connection.ts b/src/cdp/connection.ts
index c5fc282a4..febe68881 100644
--- a/src/cdp/connection.ts
+++ b/src/cdp/connection.ts
@@ -105,15 +105,24 @@ export default class Connection {
     if (!session) {
       const disposedDate = this._disposedSessions.get(object.sessionId);
       if (!disposedDate) {
-        // This can happen when Chrome sends events on a new session (e.g. an
-        // extension service worker) before our createSession() call has run —
-        // a race between Target.attachToTarget's response and early CDP events.
-        this.logger.warn(
-          LogTag.Internal,
-          `Got message for unknown session, ignoring`,
-          { sessionId: object.sessionId, method: object.method },
+        if (object.method) {
+          // Event (not a response) on an unregistered session. This can happen
+          // when Chrome pushes events (e.g. Inspector.workerScriptLoaded) on a
+          // new session before our createSession() call completes — a race
+          // between Target.attachToTarget's response and early CDP events.
+          // Safe to drop: events are fire-and-forget with no waiting caller.
+          this.logger.warn(
+            LogTag.Internal,
+            `Got event for unknown session, ignoring`,
+            { sessionId: object.sessionId, method: object.method },
+          );
+          return;
+        }
+        // A command response on an unregistered session is a real bug — we
+        // only send commands after createSession(), so this should never happen.
+        throw new Error(
+          `Unknown session id: ${object.sessionId} while processing response`,
         );
-        return;
       } else {
         const secondsAgo = (Date.now() - disposedDate.getTime()) / 1000.0;
         this.logger.warn(