Skip to content

Commit 0d07209

Browse files
Noorez Kassamclaude
authored andcommitted
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://<id>/path resolves to extensionPath/path using a UUID-ignoring regex (same pattern as vscode-firefox-debug) After the main target is attached, the resolved extension ID is pinned in the path resolver so subsequent source URLs are matched only against that exact extension origin, preventing any other loaded extensions from being incorrectly resolved or attached to. Also fixes a race condition where Inspector.workerScriptLoaded arrives on the service worker's CDP session before createSession() is called, which previously threw an unhandled error. Adds a "Chrome: Launch Extension" configuration snippet for discoverability in the Add Configuration menu. Closes #945 (WebExtension debugging support) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4ebdfc commit 0d07209

9 files changed

Lines changed: 104 additions & 9 deletions

OPTIONS.md

Lines changed: 12 additions & 6 deletions
Large diffs are not rendered by default.

package.nls.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"browser.profileStartup.description": "If true, will start profiling soon as the process launches",
2727
"browser.restart": "Whether to reconnect if the browser connection is closed",
2828
"browser.revealPage": "Focus Tab",
29+
"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.",
2930
"browser.runtimeArgs.description": "Optional arguments passed to the runtime executable.",
3031
"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.",
3132
"browser.runtimeExecutable.edge.description": "Either 'canary', 'stable', 'dev', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or EDGE_PATH environment variable.",
@@ -47,6 +48,8 @@
4748
"chrome.label": "Web App (Chrome)",
4849
"chrome.launch.description": "Launch Chrome to debug a URL",
4950
"chrome.launch.label": "Chrome: Launch",
51+
"chrome.launch.extension.description": "Launch Chrome to debug an unpacked browser extension",
52+
"chrome.launch.extension.label": "Chrome: Launch Extension",
5053
"editorBrowser.attach.description": "Attach to an open VS Code integrated browser",
5154
"editorBrowser.attach.label": "Integrated Browser: Attach",
5255
"editorBrowser.label": "Web App (Integrated Browser)",

src/build/generate-contributions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,11 @@ const chromiumBaseConfigurationAttributes: ConfigurationAttributes<IChromiumBase
793793
enum: ['yes', 'no', 'auto'],
794794
description: refString('browser.perScriptSourcemaps.description'),
795795
},
796+
extensionPath: {
797+
type: ['string', 'null'],
798+
description: refString('browser.extensionPath.description'),
799+
default: null,
800+
},
796801
};
797802

