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