From 1e48a3772c3e6c8d21093fd46707c2bf59b067a5 Mon Sep 17 00:00:00 2001 From: Noorez Kassam Date: Mon, 27 Jul 2026 16:37:13 -0600 Subject: [PATCH] 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 The extension id is derived offline rather than guessed: Chrome hashes the manifest `key` (or, absent one, the absolute path), takes the first 16 bytes and maps each nibble onto a-p. Deriving it lets the target filter require an exact match, which matters because browsers also expose service workers for their own component extensions, and those can be reported before the extension under debug. This addresses the "non-deterministic extension ID" blocker noted on #945. An idle MV3 service worker has no CDP target at all, so there is nothing to attach to until something starts it. When no target is present the debugger now calls ServiceWorker.startWorker for the extension's scope, which is what previously required the manual "start the worker in DevTools" dance described in #1445. Lifecycle: an extension service worker torn down by a reload is reported only via Inspector.targetCrashed (no detachedFromTarget/targetDestroyed), which previously left a dead target in the Call Stack forever; crashed service worker sessions are now released. An empty target list is also a normal transient state for an extension session (idle worker, closed popup), so extension sessions stay alive and re-attach when the next target appears instead of terminating. Launch mode extras for extensions: - Auto-reload: the extension directory is watched and re-installed via Extensions.loadUnpacked when its files change (pipe transport only); the new worker re-attaches through the normal target flow. Unlike chrome.runtime.reload(), loadUnpacked reliably restarts the worker. - The chrome://extensions developer-mode UI is enabled in the launched profile so the unpacked extension's inspection links are usable. - Each debugged extension gets its own short-named profile directory. The name must stay short: an extension origin's IndexedDB LevelDB paths add ~106 characters under the profile, and a profile base past ~135 characters exceeds MAX_PATH on Windows, breaking the extension's IndexedDB with "Internal error opening database". The VS Code-side resolver previously pinned profiles under workspaceStorage, whose paths already exceed that threshold in common setups. - New `ephemeralUserDataDir` option deletes the profile before launch for a clean per-session browser state. Deletion is rename-then-delete with retries so a locked profile is reused whole rather than left half-deleted with corrupted storage. Also includes four robustness fixes surfaced by extension debugging, each of which produced spurious errors before: - cdp/connection: Chrome can push events (e.g. Inspector.workerScriptLoaded) on a new service worker session before createSession() completes. Such events are now dropped with a warning rather than throwing. Command *responses* on an unknown session still throw, since those would indicate a real bug. - sourceContainer: removeSource asserted when a source's reference id had been reassigned to a newer Source for the same URL, which happens routinely as service workers restart. Suppressed only for the same-URL case; other mismatches still log. - serviceWorkers: replaced a leftover `debugger;` statement, hit when a delete arrived for an unknown registration, with a verbose log. - resourceProvider: fetching a chrome-extension:// sourcemap threw an opaque "Unsupported protocol" from the HTTP client, escaping before the existing CDP fallback could run. Non-HTTP schemes now return a failed response so the browser, which understands them, can serve the resource. Adds a "Chrome: Launch Extension" configuration snippet so the option is discoverable from the Add Configuration menu. Closes #945 --- OPTIONS.md | 20 ++- package.nls.json | 4 + .../resourceProvider/basicResourceProvider.ts | 13 ++ src/adapter/sourceContainer.ts | 17 +- src/build/generate-contributions.ts | 21 +++ src/cdp/connection.ts | 17 +- src/configuration.ts | 19 +++ src/targets/browser/browserAttacher.ts | 34 +++- src/targets/browser/browserLauncher.ts | 146 +++++++++++++++++- src/targets/browser/browserPathResolver.ts | 23 +++ src/targets/browser/browserTargetManager.ts | 130 +++++++++++++++- src/targets/browser/edgeLauncher.ts | 2 +- src/targets/browser/extensionAutoReload.ts | 94 +++++++++++ src/targets/browser/extensionId.ts | 124 +++++++++++++++ src/targets/browser/remoteBrowserAttacher.ts | 5 +- src/targets/browser/serviceWorkers.ts | 13 +- .../browser/uwpWebviewBrowserAttacher.ts | 5 +- src/targets/browser/vscodeRendererAttacher.ts | 4 +- src/targets/sourcePathResolverFactory.ts | 3 + .../chromiumDebugConfigurationProvider.ts | 20 ++- 20 files changed, 682 insertions(+), 32 deletions(-) create mode 100644 src/targets/browser/extensionAutoReload.ts create mode 100644 src/targets/browser/extensionId.ts diff --git a/OPTIONS.md b/OPTIONS.md index f59a48a3f..4228b5710 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -231,7 +231,9 @@
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:
{}