798803
/**
@@ -854,6 +859,17 @@ const chromeLaunchConfig: IDebugger<IChromeLaunchConfiguration> = {
854859
webRoot: '^"${2:\\${workspaceFolder\\}}"',
855860
},
856861
},
862+
{
863+
label: refString('chrome.launch.extension.label'),
864+
description: refString('chrome.launch.extension.description'),
865+
body: {
866+
type: DebugType.Chrome,
867+
request: 'launch',
868+
name: 'Launch Chrome Extension',
869+
extensionPath: '^"${1:\\${workspaceFolder\\}}"',
870+
webRoot: '^"${2:\\${workspaceFolder\\}}"',
871+
},
872+
},
857873
],
858874
configurationAttributes: {
859875
...chromiumBaseConfigurationAttributes,

src/cdp/connection.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,15 @@ export default class Connection {
105105
if (!session) {
106106
const disposedDate = this._disposedSessions.get(object.sessionId);
107107
if (!disposedDate) {
108-
throw new Error(
109-
`Unknown session id: ${object.sessionId} while processing: ${object.method}`,
108+
// This can happen when Chrome sends events on a new session (e.g. an
109+
// extension service worker) before our createSession() call has run —
110+
// a race between Target.attachToTarget's response and early CDP events.
111+
this.logger.warn(
112+
LogTag.Internal,
113+
`Got message for unknown session, ignoring`,
114+
{ sessionId: object.sessionId, method: object.method },
110115
);
116+
return;
111117
} else {
112118
const secondsAgo = (Date.now() - disposedDate.getTime()) / 1000.0;
113119
this.logger.warn(

src/configuration.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,14 @@ export interface INodeLaunchConfiguration extends INodeBaseConfiguration, IConfi
454454
export type PathMapping = Readonly<{ [key: string]: string }>;
455455

456456
export interface IChromiumBaseConfiguration extends IBaseConfiguration {
457+
/**
458+
* Absolute path to the root directory of an unpacked browser extension to
459+
* load and debug. When set, the debugger automatically passes
460+
* --load-extension to Chrome/Edge (launch only) and attaches to the
461+
* extension's background script or service worker rather than a web page.
462+
*/
463+
extensionPath: string | null;
464+
457465
/**
458466
* Controls whether to skip the network cache for each request.
459467
*/
@@ -959,6 +967,7 @@ export const chromeAttachConfigDefaults: IChromeAttachConfiguration = {
959967
request: 'attach',
960968
address: 'localhost',
961969
port: 0,
970+
extensionPath: null,
962971
disableNetworkCache: true,
963972
pathMapping: {},
964973
url: null,
@@ -996,6 +1005,7 @@ export const chromeLaunchConfigDefaults: IChromeLaunchConfiguration = {
9961005
profileStartup: false,
9971006
cleanUp: 'wholeBrowser',
9981007
killBehavior: KillBehavior.Forceful,
1008+
extensionPath: null,
9991009
};
10001010

10011011
export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
@@ -1006,6 +1016,7 @@ export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
10061016

10071017
const editorBrowserBaseDefaults: IChromiumBaseConfiguration = {
10081018
...baseDefaults,
1019+
extensionPath: null,
10091020
disableNetworkCache: true,
10101021
pathMapping: {},
10111022
url: null,

src/targets/browser/browserAttacher.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ export class BrowserAttacher<
182182
manager: BrowserTargetManager,
183183
params: AnyChromiumAttachConfiguration,
184184
): Promise<TargetFilter> {
185+
if (params.extensionPath) {
186+
return (t: { url: string; type: string }) =>
187+
t.url.startsWith('chrome-extension://')
188+
&& (t.type === BrowserTargetType.ServiceWorker || t.type === BrowserTargetType.Page);
189+
}
190+
185191
const rawFilter = createTargetFilterForConfig(params);
186192
const baseFilter = requirePageTarget(rawFilter);
187193
if (params.targetSelection !== 'pick') {

src/targets/browser/browserLauncher.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ProtocolError } from '../../dap/protocolError';
2929
import { FS, FsPromises, IInitializeParams, StoragePath } from '../../ioc-extras';
3030
import { ITelemetryReporter } from '../../telemetry/telemetryReporter';
3131
import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets';
32+
import { BrowserSourcePathResolver } from './browserPathResolver';
3233
import { BrowserTargetManager } from './browserTargetManager';
3334
import { BrowserTarget, BrowserTargetType } from './browserTargets';
3435
import * as launcher from './launcher';
@@ -88,6 +89,7 @@ export abstract class BrowserLauncher<T extends AnyChromiumLaunchConfiguration>
8889
cleanUp,
8990
launchUnelevated: launchUnelevated,
9091
killBehavior,
92+
extensionPath,
9193
}: T,
9294
dap: Dap.Api,
9395
cancellationToken: CancellationToken,
@@ -117,6 +119,10 @@ export abstract class BrowserLauncher<T extends AnyChromiumLaunchConfiguration>
117119
resolvedDataDir = fs.realpathSync(resolvedDataDir);
118120
}
119121

122+
const effectiveRuntimeArgs = extensionPath
123+
? [...(runtimeArgs || []), `--load-extension=${extensionPath}`]
124+
: runtimeArgs || [];
125+
120126
return await launcher.launch(
121127
dap,
122128
executablePath,
@@ -132,7 +138,7 @@ export abstract class BrowserLauncher<T extends AnyChromiumLaunchConfiguration>
132138
hasUserNavigation: !!(url || file),
133139
cwd: cwd || webRoot || undefined,
134140
env: EnvironmentVars.merge(EnvironmentVars.processEnv(), env),
135-
args: runtimeArgs || [],
141+
args: effectiveRuntimeArgs,
136142
userDataDir: resolvedDataDir,
137143
connection: port || (inspectUri ? 0 : 'pipe'), // We don't default to pipe if we are using an inspectUri
138144
launchUnelevated: launchUnelevated,
@@ -147,6 +153,11 @@ export abstract class BrowserLauncher<T extends AnyChromiumLaunchConfiguration>
147153
}
148154

