mcp-accessibility-scanner 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +232 -7
  2. package/lib/browserContextFactory.js +645 -32
  3. package/lib/browserContextFactory.js.map +1 -1
  4. package/lib/browserServerBackend.js +8 -2
  5. package/lib/browserServerBackend.js.map +1 -1
  6. package/lib/context.js +179 -62
  7. package/lib/context.js.map +1 -1
  8. package/lib/extension/extensionContextFactory.js +3 -0
  9. package/lib/extension/extensionContextFactory.js.map +1 -1
  10. package/lib/mcp/proxyBackend.js +1 -0
  11. package/lib/mcp/proxyBackend.js.map +1 -1
  12. package/lib/networkPolicy.js +73 -0
  13. package/lib/networkPolicy.js.map +1 -0
  14. package/lib/program.js +21 -3
  15. package/lib/program.js.map +1 -1
  16. package/lib/response.js +8 -3
  17. package/lib/response.js.map +1 -1
  18. package/lib/tab.js +117 -16
  19. package/lib/tab.js.map +1 -1
  20. package/lib/tools/auditKeyboard.js +203 -0
  21. package/lib/tools/auditKeyboard.js.map +1 -1
  22. package/lib/tools/auditScreenReader.js +826 -0
  23. package/lib/tools/auditScreenReader.js.map +1 -0
  24. package/lib/tools/auditSite.js +267 -86
  25. package/lib/tools/auditSite.js.map +1 -1
  26. package/lib/tools/axe.js +479 -21
  27. package/lib/tools/axe.js.map +1 -1
  28. package/lib/tools/dialogs.js +18 -4
  29. package/lib/tools/dialogs.js.map +1 -1
  30. package/lib/tools/evaluate.js +34 -5
  31. package/lib/tools/evaluate.js.map +1 -1
  32. package/lib/tools/keyboard.js +1 -2
  33. package/lib/tools/keyboard.js.map +1 -1
  34. package/lib/tools/navigate.js +2 -1
  35. package/lib/tools/navigate.js.map +1 -1
  36. package/lib/tools/network.js +225 -8
  37. package/lib/tools/network.js.map +1 -1
  38. package/lib/tools/scanPageMatrix.js +134 -43
  39. package/lib/tools/scanPageMatrix.js.map +1 -1
  40. package/lib/tools/screenshot.js +3 -3
  41. package/lib/tools/screenshot.js.map +1 -1
  42. package/lib/tools/snapshot.js +269 -8
  43. package/lib/tools/snapshot.js.map +1 -1
  44. package/lib/tools/tool.js.map +1 -1
  45. package/lib/tools/utils.js +62 -6
  46. package/lib/tools/utils.js.map +1 -1
  47. package/lib/tools.js +5 -2
  48. package/lib/tools.js.map +1 -1
  49. package/lib/utils/dataUrl.js +58 -35
  50. package/lib/utils/dataUrl.js.map +1 -1
  51. package/lib/utils/jsSource.js +187 -0
  52. package/lib/utils/jsSource.js.map +1 -0
  53. package/lib/vscode/browserContextFactory.js +83 -0
  54. package/lib/vscode/browserContextFactory.js.map +1 -0
  55. package/lib/vscode/host.js +16 -1
  56. package/lib/vscode/host.js.map +1 -1
  57. package/lib/vscode/main.js +1 -33
  58. package/lib/vscode/main.js.map +1 -1
  59. package/package.json +6 -4
@@ -22,9 +22,413 @@ import coreBundle from 'playwright-core/lib/coreBundle';
22
22
  const { registryDirectory } = coreBundle.registry;
23
23
  const { startTraceViewerServer } = coreBundle.server;
24
24
  import { logUnhandledError, testDebug } from './utils/log.js';
25
- import { createHash } from './utils/guid.js';
25
+ import { createGuid, createHash } from './utils/guid.js';
26
26
  import { outputFile } from './config.js';