ephemeralUserDataDir

When true, the profile directory given by (or created for) userDataDir is deleted before the browser launches, so every debug session starts from a clean browser state.

+
Default value:
false

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 +289,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 +343,9 @@
 
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:
{}

ephemeralUserDataDir

When true, the profile directory given by (or created for) userDataDir is deleted before the browser launches, so every debug session starts from a clean browser state.

+
Default value:
false

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 +402,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 +452,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 +498,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..c01fb9ed1 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -26,6 +26,8 @@
   "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.ephemeralUserDataDir.description": "When true, the profile directory given by (or created for) `userDataDir` is deleted before the browser launches, so every debug session starts from a clean browser state.",
+  "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 +49,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/adapter/resourceProvider/basicResourceProvider.ts b/src/adapter/resourceProvider/basicResourceProvider.ts
index 0fa0de4f4..8e3fd96d0 100644
--- a/src/adapter/resourceProvider/basicResourceProvider.ts
+++ b/src/adapter/resourceProvider/basicResourceProvider.ts
@@ -88,6 +88,19 @@ export class BasicResourceProvider implements IResourceProvider {
   ): Promise> {
     const parsed = new URL(url);
 
+    // Schemes the HTTP client can't speak (chrome-extension:, webpack:, ...)
+    // would make it throw an opaque "Unsupported protocol" error. Return a
+    // failed response instead, so subclasses can fall back to loading the
+    // resource through the browser, which does understand them.
+    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+      return {
+        ok: false,
+        url,
+        statusCode: 0,
+        error: new Error(`Cannot fetch ${parsed.protocol} URLs over HTTP`),
+      };
+    }
+
     const isSecure = parsed.protocol !== 'http:';
     const port = Number(parsed.port) ?? (isSecure ? 443 : 80);
     const options: OptionsOfTextResponseBody = { headers, followRedirect: true };
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/build/generate-contributions.ts b/src/build/generate-contributions.ts
index e27f9b5b9..a979b5b0a 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,
@@ -873,6 +889,11 @@ const chromeLaunchConfig: IDebugger = {
       description: refString('browser.userDataDir.description'),
       default: true,
     },