149155
protected getFilterForTarget(params: T) {
156+
if (params.extensionPath) {
157+
return (t: { url: string; type: string }) =>
158+
t.url.startsWith('chrome-extension://')
159+
&& (t.type === BrowserTargetType.ServiceWorker || t.type === BrowserTargetType.Page);
160+
}
150161
return requirePageTarget(createTargetFilterForConfig(params, ['about:blank']));
151162
}
152163

@@ -294,6 +305,16 @@ export abstract class BrowserLauncher<T extends AnyChromiumLaunchConfiguration>
294305
throw new ProtocolError(targetPageNotFound());
295306
}
296307

308+
// Once we know which target we attached to, pin the extension ID in the
309+
// path resolver so subsequent source URL resolutions only accept that exact
310+
// extension and not any other chrome-extension:// origin.
311+
if (params.extensionPath) {
312+
const idMatch = mainTarget.fileName()?.match(/^chrome-extension:\/\/([a-z0-9]{32})\//);
313+
if (idMatch) {
314+
(this.pathResolver as BrowserSourcePathResolver).pinExtensionId(idMatch[1]);
315+
}
316+
}
317+
297318
return mainTarget;
298319
}
299320

src/targets/browser/browserPathResolver.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export interface IOptions extends ISourcePathResolverOptions {
3131
pathMapping: PathMapping;
3232
clientID: string | undefined;
3333
remoteFilePrefix: string | undefined;
34+
extensionPath?: string;
35+
/** Pinned extension ID once discovered from the attached target's URL. */
36+
extensionId?: string;
3437
}
3538

3639
const enum Suffix {
@@ -51,6 +54,11 @@ export class BrowserSourcePathResolver extends SourcePathResolverBase<IOptions>
5154
super(options, logger);
5255
}
5356

57+
/** Pins the resolved extension ID so path resolution only accepts that exact origin. */
58+
public pinExtensionId(id: string) {
59+
(this.options as IOptions).extensionId = id;
60+
}
61+
5462
/** @override */
5563
private absolutePathToUrlPath(absolutePath: string): { url: string; needsWildcard: boolean } {
5664
absolutePath = path.normalize(absolutePath);
@@ -108,6 +116,21 @@ export class BrowserSourcePathResolver extends SourcePathResolverBase<IOptions>
108116
// URIs (vscode-dwarf-debugging-ext#7)
109117
url = this.sourceMapOverrides.apply(url);
110118

119+
// Map chrome-extension://<id>/path → extensionPath/path.
120+
// If extensionId is known (pinned after attaching to the target) we match
121+
// only that exact ID. Otherwise we accept any 32-char ID so the very first
122+
// resolution works before we know the ID.
123+
if (this.options.extensionPath && url.startsWith('chrome-extension://')) {
124+
const idPattern = this.options.extensionId
125+
? this.options.extensionId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
126+
: '[a-z0-9]{32}';
127+
const match = url.match(new RegExp(`^chrome-extension://${idPattern}(/.*)?\$`));
128+
if (match) {
129+
const relPath = (match[1] ?? '/').replace(/^\//, '');
130+
return path.join(this.options.extensionPath, relPath || 'index.html');
131+
}
132+
}
133+
111134
// If we have a file URL, we know it's absolute already and points
112135
// to a location on disk.
113136
if (utils.isFileUrl(url)) {

src/targets/sourcePathResolverFactory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ export class SourcePathResolverFactory implements ISourcePathResolverFactory {
9292
sourceMapOverrides: c.sourceMapPathOverrides,
9393
clientID: this.initializeParams.clientID,
9494
remoteFilePrefix: c.__remoteFilePrefix,
95+
extensionPath: 'extensionPath' in c && typeof c.extensionPath === 'string'
96+
? c.extensionPath
97+
: undefined,
9598
},
9699
logger,
97100
);

0 commit comments

Comments
 (0)