27
+ import { ensureNetworkPolicyRoutes } from './networkPolicy.js';
28
+ /**
29
+ * Throws when a storage state is configured but `factory` will not apply it. A
30
+ * factory that neither creates a fresh context with the state nor applies it to
31
+ * the context it reuses would drop it without a word and audit the site as an
32
+ * anonymous user, which looks exactly like a successful run. Every factory in
33
+ * this file applies it one way or the other; the extension factory cannot — it
34
+ * works through the user's own running browser, where clearing every origin's
35
+ * cookies to install the recorded state is not an acceptable side effect.
36
+ * Callers pass the remedy that fits the mode they selected — the factory that
37
+ * creates the context is not always the one `contextFactory()` built.
38
+ */
39
+ export function assertStorageStateSupported(config, factory, remedy) {
40
+ if (config.browser.contextOptions?.storageState && !factory.appliesStorageState)
41
+ throw new Error(`Storage state cannot be applied in this mode. ${remedy}`);
42
+ }
43
+ /**
44
+ * Throws when the configured storage state would be installed inside a profile
45
+ * directory the user supplied. The profile carries its own session data — it is
46
+ * data this server does not own — and resetting it to the recorded state would
47
+ * destroy it. Every factory that lands the state in a context backed by
48
+ * `--user-data-dir` calls this before touching the browser; callers pass the
49
+ * remedy that fits their mode.
50
+ */
51
+ export function assertStorageStateDoesNotResetUserProfile(config, remedy) {
52
+ if (config.browser.contextOptions?.storageState && config.browser.userDataDir)
53
+ throw new Error(`--storage-state and --user-data-dir contradict each other: the profile carries its own session data, and resetting a user-supplied profile to match the recorded state would destroy it. ${remedy}`);
54
+ }
27
55
  export function contextFactory(config) {
56
+ const factory = createContextFactory(config);
57
+ // Every built-in factory now applies a storage state; the guard stays so a
58
+ // future factory that forgets to declare support rejects the option instead
59
+ // of silently dropping it.
60
+ assertStorageStateSupported(config, factory, 'Drop the storage state and sign in interactively before auditing.');
61
+ return factory;
62
+ }
63
+ // The rules addCookies enforces client-side (verified against Playwright
64
+ // 1.61.1: empty or missing domain/path without a url, a url combined with a
65
+ // domain or a path, about:blank/data:/unparseable urls, an expires other
66
+ // than -1 or a positive number up to Playwright's ceiling, and sameSite
67
+ // outside Strict/Lax/None are all rejected there — non-http(s) url schemes
68
+ // and malformed origin strings fail browser-side during the apply). Failing
69
+ // them here keeps the failure ahead of the cache clear; anything these
70
+ // checks miss still fails inside setStorageState.
71
+ const cookieUrlProblem = (url) => {
72
+ if (typeof url !== 'string')
73
+ return 'is not a string';
74
+ if (url === 'about:blank')
75
+ return 'cannot be about:blank';
76
+ if (url.startsWith('data:'))
77
+ return 'cannot be a data: URL';
78
+ try {
79
+ const parsed = new URL(url);
80
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
81
+ return `must be an http(s) URL, not ${parsed.protocol}`;
82
+ }
83
+ catch {
84
+ return 'is not a valid absolute URL';
85
+ }
86
+ return null;
87
+ };
88
+ const isValidIndexedDBKey = (value) => typeof value === 'string'
89
+ || (typeof value === 'number' && Number.isFinite(value))
90
+ || (value instanceof Date && Number.isFinite(value.getTime()))
91
+ || value instanceof ArrayBuffer
92
+ || ArrayBuffer.isView(value)
93
+ || (Array.isArray(value) && value.every(isValidIndexedDBKey));
94
+ const indexedDBIdentifier = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u;
95
+ const isValidIndexedDBKeyPath = (value) => typeof value === 'string' && (value === '' || value.split('.').every(part => indexedDBIdentifier.test(part)));
96
+ const isValidIndexedDBKeyPathArray = (value) => Array.isArray(value) && !!value.length && value.every(isValidIndexedDBKeyPath);
97
+ function assertValidStorageState(state) {
98
+ for (const value of state.cookies ?? []) {
99
+ const cookie = value;
100
+ const problem = !cookie || typeof cookie !== 'object'
101
+ ? 'a cookie entry is not an object'
102
+ : !cookie.url && (!cookie.domain || !cookie.path)
103
+ ? `cookie "${String(cookie.name ?? '')}" should have a url or a domain/path pair`
104
+ : cookie.url && cookie.domain
105
+ ? `cookie "${String(cookie.name ?? '')}" should have either a url or a domain, not both`
106
+ : cookie.url && cookie.path
107
+ ? `cookie "${String(cookie.name ?? '')}" should have either a url or a path, not both`
108
+ : cookie.url !== undefined && cookieUrlProblem(cookie.url)
109
+ ? `cookie "${String(cookie.name ?? '')}" has a url that ${cookieUrlProblem(cookie.url)}`
110
+ : cookie.expires !== undefined && (typeof cookie.expires !== 'number' || Number.isNaN(cookie.expires) || (cookie.expires !== -1 && (cookie.expires <= 0 || cookie.expires > 253402300799)))
111
+ ? `cookie "${String(cookie.name ?? '')}" should have a valid expires — only -1 or a positive unix timestamp in seconds up to 253402300799 (9999-12-31T23:59:59Z, Playwright's own ceiling) is allowed`
112
+ : cookie.sameSite !== undefined && !['Strict', 'Lax', 'None'].includes(cookie.sameSite)
113
+ ? `cookie "${String(cookie.name ?? '')}" has sameSite "${String(cookie.sameSite)}", expected one of Strict|Lax|None`
114
+ : null;
115
+ if (problem)
116
+ throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
117
+ }
118
+ for (const value of state.origins ?? []) {
119
+ const entry = value;
120
+ // Restoring an origin's storage navigates Playwright's temporary page to
121
+ // it — a malformed or non-http(s) origin fails that navigation after the
122
+ // clear.
123
+ const problem = !entry || typeof entry !== 'object'
124
+ ? 'an origins entry is not an object'
125
+ : typeof entry.origin !== 'string' || cookieUrlProblem(entry.origin)
126
+ ? `origins entry "${String(entry?.origin ?? '')}" is not an absolute http(s) URL`
127
+ : null;
128
+ if (problem)
129
+ throw new Error(`Invalid storage state: ${problem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
130
+ const databaseNames = new Set();
131
+ for (const value of Array.isArray(entry?.indexedDB) ? entry.indexedDB : []) {
132
+ const database = value;
133
+ const stores = Array.isArray(database.stores) ? database.stores : [];
134
+ let indexedDBProblem = !Number.isSafeInteger(database.version) || database.version <= 0
135
+ ? `IndexedDB database "${String(database.name ?? '')}" should have a positive integer version`
136
+ : databaseNames.has(String(database.name))
137
+ ? `IndexedDB database name "${String(database.name)}" is duplicated`
138
+ : null;
139
+ databaseNames.add(String(database.name));
140
+ const storeNames = new Set();
141
+ for (const store of stores) {
142
+ const storeName = String(store.name);
143
+ indexedDBProblem ??= storeNames.has(storeName)
144
+ ? `IndexedDB object store name "${storeName}" is duplicated in database "${String(database.name)}"`
145
+ : store.keyPath !== undefined && !isValidIndexedDBKeyPath(store.keyPath)
146
+ ? `IndexedDB object store "${storeName}" has an invalid key path`
147
+ : store.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(store.keyPathArray)
148
+ ? `IndexedDB object store "${storeName}" has an invalid array key path`
149
+ : store.autoIncrement && (store.keyPath === '' || Array.isArray(store.keyPathArray))
150
+ ? `IndexedDB object store "${storeName}" cannot combine autoIncrement with an empty or array key path`
151
+ : null;
152
+ storeNames.add(storeName);
153
+ const recordKeys = new Set();
154
+ const hasInlineKey = store.keyPath !== undefined || store.keyPathArray !== undefined;
155
+ for (const value of Array.isArray(store.records) ? store.records : []) {
156
+ const record = value;
157
+ const hasExternalKey = (record.key !== undefined && record.key !== null)
158
+ || (record.keyEncoded !== undefined && record.keyEncoded !== null);
159
+ const key = record.key ?? record.keyEncoded;
160
+ const serializedKey = hasExternalKey ? JSON.stringify(key) ?? String(key) : '';
161
+ indexedDBProblem ??= record.key !== undefined && record.key !== null && !isValidIndexedDBKey(record.key)
162
+ ? `IndexedDB object store "${storeName}" has an invalid external record key`
163
+ : hasInlineKey && hasExternalKey
164
+ ? `IndexedDB object store "${storeName}" has an inline key path but record also supplies an external key`
165
+ : !hasInlineKey && !store.autoIncrement && !hasExternalKey
166
+ ? `IndexedDB object store "${storeName}" requires an external key for every record`
167
+ : hasExternalKey && recordKeys.has(serializedKey)
168
+ ? `IndexedDB object store "${storeName}" has duplicate record key ${serializedKey}`
169
+ : null;
170
+ if (hasExternalKey)
171
+ recordKeys.add(serializedKey);
172
+ }
173
+ const indexNames = new Set();
174
+ for (const index of Array.isArray(store.indexes) ? store.indexes : []) {
175
+ const indexName = String(index.name);
176
+ indexedDBProblem ??= indexNames.has(indexName)
177
+ ? `IndexedDB index name "${indexName}" is duplicated in object store "${storeName}"`
178
+ : index.keyPath !== undefined && !isValidIndexedDBKeyPath(index.keyPath)
179
+ ? `IndexedDB index "${indexName}" has an invalid key path`
180
+ : index.keyPathArray !== undefined && !isValidIndexedDBKeyPathArray(index.keyPathArray)
181
+ ? `IndexedDB index "${indexName}" has an invalid array key path`
182
+ : index.multiEntry && Array.isArray(index.keyPathArray)
183
+ ? `IndexedDB index "${indexName}" cannot combine multiEntry with an array key path`
184
+ : null;
185
+ indexNames.add(indexName);
186
+ }
187
+ }
188
+ if (indexedDBProblem)
189
+ throw new Error(`Invalid storage state: ${indexedDBProblem}. Nothing was changed — the state is validated before the apply, because setStorageState() clears the attached context's HTTP cache and cookie jar before it validates, and the cache cannot be restored.`);
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Replaces every open page of `browserContext` with a blank fresh tab and
195
+ * returns the fresh tabs paired with the URL each replaced page showed.
196
+ * Replacement, not an in-page sessionStorage clear plus reload: the old
197
+ * document's scripts keep running between an evaluated clear and the
198
+ * navigation that would follow it, and a timer persisting the previous
199
+ * identity can write it back into that window — sessionStorage survives the
200
+ * reload, and the audit reads the old user again. A fresh tab starts with
201
+ * empty sessionStorage for every origin (only window.open clones the
202
+ * opener's copy, verified against Playwright 1.61.1 Chromium). The
203
+ * replacement tab is created before the old page closes — closing a
204
+ * browser's last tab can take the whole (attached, user-owned) browser down
205
+ * with it.
206
+ */
207
+ async function replaceOpenPagesWithBlankTabs(browserContext) {
208
+ const seen = new Set();
209
+ const arrivals = [];
210
+ const onPage = (page) => arrivals.push(page);
211
+ const replaced = [];
212
+ const replacePage = async (page) => {
213
+ const url = page.url();
214
+ let fresh;
215
+ try {
216
+ fresh = await browserContext.newPage();
217
+ }
218
+ catch {
219
+ // Without a replacement tab (Electron targets cannot create pages),
220
+ // closing is the only way to keep the previous identity's DOM and
221
+ // sessionStorage out of the audit.
222
+ await page.close();
223
+ return;
224
+ }
225
+ // The replacement never needs replacing itself — it must not be swept up
226
+ // by the loop below when it surfaces through pages() or the listener.
227
+ seen.add(fresh);
228
+ try {
229
+ await page.close();
230
+ }
231
+ catch (error) {
232
+ await fresh.close().catch(() => { });
233
+ throw error;
234
+ }
235
+ replaced.push({ page: fresh, url });
236
+ };
237
+ // A still-old document can open a page while the ones above are being
238
+ // replaced — a popup from a timer, say — and a same-origin popup clones
239
+ // its opener's previous-identity sessionStorage at creation. A single
240
+ // pages() snapshot would hand such a page to Context unreset, so the sweep
241
+ // repeats until no unseen page remains; the temporary 'page' listener
242
+ // catches pages whose creation the next pages() call cannot see yet. The
243
+ // Set dedupes a page that arrives through both — two concurrent
244
+ // replacements of one page would race each other's close.
245
+ browserContext.on('page', onPage);
246
+ try {
247
+ while (true) {
248
+ const pending = [...new Set([...browserContext.pages(), ...arrivals.splice(0)])].filter(page => !seen.has(page));
249
+ if (!pending.length)
250
+ break;
251
+ for (const page of pending)
252
+ seen.add(page);
253
+ await Promise.all(pending.map(replacePage));
254
+ }
255
+ }
256
+ finally {
257
+ browserContext.off('page', onPage);
258
+ }
259
+ return replaced;
260
+ }
261
+ /**
262
+ * Navigates each replacement tab to the URL its replaced page showed, bringing
263
+ * what a scan sees onto the state the context now holds. A tab that cannot
264
+ * load its page — the origin may be blocked by the just-installed policy, or
265
+ * the load simply failed — is blanked instead (the fresh tab carries no old
266
+ * identity, so a blank one is safe to hand to Context), and closed only when
267
+ * even blanking fails.
268
+ */
269
+ async function navigateReplacementPages(replaced) {
270
+ await Promise.all(replaced.map(async ({ page, url }) => {
271
+ if (!url || url === 'about:blank')
272
+ return;
273
+ try {
274
+ await page.goto(url);
275
+ }
276
+ catch {
277
+ try {
278
+ await page.goto('about:blank');
279
+ }
280
+ catch {
281
+ await page.close().catch(() => { });
282
+ }
283
+ }
284
+ }));
285
+ }
286
+ /**
287
+ * Lands the configured storage state in a context the browser already had.
288
+ * `setStorageState()` clears the context's cookies, local storage and IndexedDB
289
+ * and installs the recorded state — the documented semantics of the option the
290
+ * caller asked for, applied to a context `newContext()` never sees: the CDP
291
+ * modes without --isolated reuse the browser's existing context, and
292
+ * launchPersistentContext() silently ignores a storageState option (verified
293
+ * against Playwright 1.61.1).
294
+ */
295
+ export async function applyStorageStateToReusedContext(config, browserContext) {
296
+ const storageState = config.browser.contextOptions?.storageState;
297
+ if (!storageState)
298
+ return;
299
+ const parsedState = await (async () => {
300
+ try {
301
+ return typeof storageState === 'string'
302
+ ? JSON.parse(await fs.promises.readFile(storageState, 'utf-8'))
303
+ : storageState;
304
+ }
305
+ catch (error) {
306
+ // Letting setStorageState() discover the bad file would fail inside
307
+ // the apply block, whose catch answers every failure with a rollback —
308
+ // and the rollback's own setStorageState() clears the attached
309
+ // context's HTTP cache. A config error that changed nothing must not
310
+ // cost the running application its cache.
311
+ throw new Error(`The storage state file could not be read or parsed: ${error instanceof Error ? error.message : String(error)}. Nothing was changed.`);
312
+ }
313
+ })();
314
+ // Playwright validates cookies only while installing them — after the
315
+ // attached context's HTTP cache and cookie jar are already cleared — so a
316
+ // semantically invalid cookie (bad expires, missing domain/path) would
317
+ // fail the apply with the cache unrestorably gone. Checked up front, with
318
+ // the same rules addCookies enforces (verified against 1.61.1).
319
+ assertValidStorageState(parsedState);
320
+ // setStorageState needs a temporary page whenever the state carries origins
321
+ // or the context has visited any — and by the time that page creation fails
322
+ // on a target without Target.createTarget, the HTTP cache is already cleared
323
+ // and cannot be put back. Probe the page creation first, so such targets are
324
+ // rejected before anything is mutated. When neither signal indicates a page
325
+ // will be needed (cookie-only state, no pages open), the probe is skipped so
326
+ // that case keeps working on those targets.
327
+ const stateHasOrigins = (parsedState.origins?.length ?? 0) > 0;
328
+ const hasLoadedPages = browserContext.pages().some(page => page.url() && page.url() !== 'about:blank');
329
+ if (stateHasOrigins || hasLoadedPages) {
330
+ try {
331
+ const probe = await browserContext.newPage();
332
+ await probe.close();
333
+ }
334
+ catch (error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ if (message.includes('Target.createTarget'))
337
+ throw new Error(`The attached browser cannot open the temporary page Playwright needs to apply the storage state's origin data (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
338
+ throw error;
339
+ }
340
+ }
341
+ // setStorageState replaces the cookie jar and then rewrites origin storage
342
+ // one origin at a time, so a failure partway would otherwise leave the
343
+ // attached browser holding a mixture of old and recorded state while the
344
+ // operation reports failure. Both layers are snapshotted first: the cookie
345
+ // jar through pure protocol calls that work everywhere (kept as the
346
+ // fallback for a restore whose own origin phase fails), and origin storage
347
+ // through storageState() below.
348
+ const originalCookies = await browserContext.cookies();
349
+ // The snapshot doubles as a probe for origins this connection has already
350
+ // visited while its pages have since closed or gone blank: for those,
351
+ // storageState() opens the same temporary page the forward apply will need
352
+ // — the newPage probe above cannot see them — but unlike setStorageState()
353
+ // it mutates nothing, so a target that cannot create pages (Electron) is
354
+ // rejected here with everything intact, not after the forward apply has
355
+ // cleared the HTTP cache. Any other snapshot failure also aborts: without
356
+ // the full snapshot, a partial forward apply could only be rolled back to
357
+ // cookies, leaving the attached browser's origin storage part old, part
358
+ // recorded.
359
+ const originalState = await browserContext.storageState({ indexedDB: true }).catch((error) => {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ if (message.includes('Target.createTarget'))
362
+ throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage for origins this connection has already visited (Electron targets do not support Target.createTarget). Nothing was changed. Drop the storage state and sign in inside the app instead. Original error: ${message}`);
363
+ throw new Error(`Snapshotting the context's current storage for rollback failed, so the storage state was not applied — a partial apply could not have been undone. Nothing was changed. Retry, or use --isolated for a fresh context. Original error: ${message}`);
364
+ });
365
+ // Pages that were already open still render the previous identity — and
366
+ // their scripts keep running: a page that periodically persists
367
+ // authentication into cookies or localStorage would overwrite the state
368
+ // being installed if it were still alive during setStorageState(), and
369
+ // replacing its tab afterwards cannot undo writes already made into
370
+ // context-wide storage. Every open page is therefore replaced with a blank
371
+ // fresh tab FIRST — the old document closes before the state lands, and
372
+ // only blank replacements (which run no scripts) survive the apply — and
373
+ // the replacements are navigated to the pages they replaced only once the
374
+ // recorded state is in place.
375
+ const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
376
+ const policyRequired = !!(config.network?.allowedOrigins?.length || config.network?.blockedOrigins?.length);
377
+ let replacementNavigationSafe = !policyRequired;
378
+ try {
379
+ // The state validated above is the state applied: handing the path back
380
+ // to Playwright would re-read the file here, and a file replaced since
381
+ // that read would skip the cookie/origin validation and the page-creation
382
+ // probe only to fail after the cache clear those exist to prevent.
383
+ await browserContext.setStorageState(parsedState);
384
+ // The replacement navigations run inside the factory, before Context
385
+ // ensures the configured origin allowlist/blocklist — and the recorded
386
+ // credentials are already in place by now. The policy is installed here
387
+ // (permanently — page scripts can queue requests that fire after the
388
+ // navigation settles, so removing the handlers before Context re-ensures
389
+ // the same policy would open a window to a blocked origin;
390
+ // ensureNetworkPolicyRoutes installs once per context, so Context's later
391
+ // call is a no-op). Installed after setStorageState() so an abort-all
392
+ // route cannot interfere with the temporary page Playwright drives to
393
+ // restore origin storage.
394
+ await ensureNetworkPolicyRoutes(config, browserContext);
395
+ replacementNavigationSafe = true;
396
+ await navigateReplacementPages(replaced);
397
+ }
398
+ catch (error) {
399
+ // Prefer the full-state rollback; fall back to cookies-only when its
400
+ // reapplication is itself impossible on this target.
401
+ const restoredFully = await browserContext.setStorageState(originalState).then(() => true, () => false);
402
+ const restoredCookies = restoredFully || await browserContext.clearCookies()
403
+ .then(() => originalCookies.length ? browserContext.addCookies(originalCookies) : undefined)
404
+ .then(() => true, () => false);
405
+ // The old pages were closed before the apply and cannot be handed back.
406
+ // Navigate their replacements only when no policy was required or its
407
+ // installation succeeded; otherwise restored credentials stay offline.
408
+ if (replacementNavigationSafe)
409
+ await navigateReplacementPages(replaced);
410
+ else
411
+ await Promise.all(replaced.map(({ page }) => page.close().catch(() => { })));
412
+ // Restoring origin storage (localStorage/IndexedDB) makes Playwright open a
413
+ // temporary page; a CDP target that cannot create one — Electron has no
414
+ // Target.createTarget — fails here. Cookie-only states need no page and
415
+ // still work on such targets, so name that remedy instead of surfacing the
416
+ // raw protocol error.
417
+ const message = error instanceof Error ? error.message : String(error);
418
+ if (message.includes('Target.createTarget')) {
419
+ const rollbackNote = restoredFully
420
+ ? 'The context\'s original storage state was restored.'
421
+ : restoredCookies
422
+ ? 'The context\'s original cookies were restored.'
423
+ : 'Restoring the context\'s original cookies also failed; its cookie jar may now hold the recorded state.';
424
+ throw new Error(`The attached browser cannot open the temporary page Playwright needs to reset origin storage (Electron targets do not support Target.createTarget). Drop the storage state and sign in inside the app instead — a cookies-only state helps only while the attached target has no pages open and the connection has visited no origin, because clearing storage for an already-visited origin needs the same temporary page. ${rollbackNote} Original error: ${message}`);
425
+ }
426
+ if (!restoredFully && restoredCookies)
427
+ throw new Error(`${message} The context's original cookies were restored, but origin storage may retain partially applied state.`);
428
+ throw error;
429
+ }
430
+ }
431
+ function createContextFactory(config) {
28
432
  if (config.browser.remoteEndpoint)
29
433
  return new RemoteContextFactory(config);
30
434
  if (config.browser.cdpLaunch)
@@ -75,6 +479,7 @@ class BaseContextFactory {
75
479
  }
76
480
  }
77
481
  class IsolatedContextFactory extends BaseContextFactory {
482
+ appliesStorageState = true;
78
483
  constructor(config) {
79
484
  super('isolated', config);
80
485
  }
@@ -88,7 +493,7 @@ class IsolatedContextFactory extends BaseContextFactory {
88
493
  handleSIGTERM: false,
89
494
  }).catch(error => {
90
495
  if (error.message.includes('Executable doesn\'t exist'))
91
- throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
496
+ throw browserNotInstalledError(error);
92
497
  throw error;
93
498
  });
94
499
  }
@@ -97,18 +502,75 @@ class IsolatedContextFactory extends BaseContextFactory {
97
502
  }
98
503
  }
99
504
  class CdpContextFactory extends BaseContextFactory {
505
+ // The isolated path creates a fresh context with the state; the attach path
506
+ // applies it to the browser's existing context via setStorageState().
507
+ appliesStorageState = true;
508
+ // One attached browser — and its default context — serves every session
509
+ // this factory creates. Re-running the global setStorageState() for a
510
+ // second session would wipe the first session's live cookies and origin
511
+ // storage mid-audit and reload its pages, so the state is applied once per
512
+ // context object: later sessions join the live shared state. Keyed weakly —
513
+ // a reconnect yields a fresh context object, so the slate resets with the
514
+ // connection — and a failed apply is forgotten so the next session retries.
515
+ _storageStateApplied = new WeakMap();
516
+ // Serializes the no-context fallback the same way: two sessions arriving at
517
+ // a contextless target must share one created context, not race two.
518
+ _fallbackContext = new WeakMap();
100
519
  constructor(config) {
101
520
  super('cdp', config);
102
521
  }
522
+ // The CDP connection (and with it every route and page proxy) is shared by
523
+ // all live sessions of this factory, so nothing may close it while a
524
+ // sibling session still audits through it — neither a session's own
525
+ // close() nor the cleanup after another session's failed setup. The
526
+ // handout count is kept per browser object, not per factory: an external
527
+ // disconnect makes _obtainBrowser hand out a fresh browser while stale
528
+ // sessions still hold references to the old one, and a shared counter
529
+ // would let a stale release keep the new connection open forever (its last
530
+ // real user would only ever bring the count down to the stale remainder).
531
+ // The reference is claimed before context creation, so a sibling still
532
+ // inside _doCreateContext() counts and a concurrent failure cannot close
533
+ // the connection out from under it.
534
+ _sessionCounts = new WeakMap();
535
+ _releaseBrowser(browser) {
536
+ const remaining = Math.max(0, (this._sessionCounts.get(browser) ?? 1) - 1);
537
+ this._sessionCounts.set(browser, remaining);
538
+ return remaining === 0;
539
+ }
103
540
  async createContext(clientInfo) {
104
541
  testDebug('create browser context (cdp)');
105
542
  const browser = await this._obtainBrowser(clientInfo);
106
- const browserContext = await this._doCreateContext(browser);
543
+ this._sessionCounts.set(browser, (this._sessionCounts.get(browser) ?? 0) + 1);
544
+ let browserContext;
545
+ try {
546
+ browserContext = await this._doCreateContext(browser);
547
+ }
548
+ catch (error) {
549
+ // Without this the CDP connection stays open after e.g. an unreadable
550
+ // storage-state file, even though no context was ever handed out — but
551
+ // only when no sibling session is still using the shared connection.
552
+ if (this._releaseBrowser(browser))
553
+ await browser.close().catch(logUnhandledError);
554
+ throw error;
555
+ }
556
+ let released = false;
107
557
  return {
108
558
  browserContext,
109
559
  close: async () => {
110
- testDebug('disconnect browser (cdp)');
111
- await browser.close().catch(logUnhandledError);
560
+ if (released)
561
+ return;
562
+ released = true;
563
+ // An isolated session's context belongs to it alone — close it now,
564
+ // or abandoned contexts (with their pages, routes and listeners)
565
+ // pile up on a long-lived shared connection until the last session
566
+ // exits. The non-isolated context is the browser's own and shared;
567
+ // it stays.
568
+ if (this.config.browser.isolated)
569
+ await browserContext.close().catch(logUnhandledError);
570
+ if (this._releaseBrowser(browser)) {
571
+ testDebug('disconnect browser (cdp)');
572
+ await browser.close().catch(logUnhandledError);
573
+ }
112
574
  }
113
575
  };
114
576
  }
@@ -120,10 +582,46 @@ class CdpContextFactory extends BaseContextFactory {
120
582
  });
121
583
  }
122
584
  async _doCreateContext(browser) {
123
- return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
585
+ if (this.config.browser.isolated)
586
+ return await browser.newContext(this.config.browser.contextOptions);
587
+ const existing = browser.contexts()[0];
588
+ // An attached browser can expose no context at all; a fresh one created
589
+ // with the configured options (storage state included) beats handing an
590
+ // undefined context to the caller. The created context immediately seeds
591
+ // the applied-state memo — a later session will find it as the browser's
592
+ // existing context, and must join it rather than reset it — and the
593
+ // creation itself is memoized so concurrent arrivals share one context.
594
+ if (!existing) {
595
+ let creating = this._fallbackContext.get(browser);
596
+ if (!creating) {
597
+ creating = browser.newContext(this.config.browser.contextOptions).then(created => {
598
+ this._storageStateApplied.set(created, Promise.resolve());
599
+ // Evict on close, or a context closed externally (while the
600
+ // connection lives on) would keep being handed out of this memo to
601
+ // every later session — contexts() no longer lists it, so only the
602
+ // memo would remember it, forever.
603
+ created.on('close', () => this._fallbackContext.delete(browser));
604
+ return created;
605
+ });
606
+ this._fallbackContext.set(browser, creating);
607
+ creating.catch(() => this._fallbackContext.delete(browser));
608
+ }
609
+ return await creating;
610
+ }
611
+ // The shared promise also serializes two sessions arriving at once: both
612
+ // await the same application instead of racing two global resets.
613
+ let applied = this._storageStateApplied.get(existing);
614
+ if (!applied) {
615
+ applied = applyStorageStateToReusedContext(this.config, existing);
616
+ this._storageStateApplied.set(existing, applied);
617
+ applied.catch(() => this._storageStateApplied.delete(existing));
618
+ }
619
+ await applied;
620
+ return existing;
124
621
  }
125
622
  }
