From d5b9f028831f388998eb9da8a2c7ac12ee0c7372 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 | 17 +++++++++++++++- 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, 112 insertions(+), 8 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..febe68881 100644
--- a/src/cdp/connection.ts
+++ b/src/cdp/connection.ts
@@ -105,8 +105,23 @@ export default class Connection {
     if (!session) {
       const disposedDate = this._disposedSessions.get(object.sessionId);
       if (!disposedDate) {
+        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: ${object.method}`,
+          `Unknown session id: ${object.sessionId} while processing response`,
         );
       } else {
         const secondsAgo = (Date.now() - disposedDate.getTime()) / 1000.0;
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 5558dd1c2369ec083d41bd918e632f42b029b5d2 Mon Sep 17 00:00:00 2001
From: Noorez Kassam 
Date: Mon, 20 Jul 2026 13:42:04 -0600
Subject: [PATCH 2/2] fix: tolerate out-of-order lifecycle events in source and
 worker tracking

Two related issues surface more often once extensions are debuggable,
because extension service workers restart frequently (MV3 idle timeout)
and content scripts can re-inject on tab navigation.

1. `SourceContainer.removeSource` asserted "Expected source to be the same
   as the existing reference" when a source's reference id had been
   reassigned to a different Source instance for the same URL between when
   this source was replaced and when its owning ExecutionContext was
   finally destroyed:

     - Context X1 loads URL U -> Source A at reference R
     - Context X2 (later) loads same URL U -> in addSource, existing A is
       removed silently, freeing R; new Source B is added with reference R
       (getSourceReference re-uses the freed id since xxHash32(U) is stable)
     - Context X1 is destroyed. ExecutionContext.remove still holds A and
       calls removeSource(A). The reference now points to B.

   Suppress the assertion only when the replacement has the same URL as
   the removed source. Any other mismatch is still logged.

2. `ServiceWorkerModel._workerRegistrationsUpdated` had a leftover raw
   `debugger;` statement that fired when a delete arrived for an unknown
   registration id. Replaced with a verbose-level log line.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/adapter/sourceContainer.ts              | 17 +++++++++++++----
 src/targets/browser/browserTargetManager.ts |  2 +-
 src/targets/browser/serviceWorkers.ts       | 13 +++++++++++--
 3 files changed, 25 insertions(+), 7 deletions(-)

diff --git a/src/adapter/sourceContainer.ts b/src/adapter/sourceContainer.ts
index e91cbb153..c239f901c 100644
--- a/src/adapter/sourceContainer.ts
+++ b/src/adapter/sourceContainer.ts
@@ -809,10 +809,19 @@ export class SourceContainer {
       return; // already removed
     }
 
-    this.logger.assert(
-      source === existing,
-      'Expected source to be the same as the existing reference',
-    );
+    if (existing !== source) {
+      // The reference was reassigned. If the replacement source has the same
+      // URL, this is an expected lifecycle event (e.g. an extension service
+      // worker restarted and re-registered the same URL under the freed
+      // reference id) — silently ignore. Any other mismatch is unexpected
+      // and still flagged so genuine state bugs surface.
+      this.logger.assert(
+        existing.url === source.url,
+        'Expected source to be the same as the existing reference',
+      );
+      return;
+    }
+
     this._sourceByReference.delete(source.sourceReference);
 
     // check for overwrites:
diff --git a/src/targets/browser/browserTargetManager.ts b/src/targets/browser/browserTargetManager.ts
index e685bc69d..2c952f477 100644
--- a/src/targets/browser/browserTargetManager.ts
+++ b/src/targets/browser/browserTargetManager.ts
@@ -23,7 +23,7 @@ export class BrowserTargetManager implements IDisposable {
   protected readonly _browser: Cdp.Api;
   private readonly _detachedTargets = new Set();
   readonly frameModel = new FrameModel();
-  readonly serviceWorkerModel = new ServiceWorkerModel(this.frameModel);
+  readonly serviceWorkerModel = new ServiceWorkerModel(this.frameModel, this.logger);
   private _lifecycleQueue = Promise.resolve();
   _sourcePathResolver: ISourcePathResolver;
   _targetOrigin: ITargetOrigin;
diff --git a/src/targets/browser/serviceWorkers.ts b/src/targets/browser/serviceWorkers.ts
index c20118cb7..07348916b 100644
--- a/src/targets/browser/serviceWorkers.ts
+++ b/src/targets/browser/serviceWorkers.ts
@@ -5,6 +5,7 @@
 import { URL } from 'url';
 import Cdp from '../../cdp/api';
 import { EventEmitter, IDisposable } from '../../common/events';
+import { ILogger, LogTag } from '../../common/logging';
 import { FrameModel } from './frames';
 
 export class ServiceWorkerRegistration {
@@ -84,7 +85,7 @@ export class ServiceWorkerModel implements IDisposable {
   private static _mode: ServiceWorkerMode;
   private static _instances = new Set();
 
-  constructor(frameModel: FrameModel) {
+  constructor(frameModel: FrameModel, private readonly _logger?: ILogger) {
     this._frameModel = frameModel;
     ServiceWorkerModel._instances.add(this);
   }
@@ -163,7 +164,15 @@ export class ServiceWorkerModel implements IDisposable {
   _workerRegistrationsUpdated(payloads: Cdp.ServiceWorker.ServiceWorkerRegistration[]): void {
     for (const payload of payloads) {
       if (payload.isDeleted) {
-        if (!this._registrations.has(payload.registrationId)) debugger;
+        if (!this._registrations.has(payload.registrationId)) {
+          // Delete received for a registration we don't have. Log at verbose
+          // level rather than breaking into the debugger.
+          this._logger?.verbose(
+            LogTag.RuntimeTarget,
+            'Ignoring delete for unknown service worker registration',
+            { registrationId: payload.registrationId },
+          );
+        }
         this._registrations.delete(payload.registrationId);
       } else {
         if (this._registrations.has(payload.registrationId)) return;