+    ephemeralUserDataDir: {
+      type: 'boolean',
+      description: refString('browser.ephemeralUserDataDir.description'),
+      default: false,
+    },
     includeDefaultArgs: {
       type: 'boolean',
       description: refString('browser.includeDefaultArgs.description'),
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..43b5e5b2b 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.
    */
@@ -631,6 +639,13 @@ export interface IChromiumLaunchConfiguration extends IChromiumBaseConfiguration
    */
   userDataDir: string | boolean;
 
+  /**
+   * When true, the profile directory given by (or created for) userDataDir is
+   * deleted before the browser launches, so every debug session starts from a
+   * clean browser state.
+   */
+  ephemeralUserDataDir: boolean;
+
   /**
    * The debug adapter is running elevated. Launch Chrome unelevated to avoid the security restrictions of running Chrome elevated
    */
@@ -959,6 +974,7 @@ export const chromeAttachConfigDefaults: IChromeAttachConfiguration = {
   request: 'attach',
   address: 'localhost',
   port: 0,
+  extensionPath: null,
   disableNetworkCache: true,
   pathMapping: {},
   url: null,
@@ -992,10 +1008,12 @@ export const chromeLaunchConfigDefaults: IChromeLaunchConfiguration = {
   runtimeArgs: null,
   runtimeExecutable: '*',
   userDataDir: true,
+  ephemeralUserDataDir: false,
   browserLaunchLocation: 'workspace',
   profileStartup: false,
   cleanUp: 'wholeBrowser',
   killBehavior: KillBehavior.Forceful,
+  extensionPath: null,
 };
 
 export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
@@ -1006,6 +1024,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..94715187b 100644
--- a/src/targets/browser/browserAttacher.ts
+++ b/src/targets/browser/browserAttacher.ts
@@ -21,11 +21,12 @@ import {
 import { AnyChromiumAttachConfiguration, AnyLaunchConfiguration } from '../../configuration';
 import { browserAttachFailed, targetPageNotFound } from '../../dap/errors';
 import { ProtocolError } from '../../dap/protocolError';
-import { VSCodeApi } from '../../ioc-extras';
+import { FS, FsPromises, VSCodeApi } from '../../ioc-extras';
 import { ITelemetryReporter } from '../../telemetry/telemetryReporter';
 import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets';
 import { BrowserTargetManager } from './browserTargetManager';
 import { BrowserTargetType } from './browserTargets';
+import { createExtensionTargetFilter, resolveExpectedExtensionId } from './extensionId';
 import * as launcher from './launcher';
 
 @injectable()
@@ -46,6 +47,7 @@ export class BrowserAttacher<
   constructor(
     @inject(ILogger) protected readonly logger: ILogger,
     @inject(ISourcePathResolver) private readonly pathResolver: ISourcePathResolver,
+    @inject(FS) private readonly fs: FsPromises,
     @optional() @inject(VSCodeApi) private readonly vscode?: typeof vscodeType,
   ) {}
 
@@ -158,15 +160,33 @@ export class BrowserAttacher<
     });
     targetManager.onTargetRemoved(() => {
       this._onTargetListChangedEmitter.fire();
-      if (!targetManager.targetList().length && this.closeWhenTargetsEmpty) {
+      // For extension debugging an empty target list is a normal, transient
+      // state (idle worker, closed popup); stay attached and wait for the
+      // next target instead of ending the session.
+      if (
+        !targetManager.targetList().length && this.closeWhenTargetsEmpty && !params.extensionPath
+      ) {
         // graceful exit
         this._onTerminatedEmitter.fire({ killed: true, code: 0 });
         this._connection?.close();
       }
     });
 
+    const filter = await this.getTargetFilter(targetManager, params);
+    const waitForTarget = targetManager.waitForMainTarget(filter);
+
+    // An idle extension service worker has no target to attach to, so ask the
+    // browser to start it. Done after waitForMainTarget has subscribed so we
+    // don't miss the target it creates.
+    if (params.extensionPath) {
+      const extensionId = await resolveExpectedExtensionId(params.extensionPath, this.fs);
+      if (extensionId) {
+        await targetManager.wakeExtensionServiceWorker(extensionId);
+      }
+    }
+
     const result = await Promise.race([
-      targetManager.waitForMainTarget(await this.getTargetFilter(targetManager, params)),
+      waitForTarget,
       delay(params.timeout).then(() => {
         throw new ProtocolError(targetPageNotFound());
       }),
@@ -182,6 +202,14 @@ export class BrowserAttacher<
     manager: BrowserTargetManager,
     params: AnyChromiumAttachConfiguration,
   ): Promise {
+    if (params.extensionPath) {
+      return createExtensionTargetFilter(
+        params.extensionPath,
+        await resolveExpectedExtensionId(params.extensionPath, this.fs),
+        this.logger,
+      );
+    }
+
     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..288860972 100644
--- a/src/targets/browser/browserLauncher.ts
+++ b/src/targets/browser/browserLauncher.ts
@@ -14,7 +14,7 @@ import { DisposableList } from '../../common/disposable';
 import { EnvironmentVars } from '../../common/environmentVars';
 import { EventEmitter } from '../../common/events';
 import { existsInjected } from '../../common/fsUtils';
-import { ILogger } from '../../common/logging';
+import { ILogger, LogTag } from '../../common/logging';
 import { delay } from '../../common/promiseUtil';
 import { ISourcePathResolver } from '../../common/sourcePathResolver';
 import {
@@ -29,8 +29,15 @@ 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 { ExtensionAutoReloader } from './extensionAutoReload';
+import {
+  createExtensionTargetFilter,
+  extensionProfileDirName,
+  resolveExpectedExtensionId,
+} from './extensionId';
 import * as launcher from './launcher';
 
 export interface IDapInitializeParamsWithExtensions extends Dap.InitializeParams {
@@ -43,6 +50,7 @@ export abstract class BrowserLauncher
 {
   private _connectionForTest: CdpConnection | undefined;
   private _targetManager: BrowserTargetManager | undefined;
+  private _extensionReloader: ExtensionAutoReloader | undefined;
   protected _disposables = new DisposableList();
   private _onTerminatedEmitter = new EventEmitter();
   readonly onTerminated = this._onTerminatedEmitter.event;
@@ -62,6 +70,7 @@ export abstract class BrowserLauncher
    * @inheritdoc
    */
   public dispose() {
+    this._extensionReloader?.dispose();
     this._disposables.dispose();
   }
 
@@ -88,6 +97,8 @@ export abstract class BrowserLauncher
       cleanUp,
       launchUnelevated: launchUnelevated,
       killBehavior,
+      extensionPath,
+      ephemeralUserDataDir,
     }: T,
     dap: Dap.Api,
     cancellationToken: CancellationToken,
@@ -101,6 +112,15 @@ export abstract class BrowserLauncher
     let resolvedDataDir: string | undefined;
     if (typeof userDataDir === 'string') {
       resolvedDataDir = path.resolve(userDataDir);
+    } else if (userDataDir && extensionPath) {
+      // Extension debugging gets a profile of its own rather than the shared
+      // `.profile`: concurrent sessions and different browser builds sharing
+      // one profile corrupt its storage. See extensionProfileDirName for why
+      // the name must stay short.
+      const extensionId = await resolveExpectedExtensionId(extensionPath, this.fs);
+      resolvedDataDir = path.resolve(
+        path.join(this.storagePath, extensionProfileDirName(extensionPath, extensionId)),
+      );
     } else if (userDataDir) {
       resolvedDataDir = path.resolve(
         path.join(
@@ -113,10 +133,60 @@ export abstract class BrowserLauncher
     fs.mkdirSync(this.storagePath, { recursive: true });
 
     if (resolvedDataDir) {
+      if (ephemeralUserDataDir) {
+        // Delete via rename: if the directory is locked (e.g. a previous
+        // browser instance is still shutting down) the rename fails atomically
+        // and the profile is reused whole, rather than rmSync deleting part of
+        // it and leaving corrupted browser storage behind. Locks from a
+        // shutting-down browser clear within moments, so retry briefly.
+        const doomed = `${resolvedDataDir}-deleting`;
+        for (let attempt = 0;; attempt++) {
+          try {
+            fs.rmSync(doomed, { recursive: true, force: true });
+            if (fs.existsSync(resolvedDataDir)) {
+              fs.renameSync(resolvedDataDir, doomed);
+              fs.rmSync(doomed, { recursive: true, force: true });
+            }
+            break;
+          } catch (e) {
+            if (attempt < 6) {
+              await delay(500);
+              continue;
+            }
+
+            this.logger.warn(
+              LogTag.RuntimeLaunch,
+              'Could not clean the browser profile directory',
+              { resolvedDataDir, error: e },
+            );
+            dap.output({
+              category: 'stderr',
+              output: l10n.t(
+                'Could not clean the browser profile at {0}; launching with the existing profile',
+                resolvedDataDir,
+              ) + '\n',
+            });
+            break;
+          }
+        }
+      }
+
       fs.mkdirSync(resolvedDataDir, { recursive: true });
       resolvedDataDir = fs.realpathSync(resolvedDataDir);
     }
 
+    const effectiveRuntimeArgs = extensionPath
+      ? [
+        ...(runtimeArgs || []),
+        `--load-extension=${extensionPath}`,
+        // Allows Extensions.loadUnpacked, used to reload the extension when
+        // its files change. Only available over the pipe transport.
+        ...(this.usesPipeConnection(port, inspectUri)
+          ? ['--enable-unsafe-extension-debugging']
+          : []),
+      ]
+      : runtimeArgs || [];
+
     return await launcher.launch(
       dap,
       executablePath,
@@ -132,7 +202,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,
@@ -146,7 +216,19 @@ export abstract class BrowserLauncher
     );
   }
 
-  protected getFilterForTarget(params: T) {
+  /** Whether the browser will be launched with a pipe CDP transport. */
+  private usesPipeConnection(port: number | undefined, inspectUri: string | null | undefined) {
+    return !port && !inspectUri;
+  }
+
+  protected async getFilterForTarget(params: T) {
+    if (params.extensionPath) {
+      return createExtensionTargetFilter(
+        params.extensionPath,
+        await resolveExpectedExtensionId(params.extensionPath, this.fs),
+        this.logger,
+      );
+    }
     return requirePageTarget(createTargetFilterForConfig(params, ['about:blank']));
   }
 
@@ -283,9 +365,24 @@ export abstract class BrowserLauncher
 
     // Note: assuming first page is our main target breaks multiple debugging sessions
     // sharing the browser instance. This can be fixed.
-    const filter = this.getFilterForTarget(params);
+    const filter = await this.getFilterForTarget(params);
+    const waitForTarget = this._targetManager.waitForMainTarget(filter);
+
+    // An idle extension service worker has no target to attach to, so ask the
+    // browser to start it. Done after waitForMainTarget has subscribed so we
+    // don't miss the target it creates.
+    if (params.extensionPath) {
+      const extensionId = await resolveExpectedExtensionId(params.extensionPath, this.fs);
+      if (extensionId) {
+        await this._targetManager.wakeExtensionServiceWorker(extensionId);
+      }
+
+      // best-effort and not needed for attaching; runs while we wait
+      this._targetManager.enableExtensionDeveloperMode();
+    }
+
     const mainTarget = await timeoutPromise(
-      this._targetManager.waitForMainTarget(filter),
+      waitForTarget,
       ctx.cancellationToken,
       'Could not attach to main target',
     );
@@ -294,6 +391,45 @@ export abstract class BrowserLauncher
       throw new ProtocolError(targetPageNotFound());
     }
 
+    // Pin the ID of the extension we attached to, so subsequent source URL
+    // resolutions only accept that exact extension and not any other
+    // chrome-extension:// origin the browser may have loaded.
+    if (params.extensionPath) {
+      const idMatch = mainTarget.fileName()?.match(/^chrome-extension:\/\/([a-p]{32})\//);
+      if (idMatch) {
+        (this.pathResolver as BrowserSourcePathResolver).pinExtensionId(idMatch[1]);
+      }
+    }
+
+    // Reload the extension when its files change. Extensions.loadUnpacked
+    // re-installs from the same path (same ID), which also starts the new
+    // service worker; the debugger then re-attaches through the normal
+    // target-created flow. The method requires the pipe transport.
+    if (params.extensionPath && this.usesPipeConnection(params.port, params.inspectUri)) {
+      const extensionPath = params.extensionPath;
+      const rootSession = cdp.rootSession();
+      this._extensionReloader?.dispose();
+      this._extensionReloader = new ExtensionAutoReloader(
+        extensionPath,
+        async () => {
+          const result = await rootSession.Extensions.loadUnpacked({ path: extensionPath });
+          ctx.dap.output(
+            result
+              ? {
+                category: 'console',
+                output: l10n.t('Reloaded browser extension from {0}', extensionPath) + '\n',
+              }
+              : {
+                category: 'stderr',
+                output: l10n.t('Could not reload the browser extension from {0}', extensionPath)
+                  + '\n',
+              },
+          );
+        },
+        this.logger,
+      );
+    }
+
     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/browser/browserTargetManager.ts b/src/targets/browser/browserTargetManager.ts
index e685bc69d..160d79a3d 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;
@@ -119,6 +119,114 @@ export class BrowserTargetManager implements IDisposable {
     }
   }
 
+  /**
+   * Wakes the service worker of the given extension so that it exposes a CDP
+   * target we can attach to.
+   *
+   * MV3 extension service workers are torn down when idle, and a stopped
+   * worker has no target at all — so there is nothing for
+   * {@link waitForMainTarget} to match until something causes it to start.
+   * `ServiceWorker.startWorker` starts it on demand. The domain is not
+   * available on the browser session, so this borrows a page target's session.
+   *
+   * Best-effort: failures are logged and ignored, since the worker may also
+   * start on its own.
+   */
+  public async wakeExtensionServiceWorker(extensionId: string): Promise {
+    const scopeURL = `chrome-extension://${extensionId}/`;
+    const targets = await this._browser.Target.getTargets({});
+    const page = targets?.targetInfos.find(t => t.type === BrowserTargetType.Page);
+    if (!page) {
+      this.logger.info(
+        LogTag.RuntimeTarget,
+        'No page target available to start the extension service worker from',
+        { scopeURL },
+      );
+      return;
+    }
+
+    const attached = await this._browser.Target.attachToTarget({
+      targetId: page.targetId,
+      flatten: true,
+    });
+    if (!attached) {
+      return;
+    }
+
+    const session = this._connection.createSession(attached.sessionId);
+    try {
+      await session.ServiceWorker.enable({});
+      await session.ServiceWorker.startWorker({ scopeURL });
+      this.logger.verbose(LogTag.RuntimeTarget, 'Requested extension service worker start', {
+        scopeURL,
+      });
+    } catch (e) {
+      this.logger.info(LogTag.RuntimeTarget, 'Could not start extension service worker', {
+        scopeURL,
+        error: e,
+      });
+    } finally {
+      this._connection.disposeSession(attached.sessionId);
+      await this._browser.Target.detachFromTarget({ sessionId: attached.sessionId });
+    }
+  }
+
+  /**
+   * Turns on the chrome://extensions developer-mode UI so the unpacked
+   * extension's "service worker" and error links are usable. The API behind
+   * the UI toggle is only exposed on the extensions WebUI page, so this opens
+   * one in a background tab and closes it again. Best-effort: failures are
+   * logged and ignored.
+   */
+  public async enableExtensionDeveloperMode(): Promise {
+    try {
+      const created = await this._browser.Target.createTarget({
+        url: 'chrome://extensions/',
+        background: true,
+      });
+      if (!created) {
+        return;
+      }
+
+      try {
+        const attached = await this._browser.Target.attachToTarget({
+          targetId: created.targetId,
+          flatten: true,
+        });
+        if (!attached) {
+          return;
+        }
+
+        const session = this._connection.createSession(attached.sessionId);
+        try {
+          // the WebUI may not have loaded far enough to expose the API yet
+          for (let attempt = 0; attempt < 5; attempt++) {
+            const result = await session.Runtime.evaluate({
+              expression:
+                'new Promise(r => chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, () => r(true)))',
+              awaitPromise: true,
+              returnByValue: true,
+            });
+            if (result?.result.value === true) {
+              this.logger.verbose(LogTag.RuntimeTarget, 'Enabled extension developer mode');
+              return;
+            }
+            await new Promise(r => setTimeout(r, 300));
+          }
+        } finally {
+          this._connection.disposeSession(attached.sessionId);
+          await this._browser.Target.detachFromTarget({ sessionId: attached.sessionId });
+        }
+      } finally {
+        await this._browser.Target.closeTarget({ targetId: created.targetId });
+      }
+    } catch (e) {
+      this.logger.info(LogTag.RuntimeTarget, 'Could not enable extension developer mode', {
+        error: e,
+      });
+    }
+  }
+
   /**
    * Returns a promise that pends until the first target matching the given
    * filter attaches.
@@ -236,6 +344,17 @@ export class BrowserTargetManager implements IDisposable {
         this._detachedFromTarget(event.sessionId, false);
       }
     });
+    cdp.Inspector.on('targetCrashed', () => {
+      // A service worker torn down by e.g. `chrome.runtime.reload()` is
+      // reported only as a crash on its own session -- Chrome sends no
+      // detachedFromTarget or targetDestroyed for it. Release it, or the dead
+      // target stays in the list forever. A restarted worker is a new target,
+      // so there is nothing to recover on this session. Crashed pages are left
+      // alone: they keep their target and can be revived by a reload.
+      if (targetInfo.type === BrowserTargetType.ServiceWorker) {
+        this._detachedFromTarget(sessionId, true);
+      }
+    });
 
     cdp.Target.setAutoAttach({ autoAttach: true, waitForDebuggerOnStart, flatten: true });
 
@@ -325,7 +444,14 @@ export class BrowserTargetManager implements IDisposable {
       await this._browser.Target.detachFromTarget({ sessionId });
     }
 
-    if (!this._targets.size && this.launchParams.request === 'launch') {
+    // When debugging an extension, an empty target list is a normal state --
+    // the worker is torn down whenever it goes idle or the extension reloads,
+    // and a popup closes on any focus loss -- so keep the browser and session
+    // alive; new targets attach as they appear.
+    const emptyIsTransient = 'extensionPath' in this.launchParams
+      && this.launchParams.extensionPath;
+
+    if (!this._targets.size && this.launchParams.request === 'launch' && !emptyIsTransient) {
       try {
         if (this.launchParams.cleanUp === 'wholeBrowser') {
           await this._browser.Browser.close({});
diff --git a/src/targets/browser/edgeLauncher.ts b/src/targets/browser/edgeLauncher.ts
index 1144f8c1e..3baf9c57e 100644
--- a/src/targets/browser/edgeLauncher.ts
+++ b/src/targets/browser/edgeLauncher.ts
@@ -83,7 +83,7 @@ export class EdgeLauncher extends BrowserLauncher {
    * we don't want to.
    * @override
    */
-  protected getFilterForTarget(params: IEdgeLaunchConfiguration) {
+  protected async getFilterForTarget(params: IEdgeLaunchConfiguration) {
     return params.useWebView && params.urlFilter
       ? requirePageTarget(createTargetFilterForConfig(params))
       : super.getFilterForTarget(params);
diff --git a/src/targets/browser/extensionAutoReload.ts b/src/targets/browser/extensionAutoReload.ts
new file mode 100644
index 000000000..af7b7199f
--- /dev/null
+++ b/src/targets/browser/extensionAutoReload.ts
@@ -0,0 +1,94 @@
+/*---------------------------------------------------------
+ * Copyright (C) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------*/
+
+import * as fs from 'fs';
+import { IDisposable } from '../../common/events';
+import { ILogger, LogTag } from '../../common/logging';
+
+/**
+ * Debounce window between the last observed file change and the reload, so a
+ * build emitting many files triggers a single reload once it settles.
+ */
+const RELOAD_DEBOUNCE_MS = 400;
+
+/**
+ * Watches an unpacked extension's directory and invokes `reload` when its
+ * contents change. Used for launched browsers where the extension can be
+ * re-installed over CDP (`Extensions.loadUnpacked`), which restarts its
+ * service worker and re-attaches the debugger via the normal target flow.
+ */
+export class ExtensionAutoReloader implements IDisposable {
+  private watcher?: fs.FSWatcher;
+  private timer?: NodeJS.Timeout;
+  private reloading = false;
+  private pending = false;
+  private disposed = false;
+
+  constructor(
+    extensionPath: string,
+    private readonly reload: () => Promise,
+    private readonly logger: ILogger,
+  ) {
+    try {
+      this.watcher = fs.watch(extensionPath, { recursive: true }, () => this.schedule());
+    } catch {
+      try {
+        // recursive watches are unavailable on some platforms; a top-level
+        // watch still catches typical bundler output rewrites
+        this.watcher = fs.watch(extensionPath, () => this.schedule());
+      } catch (e) {
+        this.logger.warn(
+          LogTag.RuntimeTarget,
+          'Could not watch the extension directory for changes; auto-reload disabled',
+          { extensionPath, error: e },
+        );
+      }
+    }
+  }
+
+  private schedule() {
+    if (this.disposed) {
+      return;
+    }
+
+    if (this.timer) {
+      clearTimeout(this.timer);
+    }
+    this.timer = setTimeout(() => this.run(), RELOAD_DEBOUNCE_MS);
+  }
+
+  private async run() {
+    if (this.disposed) {
+      return;
+    }
+
+    // a change arriving while a reload is in flight schedules one trailing
+    // reload rather than stacking them
+    if (this.reloading) {
+      this.pending = true;
+      return;
+    }
+
+    this.reloading = true;
+    try {
+      await this.reload();
+    } catch (e) {
+      this.logger.warn(LogTag.RuntimeTarget, 'Error reloading extension', e);
+    } finally {
+      this.reloading = false;
+      if (this.pending && !this.disposed) {
+        this.pending = false;
+        this.schedule();
+      }
+    }
+  }
+
+  public dispose() {
+    this.disposed = true;
+    if (this.timer) {
+      clearTimeout(this.timer);
+    }
+    this.watcher?.close();
+  }
+}
diff --git a/src/targets/browser/extensionId.ts b/src/targets/browser/extensionId.ts
new file mode 100644
index 000000000..8238f9944
--- /dev/null
+++ b/src/targets/browser/extensionId.ts
@@ -0,0 +1,124 @@
+/*---------------------------------------------------------
+ * Copyright (C) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------*/
+
+import { createHash } from 'crypto';
+import * as path from 'path';
+import { ILogger, LogTag } from '../../common/logging';
+import { FsPromises } from '../../ioc-extras';
+import { BrowserTargetType } from './browserTargets';
+
+/**
+ * Chrome derives an extension ID by hashing a seed, taking the first 16 bytes
+ * of the digest, and mapping each hex nibble onto the letters a-p.
+ */
+const digestToExtensionId = (digest: Buffer) =>
+  [...digest.subarray(0, 16)]
+    .map(byte => byte.toString(16).padStart(2, '0'))
+    .join('')
+    .replace(/[0-9a-f]/g, nibble => String.fromCharCode(97 + parseInt(nibble, 16)));
+
+/**
+ * Computes the extension ID Chrome assigns to an extension declaring the given
+ * `key` in its manifest. This is stable across machines and profiles.
+ */
+export const extensionIdFromManifestKey = (key: string) =>
+  digestToExtensionId(createHash('sha256').update(Buffer.from(key, 'base64')).digest());
+
+/**
+ * Computes the extension ID Chrome assigns to an unpacked extension loaded from
+ * the given absolute path, for manifests with no `key`. Chrome hashes the path
+ * as it spells it internally, so this is best-effort: on Windows it uses
+ * backslashes and an upper-case drive letter.
+ */
+export const extensionIdFromPath = (absolutePath: string) => {
+  let seed = path.resolve(absolutePath);
+  if (process.platform === 'win32') {
+    seed = seed.replace(/\//g, '\\').replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
+  }
+
+  return digestToExtensionId(createHash('sha256').update(seed, 'utf8').digest());
+};
+
+/**
+ * Resolves the ID Chrome is expected to assign to the unpacked extension at
+ * `extensionPath`. Prefers the manifest `key` (exact) and falls back to hashing
+ * the path (best-effort). Returns undefined if the manifest can't be read.
+ */
+export async function resolveExpectedExtensionId(
+  extensionPath: string,
+  fs: FsPromises,
+): Promise {
+  let key: string | undefined;
+  try {
+    const raw = await fs.readFile(path.join(extensionPath, 'manifest.json'), 'utf8');
+    key = JSON.parse(raw).key;
+  } catch {
+    return undefined; // no readable manifest; caller falls back to a loose match
+  }
+
+  return typeof key === 'string' && key
+    ? extensionIdFromManifestKey(key)
+    : extensionIdFromPath(extensionPath);
+}
+
+/**
+ * Name of the browser profile directory used when debugging the extension at
+ * `extensionPath`. Keyed by the extension ID plus a hash of the path, since
+ * unrelated extensions can declare the same manifest `key` (and thus the same
+ * ID). Deliberately short: the profile prefixes IndexedDB's LevelDB paths,
+ * which add ~106 characters for an extension origin — a profile base past
+ * ~135 characters exceeds MAX_PATH on Windows and breaks the extension's
+ * IndexedDB with "Internal error opening database".
+ */
+export const extensionProfileDirName = (extensionPath: string, extensionId?: string) => {
+  const id = extensionId ?? extensionIdFromPath(extensionPath);
+  const pathHash = createHash('sha256').update(extensionPath).digest('hex').slice(0, 8);
+  return `js-debug-ext-${id.slice(0, 8)}-${pathHash}`;
+};
+
+/** Target types an extension's own code can run in. */
+const extensionTargetTypes: ReadonlySet = new Set([
+  BrowserTargetType.ServiceWorker,
+  BrowserTargetType.Page,
+]);
+
+const extensionUrlId = (url: string) => url.match(/^chrome-extension:\/\/([a-p]{32})\//)?.[1];
+
+/**
+ * Builds a target filter that selects the background service worker or page of
+ * the extension at `extensionPath`.
+ *
+ * Browsers expose service workers for their own component extensions too, and
+ * those can be reported before the extension under debug, so matching any
+ * `chrome-extension://` target would attach to the wrong one. When the expected
+ * ID is known we require an exact match; otherwise we warn, because the first
+ * match may not be the intended extension.
+ */
+export const createExtensionTargetFilter = (
+  extensionPath: string,
+  expectedId: string | undefined,
+  logger: ILogger,
+): (t: { url: string; type: string }) => boolean => {
+  if (!expectedId) {
+    logger.warn(
+      LogTag.RuntimeTarget,
+      'Could not determine the extension ID; will attach to the first extension '
+        + 'target seen, which may not be the extension under debug',
+      { extensionPath },
+    );
+  }
+
+  return t => {
+    if (!extensionTargetTypes.has(t.type)) {
+      return false;
+    }
+
+    const id = extensionUrlId(t.url);
+    if (!id) {
+      return false;
+    }
+
+    return expectedId === undefined || id === expectedId;
+  };
+};
diff --git a/src/targets/browser/remoteBrowserAttacher.ts b/src/targets/browser/remoteBrowserAttacher.ts
index 2258e2673..f16c925e2 100644
--- a/src/targets/browser/remoteBrowserAttacher.ts
+++ b/src/targets/browser/remoteBrowserAttacher.ts
@@ -9,7 +9,7 @@ import { DebugType } from '../../common/contributionUtils';
 import { ILogger } from '../../common/logging';
 import { ISourcePathResolver } from '../../common/sourcePathResolver';
 import { AnyChromiumAttachConfiguration, AnyLaunchConfiguration } from '../../configuration';
-import { VSCodeApi } from '../../ioc-extras';
+import { FS, FsPromises, VSCodeApi } from '../../ioc-extras';
 import { ILaunchContext } from '../targets';
 import { BrowserAttacher } from './browserAttacher';
 import { RemoteBrowserHelper } from './remoteBrowserHelper';
@@ -20,9 +20,10 @@ export class RemoteBrowserAttacher extends BrowserAttacher();
 
-  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;
diff --git a/src/targets/browser/uwpWebviewBrowserAttacher.ts b/src/targets/browser/uwpWebviewBrowserAttacher.ts
index 2673acee0..544b2d485 100644
--- a/src/targets/browser/uwpWebviewBrowserAttacher.ts
+++ b/src/targets/browser/uwpWebviewBrowserAttacher.ts
@@ -17,7 +17,7 @@ import { getWinUtils } from '../../common/win32Utils';
 import { AnyLaunchConfiguration, IEdgeAttachConfiguration } from '../../configuration';
 import { noUwpPipeFound, uwpPipeNotAvailable } from '../../dap/errors';
 import { ProtocolError } from '../../dap/protocolError';
-import { VSCodeApi } from '../../ioc-extras';
+import { FS, FsPromises, VSCodeApi } from '../../ioc-extras';
 import { ILaunchContext } from '../targets';
 import { BrowserAttacher } from './browserAttacher';
 
@@ -30,9 +30,10 @@ export class UWPWebviewBrowserAttacher extends BrowserAttacher