126
623
  class RemoteContextFactory extends BaseContextFactory {
624
+ appliesStorageState = true;
127
625
  constructor(config) {
128
626
  super('remote', config);
129
627
  }
@@ -135,11 +633,13 @@ class RemoteContextFactory extends BaseContextFactory {
135
633
  return playwright[this.config.browser.browserName].connect(String(url));
136
634
  }
137
635
  async _doCreateContext(browser) {
138
- return browser.newContext();
636
+ return browser.newContext(this.config.browser.contextOptions);
139
637
  }
140
638
  }
141
639
  class CdpLaunchContextFactory {
142
640
  config;
641
+ // See CdpContextFactory: fresh context when isolated, setStorageState otherwise.
642
+ appliesStorageState = true;
143
643
  constructor(config) {
144
644
  this.config = config;
145
645
  }
@@ -160,7 +660,32 @@ class CdpLaunchContextFactory {
160
660
  testDebug(`cdp-launch stderr: ${String(data).trimEnd()}`);
161
661
  });
162
662
  const browser = await this._waitForBrowser(endpoint, clientInfo, childProcess, cdpLaunch.startupTimeoutMs ?? 30000);
163
- const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
663
+ let browserContext;
664
+ try {
665
+ if (this.config.browser.isolated) {
666
+ browserContext = await browser.newContext(this.config.browser.contextOptions);
667
+ }
668
+ else {
669
+ const existing = browser.contexts()[0];
670
+ if (existing) {
671
+ await applyStorageStateToReusedContext(this.config, existing);
672
+ browserContext = existing;
673
+ }
674
+ else {
675
+ // See CdpContextFactory: a launched app can expose no context yet;
676
+ // a fresh one with the configured options beats an undefined one.
677
+ browserContext = await browser.newContext(this.config.browser.contextOptions);
678
+ }
679
+ }
680
+ }
681
+ catch (error) {
682
+ // The desktop process is already running by now; failing to obtain a
683
+ // context (say, an unreadable storage-state file) must not leave it and
684
+ // the CDP connection behind with nobody holding a close() for them.
685
+ await browser.close().catch(logUnhandledError);
686
+ childProcess.kill('SIGTERM');
687
+ throw error;
688
+ }
164
689
  return {
165
690
  browserContext,
166
691
  close: async () => {
@@ -191,8 +716,18 @@ class CdpLaunchContextFactory {
191
716
  }
192
717
  }
193
718
  }
194
- class PersistentContextFactory {
719
+ // Distinguishes a storage-state failure from a launch failure, so the launch
720
+ // retry loop never retries on one (a bad state file fails the same way 5 times).
721
+ class StorageStateError extends Error {
722
+ }
723
+ // Shared with the --connect-tool startup validation in program.ts, so the
724
+ // lazy rejection here and the eager one there never drift apart.
725
+ export const persistentProfileConflictRemedy = 'Drop --user-data-dir (a managed, disposable profile is used for storage-state sessions), or drop the storage state and sign in in that profile instead.';
726
+ export class PersistentContextFactory {
195
727
  config;
728
+ // launchPersistentContext() silently ignores a storageState option, so the
729
+ // state is applied to the launched context with setStorageState() instead.
730
+ appliesStorageState = true;
196
731
  name = 'persistent';
197
732
  description = 'Create a new persistent browser context';
198
733
  _userDataDirs = new Set();
@@ -202,49 +737,114 @@ class PersistentContextFactory {
202
737
  async createContext(clientInfo) {
203
738
  await injectCdpPort(this.config.browser);
204
739
  testDebug('create browser context (persistent)');
205
- const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
740
+ // launchPersistentContext() accepts a storageState option without applying
741
+ // it (verified against 1.61.1) — the profile is normally the state — so it
742
+ // is stripped here and applied explicitly after launch.
743
+ const { storageState, ...contextOptions } = this.config.browser.contextOptions ?? {};
744
+ // setStorageState() resets cookies globally but origin storage only for
745
+ // origins in the state or known to the fresh context object, so stale
746
+ // localStorage/IndexedDB in a previously used profile would survive and
747
+ // could sign the audit in as the wrong identity. A storage-state session
748
+ // therefore runs in its own fresh disposable profile — unique per context,
749
+ // because one server can hold several live sessions and a shared
750
+ // deterministic directory would let one session's setup destroy another's
751
+ // running profile — removed again when the context closes. A user-supplied
752
+ // profile cannot be treated this way — it is data we do not own — and
753
+ // keeping it contradicts "start from the recorded state", so that
754
+ // combination errors.
755
+ assertStorageStateDoesNotResetUserProfile(this.config, persistentProfileConflictRemedy);
756
+ // Trace setup runs before the disposable profile exists: it can fail (an
757
+ // unwritable output directory), and nothing after the directory is created
758
+ // may throw outside the cleanup scope below, or failed starts would leave
759
+ // stray profiles behind.
206
760
  const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
761
+ const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath, storageState ? `-storage-state-${createGuid()}` : '');
207
762
  this._userDataDirs.add(userDataDir);
208
763
  testDebug('lock user data dir', userDataDir);
209
764
  const browserType = playwright[this.config.browser.browserName];
210
- for (let i = 0; i < 5; i++) {
765
+ try {
766
+ for (let i = 0; i < 5; i++) {
767
+ try {
768
+ const browserContext = await browserType.launchPersistentContext(userDataDir, {
769
+ tracesDir,
770
+ ...this.config.browser.launchOptions,
771
+ ...contextOptions,
772
+ handleSIGINT: false,
773
+ handleSIGTERM: false,
774
+ });
775
+ return await this._applyStorageState(browserContext, storageState, userDataDir);
776
+ }
777
+ catch (error) {
778
+ if (error instanceof StorageStateError)
779
+ throw error;
780
+ if (error.message.includes('Executable doesn\'t exist'))
781
+ throw browserNotInstalledError(error);
782
+ if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
783
+ // User data directory is already in use, try again.
784
+ await new Promise(resolve => setTimeout(resolve, 1000));
785
+ continue;
786
+ }
787
+ throw error;
788
+ }
789
+ }
790
+ throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
791
+ }
792
+ catch (error) {
793
+ // The disposable profile belongs to this context alone, so a launch that
794
+ // never produced a context must not leave it behind — repeated failed
795
+ // starts would otherwise pile one stray directory into the registry each.
796
+ // (Already removed on the StorageStateError path; rm is idempotent.)
797
+ if (storageState) {
798
+ await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
799
+ this._userDataDirs.delete(userDataDir);
800
+ }
801
+ throw error;
802
+ }
803
+ }
804
+ // Separate from the launch retry loop: its `catch` retries on messages a
805
+ // malformed storage-state file could coincidentally match (`Invalid URL`).
806
+ async _applyStorageState(browserContext, storageState, userDataDir) {
807
+ if (storageState) {
211
808
  try {
212
- const browserContext = await browserType.launchPersistentContext(userDataDir, {
213
- tracesDir,
214
- ...this.config.browser.launchOptions,
215
- ...this.config.browser.contextOptions,
216
- handleSIGINT: false,
217
- handleSIGTERM: false,
218
- });
219
- const close = () => this._closeBrowserContext(browserContext, userDataDir);
220
- return { browserContext, close };
809
+ // Startup pages can keep persisting their anonymous identity while the
810
+ // state lands. Park them on blank replacements before the apply, then
811
+ // navigate the replacements only after the recorded state is installed.
812
+ const replaced = await replaceOpenPagesWithBlankTabs(browserContext);
813
+ await browserContext.setStorageState(storageState);
814
+ await ensureNetworkPolicyRoutes(this.config, browserContext);
815
+ await navigateReplacementPages(replaced);
221
816
  }
222
817
  catch (error) {
223
- if (error.message.includes('Executable doesn\'t exist'))
224
- throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
225
- if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
226
- // User data directory is already in use, try again.
227
- await new Promise(resolve => setTimeout(resolve, 1000));
228
- continue;
229
- }
230
- throw error;
818
+ // Nobody holds a close() for this context yet, so a bad storage-state
819
+ // file must not leave the launched browser running.
820
+ await this._closeBrowserContext(browserContext, userDataDir, true);
821
+ throw new StorageStateError(error instanceof Error ? error.message : String(error));
231
822
  }
232
823
  }
233
- throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
824
+ const close = () => this._closeBrowserContext(browserContext, userDataDir, !!storageState);
825
+ return { browserContext, close };
234
826
  }
235
- async _closeBrowserContext(browserContext, userDataDir) {
827
+ async _closeBrowserContext(browserContext, userDataDir, disposeUserDataDir = false) {
236
828
  testDebug('close browser context (persistent)');
237
829
  testDebug('release user data dir', userDataDir);
238
830
  await browserContext.close().catch(() => { });
831
+ // A storage-state profile is unique to this context and holds nothing worth
832
+ // keeping — the state file is the durable copy — so it is removed rather
833
+ // than left to pile up next to the regular persistent profile.
834
+ if (disposeUserDataDir)
835
+ await fs.promises.rm(userDataDir, { recursive: true, force: true }).catch(() => { });
239
836
  this._userDataDirs.delete(userDataDir);
240
837
  testDebug('close browser context complete (persistent)');
241
838
  }
242
- async _createUserDataDir(rootPath) {
839
+ // The suffix keeps disposable storage-state profiles apart from the regular
840
+ // persistent profile (and, carrying a per-context guid, from each other), so
841
+ // removing one can never destroy an interactive session or a sibling's.
842
+ async _createUserDataDir(rootPath, suffix) {
243
843
  const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
244
844
  const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
245
845
  // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
246
846
  const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
247
- const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
847
+ const result = path.join(dir, `mcp-${browserToken}${rootPathToken}${suffix}`);
248
848
  await fs.promises.mkdir(result, { recursive: true });
249
849
  return result;
250
850
  }
@@ -276,6 +876,19 @@ async function findFreePort() {
276
876
  server.on('error', reject);
277
877
  });
278
878
  }
879
+ /**
880
+ * Builds the user-facing "browser not installed" error from Playwright's raw
881
+ * launch failure. When the raw message carries a version-specific executable
882
+ * path (e.g. `chromium-1234`), that path is surfaced so a version mismatch is
883
+ * distinguishable from a genuinely missing install; otherwise the generic
884
+ * message is returned unchanged. Mirrors Playwright MCP throwIfExecutableMissing
885
+ * (microsoft/playwright#41941).
886
+ */
887
+ function browserNotInstalledError(error) {
888
+ const match = error.message.match(/Executable doesn't exist at ([^\r\n]+)/);
889
+ const location = match ? `; expected executable at ${match[1].trim()}` : '';
890
+ return new Error(`Browser specified in your config is not installed${location}. Either install it (likely) or change the config.`);
891
+ }
279
892
  async function startTraceServer(config, rootPath) {
280
893
  if (!config.saveTrace)
281
894
  return undefined;