agent-browser-stealth 0.14.0-fork.3

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 (80) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +1214 -0
  3. package/bin/agent-browser-darwin-arm64 +0 -0
  4. package/bin/agent-browser-darwin-x64 +0 -0
  5. package/bin/agent-browser-linux-arm64 +0 -0
  6. package/bin/agent-browser-linux-x64 +0 -0
  7. package/bin/agent-browser-win32-x64.exe +0 -0
  8. package/bin/agent-browser.js +109 -0
  9. package/dist/actions.d.ts +17 -0
  10. package/dist/actions.d.ts.map +1 -0
  11. package/dist/actions.js +1977 -0
  12. package/dist/actions.js.map +1 -0
  13. package/dist/browser.d.ts +611 -0
  14. package/dist/browser.d.ts.map +1 -0
  15. package/dist/browser.js +2425 -0
  16. package/dist/browser.js.map +1 -0
  17. package/dist/daemon.d.ts +66 -0
  18. package/dist/daemon.d.ts.map +1 -0
  19. package/dist/daemon.js +632 -0
  20. package/dist/daemon.js.map +1 -0
  21. package/dist/diff.d.ts +18 -0
  22. package/dist/diff.d.ts.map +1 -0
  23. package/dist/diff.js +271 -0
  24. package/dist/diff.js.map +1 -0
  25. package/dist/encryption.d.ts +50 -0
  26. package/dist/encryption.d.ts.map +1 -0
  27. package/dist/encryption.js +85 -0
  28. package/dist/encryption.js.map +1 -0
  29. package/dist/ios-actions.d.ts +11 -0
  30. package/dist/ios-actions.d.ts.map +1 -0
  31. package/dist/ios-actions.js +228 -0
  32. package/dist/ios-actions.js.map +1 -0
  33. package/dist/ios-manager.d.ts +266 -0
  34. package/dist/ios-manager.d.ts.map +1 -0
  35. package/dist/ios-manager.js +1073 -0
  36. package/dist/ios-manager.js.map +1 -0
  37. package/dist/protocol.d.ts +26 -0
  38. package/dist/protocol.d.ts.map +1 -0
  39. package/dist/protocol.js +932 -0
  40. package/dist/protocol.js.map +1 -0
  41. package/dist/snapshot.d.ts +67 -0
  42. package/dist/snapshot.d.ts.map +1 -0
  43. package/dist/snapshot.js +514 -0
  44. package/dist/snapshot.js.map +1 -0
  45. package/dist/state-utils.d.ts +77 -0
  46. package/dist/state-utils.d.ts.map +1 -0
  47. package/dist/state-utils.js +178 -0
  48. package/dist/state-utils.js.map +1 -0
  49. package/dist/stealth.d.ts +29 -0
  50. package/dist/stealth.d.ts.map +1 -0
  51. package/dist/stealth.js +1103 -0
  52. package/dist/stealth.js.map +1 -0
  53. package/dist/stream-server.d.ts +117 -0
  54. package/dist/stream-server.d.ts.map +1 -0
  55. package/dist/stream-server.js +309 -0
  56. package/dist/stream-server.js.map +1 -0
  57. package/dist/types.d.ts +854 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +2 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +84 -0
  62. package/scripts/build-all-platforms.sh +68 -0
  63. package/scripts/check-creepjs-headless.js +137 -0
  64. package/scripts/check-sannysoft-webdriver.js +112 -0
  65. package/scripts/check-version-sync.js +39 -0
  66. package/scripts/copy-native.js +36 -0
  67. package/scripts/postinstall.js +275 -0
  68. package/scripts/sync-upstream.sh +142 -0
  69. package/scripts/sync-version.js +87 -0
  70. package/skills/agent-browser/SKILL.md +470 -0
  71. package/skills/agent-browser/references/authentication.md +202 -0
  72. package/skills/agent-browser/references/commands.md +263 -0
  73. package/skills/agent-browser/references/profiling.md +120 -0
  74. package/skills/agent-browser/references/proxy-support.md +194 -0
  75. package/skills/agent-browser/references/session-management.md +193 -0
  76. package/skills/agent-browser/references/snapshot-refs.md +194 -0
  77. package/skills/agent-browser/references/video-recording.md +173 -0
  78. package/skills/agent-browser/templates/authenticated-session.sh +100 -0
  79. package/skills/agent-browser/templates/capture-workflow.sh +69 -0
  80. package/skills/agent-browser/templates/form-automation.sh +62 -0
@@ -0,0 +1,2425 @@
1
+ import { chromium, firefox, webkit, devices, } from 'playwright-core';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
5
+ import { writeFile, mkdir } from 'node:fs/promises';
6
+ import { getEnhancedSnapshot, parseRef } from './snapshot.js';
7
+ import { safeHeaderMerge } from './state-utils.js';
8
+ import { getEncryptionKey, isEncryptedPayload, decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js';
9
+ import { STEALTH_CHROMIUM_ARGS, applyStealthScripts, applyBrowserLevelStealth, } from './stealth.js';
10
+ /**
11
+ * Returns the default Playwright timeout in milliseconds for standard operations.
12
+ * Can be overridden via the AGENT_BROWSER_DEFAULT_TIMEOUT environment variable.
13
+ * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure
14
+ * Playwright errors are returned before the CLI gives up with EAGAIN.
15
+ * CDP and recording contexts use a shorter fixed timeout (10s) and are not affected.
16
+ */
17
+ export function getDefaultTimeout() {
18
+ const envValue = process.env.AGENT_BROWSER_DEFAULT_TIMEOUT;
19
+ if (envValue) {
20
+ const parsed = parseInt(envValue, 10);
21
+ if (!isNaN(parsed) && parsed >= 1000) {
22
+ return parsed;
23
+ }
24
+ }
25
+ return 25000;
26
+ }
27
+ const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
28
+ /**
29
+ * Manages the Playwright browser lifecycle with multiple tabs/windows
30
+ */
31
+ export class BrowserManager {
32
+ browser = null;
33
+ cdpEndpoint = null; // stores port number or full URL
34
+ isPersistentContext = false;
35
+ browserbaseSessionId = null;
36
+ browserbaseApiKey = null;
37
+ browserUseSessionId = null;
38
+ browserUseApiKey = null;
39
+ kernelSessionId = null;
40
+ kernelApiKey = null;
41
+ contexts = [];
42
+ pages = [];
43
+ activePageIndex = 0;
44
+ activeFrame = null;
45
+ dialogHandler = null;
46
+ trackedRequests = [];
47
+ routes = new Map();
48
+ consoleMessages = [];
49
+ pageErrors = [];
50
+ isRecordingHar = false;
51
+ refMap = {};
52
+ lastSnapshot = '';
53
+ scopedHeaderRoutes = new Map();
54
+ colorScheme = null;
55
+ stealthEnabled = true;
56
+ stealthConnectionKind = 'local';
57
+ contextLocale = undefined;
58
+ contextTimezoneId = undefined;
59
+ contextHeaders = undefined;
60
+ contextUserAgent = undefined;
61
+ /**
62
+ * Set the persistent color scheme preference.
63
+ * Applied automatically to all new pages and contexts.
64
+ */
65
+ setColorScheme(scheme) {
66
+ this.colorScheme = scheme;
67
+ }
68
+ /**
69
+ * Centralized stealth policy so launch mode semantics stay consistent.
70
+ * Local Chromium gets args + init scripts; CDP/providers get init scripts only.
71
+ */
72
+ getStealthPolicy(browserType = 'chromium') {
73
+ const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium';
74
+ const applyInitScripts = true;
75
+ const providerManaged = this.stealthConnectionKind === 'provider-kernel';
76
+ const capabilities = [];
77
+ if (applyChromiumArgs) {
78
+ capabilities.push('chromium-launch-args');
79
+ }
80
+ if (applyInitScripts) {
81
+ capabilities.push('context-init-scripts');
82
+ }
83
+ if (providerManaged) {
84
+ capabilities.push('provider-managed-stealth');
85
+ }
86
+ return {
87
+ enabled: true,
88
+ connectionKind: this.stealthConnectionKind,
89
+ applyChromiumArgs,
90
+ applyInitScripts,
91
+ providerManaged,
92
+ capabilities,
93
+ };
94
+ }
95
+ logStealthPolicy(phase, browserType = 'chromium') {
96
+ if (process.env.AGENT_BROWSER_DEBUG !== '1')
97
+ return;
98
+ const policy = this.getStealthPolicy(browserType);
99
+ const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none';
100
+ console.error(`[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}`);
101
+ }
102
+ getStealthStatus(browserType = 'chromium') {
103
+ const policy = this.getStealthPolicy(browserType);
104
+ return {
105
+ enabled: policy.enabled,
106
+ connectionKind: policy.connectionKind,
107
+ capabilities: policy.capabilities,
108
+ providerManaged: policy.providerManaged,
109
+ };
110
+ }
111
+ normalizeLocaleTag(locale) {
112
+ if (!locale)
113
+ return undefined;
114
+ const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
115
+ if (!cleaned)
116
+ return undefined;
117
+ try {
118
+ return new Intl.Locale(cleaned).toString();
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ buildAcceptLanguageHeader(locale) {
125
+ const baseLanguage = locale.split('-')[0];
126
+ if (!baseLanguage || baseLanguage === locale) {
127
+ return `${locale};q=0.9`;
128
+ }
129
+ return `${locale},${baseLanguage};q=0.9`;
130
+ }
131
+ getHeaderValue(headers, name) {
132
+ if (!headers)
133
+ return undefined;
134
+ const target = name.toLowerCase();
135
+ for (const [key, value] of Object.entries(headers)) {
136
+ if (key.toLowerCase() === target)
137
+ return value;
138
+ }
139
+ return undefined;
140
+ }
141
+ // TLD -> {locale, timezone} mapping for automatic region consistency
142
+ static TLD_REGION_MAP = {
143
+ tw: { locale: 'zh-TW', timezone: 'Asia/Taipei' },
144
+ cn: { locale: 'zh-CN', timezone: 'Asia/Shanghai' },
145
+ hk: { locale: 'zh-HK', timezone: 'Asia/Hong_Kong' },
146
+ jp: { locale: 'ja-JP', timezone: 'Asia/Tokyo' },
147
+ kr: { locale: 'ko-KR', timezone: 'Asia/Seoul' },
148
+ th: { locale: 'th-TH', timezone: 'Asia/Bangkok' },
149
+ vn: { locale: 'vi-VN', timezone: 'Asia/Ho_Chi_Minh' },
150
+ sg: { locale: 'en-SG', timezone: 'Asia/Singapore' },
151
+ my: { locale: 'ms-MY', timezone: 'Asia/Kuala_Lumpur' },
152
+ id: { locale: 'id-ID', timezone: 'Asia/Jakarta' },
153
+ ph: { locale: 'en-PH', timezone: 'Asia/Manila' },
154
+ br: { locale: 'pt-BR', timezone: 'America/Sao_Paulo' },
155
+ mx: { locale: 'es-MX', timezone: 'America/Mexico_City' },
156
+ ar: { locale: 'es-AR', timezone: 'America/Argentina/Buenos_Aires' },
157
+ de: { locale: 'de-DE', timezone: 'Europe/Berlin' },
158
+ fr: { locale: 'fr-FR', timezone: 'Europe/Paris' },
159
+ uk: { locale: 'en-GB', timezone: 'Europe/London' },
160
+ ru: { locale: 'ru-RU', timezone: 'Europe/Moscow' },
161
+ in: { locale: 'hi-IN', timezone: 'Asia/Kolkata' },
162
+ au: { locale: 'en-AU', timezone: 'Australia/Sydney' },
163
+ };
164
+ // Target URL set during navigation, used for region auto-detection
165
+ targetUrl = undefined;
166
+ /**
167
+ * Set the target URL for region auto-detection.
168
+ * Called from navigate/open commands so locale/timezone can adapt.
169
+ * Applies CDP overrides to align locale/timezone with the target site's region.
170
+ */
171
+ async setTargetUrl(url) {
172
+ this.targetUrl = url;
173
+ const region = this.getRegionFromUrl(url);
174
+ if (!region)
175
+ return;
176
+ // Skip if user has explicitly set locale/timezone via env
177
+ const envLocale = process.env.AGENT_BROWSER_LOCALE;
178
+ const envTimezone = process.env.AGENT_BROWSER_TIMEZONE || process.env.TZ;
179
+ try {
180
+ const page = this.getPage();
181
+ const cdp = await page.context().newCDPSession(page);
182
+ if (!envTimezone) {
183
+ await cdp
184
+ .send('Emulation.setTimezoneOverride', { timezoneId: region.timezone })
185
+ .catch(() => { });
186
+ }
187
+ if (!envLocale) {
188
+ await cdp.send('Emulation.setLocaleOverride', { locale: region.locale }).catch(() => { });
189
+ // Update Accept-Language header to match
190
+ const langHeader = this.buildAcceptLanguageHeader(region.locale);
191
+ const context = page.context();
192
+ const currentHeaders = this.contextHeaders ?? {};
193
+ await context.setExtraHTTPHeaders({ ...currentHeaders, 'Accept-Language': langHeader });
194
+ }
195
+ await cdp.detach().catch(() => { });
196
+ }
197
+ catch {
198
+ // CDP not available (non-Chromium), skip dynamic override
199
+ }
200
+ }
201
+ getRegionFromUrl(url) {
202
+ if (!url)
203
+ return undefined;
204
+ try {
205
+ const hostname = new URL(url).hostname;
206
+ const parts = hostname.split('.');
207
+ const tld = parts[parts.length - 1];
208
+ // Check compound TLDs like co.th, com.tw, co.id
209
+ const secondLevel = parts.length >= 2 ? parts[parts.length - 2] : '';
210
+ const compoundTld = `${secondLevel}.${tld}`;
211
+ // Try compound first (e.g., "co.th" -> "th", "com.tw" -> "tw")
212
+ const compoundMatch = BrowserManager.TLD_REGION_MAP[tld];
213
+ if (compoundMatch &&
214
+ (secondLevel === 'co' ||
215
+ secondLevel === 'com' ||
216
+ secondLevel === 'or' ||
217
+ secondLevel === 'org')) {
218
+ return compoundMatch;
219
+ }
220
+ // Then direct TLD
221
+ if (BrowserManager.TLD_REGION_MAP[tld]) {
222
+ return BrowserManager.TLD_REGION_MAP[tld];
223
+ }
224
+ return undefined;
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ }
230
+ resolveStealthLocale(headers) {
231
+ const headerLocale = this.getHeaderValue(headers, 'accept-language');
232
+ const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale);
233
+ if (normalizedHeaderLocale)
234
+ return normalizedHeaderLocale;
235
+ // Explicit env var takes priority
236
+ const envLocale = this.normalizeLocaleTag(process.env.AGENT_BROWSER_LOCALE);
237
+ if (envLocale)
238
+ return envLocale;
239
+ // Auto-detect from target URL TLD
240
+ const urlRegion = this.getRegionFromUrl(this.targetUrl);
241
+ if (urlRegion)
242
+ return urlRegion.locale;
243
+ const candidates = [
244
+ process.env.LC_ALL,
245
+ process.env.LC_MESSAGES,
246
+ process.env.LANG,
247
+ Intl.DateTimeFormat().resolvedOptions().locale,
248
+ ];
249
+ for (const candidate of candidates) {
250
+ const normalized = this.normalizeLocaleTag(candidate);
251
+ if (normalized)
252
+ return normalized;
253
+ }
254
+ return 'en-US';
255
+ }
256
+ resolveStealthTimezoneId() {
257
+ // Explicit env var takes priority
258
+ const envTz = process.env.AGENT_BROWSER_TIMEZONE?.trim() || process.env.TZ?.trim();
259
+ if (envTz && (envTz === 'UTC' || envTz.includes('/')))
260
+ return envTz;
261
+ // Auto-detect from target URL TLD
262
+ const urlRegion = this.getRegionFromUrl(this.targetUrl);
263
+ if (urlRegion)
264
+ return urlRegion.timezone;
265
+ const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
266
+ if (systemTz)
267
+ return systemTz;
268
+ return undefined;
269
+ }
270
+ buildStealthContextDefaults(policy, headers) {
271
+ if (!policy.enabled) {
272
+ return { extraHTTPHeaders: headers };
273
+ }
274
+ const locale = this.resolveStealthLocale(headers);
275
+ const timezoneId = this.resolveStealthTimezoneId();
276
+ const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined;
277
+ const extraHTTPHeaders = hasAcceptLanguage
278
+ ? headers
279
+ : {
280
+ ...(headers ?? {}),
281
+ 'Accept-Language': this.buildAcceptLanguageHeader(locale),
282
+ };
283
+ return {
284
+ locale,
285
+ timezoneId,
286
+ extraHTTPHeaders,
287
+ };
288
+ }
289
+ extractChromiumVersion(versionText) {
290
+ const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/);
291
+ return match?.[1];
292
+ }
293
+ buildStealthChromiumUserAgent(chromeVersion) {
294
+ const platform = os.platform();
295
+ let osToken = 'X11; Linux x86_64';
296
+ if (platform === 'darwin') {
297
+ osToken = 'Macintosh; Intel Mac OS X 10_15_7';
298
+ }
299
+ else if (platform === 'win32') {
300
+ osToken = 'Windows NT 10.0; Win64; x64';
301
+ }
302
+ return (`Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` +
303
+ `(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`);
304
+ }
305
+ getStealthUserAgentVersionHint() {
306
+ const deviceUA = devices['Desktop Chrome']?.userAgent;
307
+ if (!deviceUA)
308
+ return undefined;
309
+ return this.extractChromiumVersion(deviceUA);
310
+ }
311
+ /**
312
+ * Apply context init-script stealth patches when policy allows.
313
+ */
314
+ async applyStealthIfEnabled(context, options = {}) {
315
+ const policy = this.getStealthPolicy();
316
+ if (!policy.applyInitScripts)
317
+ return;
318
+ await applyStealthScripts(context, {
319
+ ...options,
320
+ userAgent: this.contextUserAgent,
321
+ });
322
+ this.logStealthPolicy('init-script applied');
323
+ }
324
+ // CDP session for screencast and input injection
325
+ cdpSession = null;
326
+ screencastActive = false;
327
+ screencastSessionId = 0;
328
+ frameCallback = null;
329
+ screencastFrameHandler = null;
330
+ // Video recording (Playwright native)
331
+ recordingContext = null;
332
+ recordingPage = null;
333
+ recordingOutputPath = '';
334
+ recordingTempDir = '';
335
+ launchWarnings = [];
336
+ /**
337
+ * Get and clear launch warnings (e.g., decryption failures)
338
+ */
339
+ getAndClearWarnings() {
340
+ const warnings = this.launchWarnings;
341
+ this.launchWarnings = [];
342
+ return warnings;
343
+ }
344
+ // CDP profiling state
345
+ static MAX_PROFILE_EVENTS = 5_000_000;
346
+ profilingActive = false;
347
+ profileChunks = [];
348
+ profileEventsDropped = false;
349
+ profileCompleteResolver = null;
350
+ profileDataHandler = null;
351
+ profileCompleteHandler = null;
352
+ /**
353
+ * Check if browser is launched
354
+ */
355
+ isLaunched() {
356
+ return this.browser !== null || this.isPersistentContext;
357
+ }
358
+ /**
359
+ * Get enhanced snapshot with refs and cache the ref map
360
+ */
361
+ async getSnapshot(options) {
362
+ const page = this.getPage();
363
+ const snapshot = await getEnhancedSnapshot(page, options);
364
+ this.refMap = snapshot.refs;
365
+ this.lastSnapshot = snapshot.tree;
366
+ return snapshot;
367
+ }
368
+ /**
369
+ * Get the last snapshot tree text (empty string if no snapshot has been taken)
370
+ */
371
+ getLastSnapshot() {
372
+ return this.lastSnapshot;
373
+ }
374
+ /**
375
+ * Update the stored snapshot (used by diff to keep the baseline current)
376
+ */
377
+ setLastSnapshot(snapshot) {
378
+ this.lastSnapshot = snapshot;
379
+ }
380
+ /**
381
+ * Get the cached ref map from last snapshot
382
+ */
383
+ getRefMap() {
384
+ return this.refMap;
385
+ }
386
+ /**
387
+ * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
388
+ * Returns null if ref doesn't exist or is invalid
389
+ */
390
+ getLocatorFromRef(refArg) {
391
+ const ref = parseRef(refArg);
392
+ if (!ref)
393
+ return null;
394
+ const refData = this.refMap[ref];
395
+ if (!refData)
396
+ return null;
397
+ const page = this.getPage();
398
+ // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role)
399
+ // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector
400
+ if (refData.role === 'clickable' || refData.role === 'focusable') {
401
+ // The selector is a CSS selector, use it directly
402
+ return page.locator(refData.selector);
403
+ }
404
+ // Build locator with exact: true to avoid substring matches
405
+ let locator;
406
+ if (refData.name) {
407
+ locator = page.getByRole(refData.role, { name: refData.name, exact: true });
408
+ }
409
+ else {
410
+ locator = page.getByRole(refData.role);
411
+ }
412
+ // If an nth index is stored (for disambiguation), use it
413
+ if (refData.nth !== undefined) {
414
+ locator = locator.nth(refData.nth);
415
+ }
416
+ return locator;
417
+ }
418
+ /**
419
+ * Check if a selector looks like a ref
420
+ */
421
+ isRef(selector) {
422
+ return parseRef(selector) !== null;
423
+ }
424
+ /**
425
+ * Get locator - supports both refs and regular selectors
426
+ */
427
+ getLocator(selectorOrRef) {
428
+ // Check if it's a ref first
429
+ const locator = this.getLocatorFromRef(selectorOrRef);
430
+ if (locator)
431
+ return locator;
432
+ // Otherwise treat as regular selector
433
+ const page = this.getPage();
434
+ return page.locator(selectorOrRef);
435
+ }
436
+ /**
437
+ * Check if the browser has any usable pages
438
+ */
439
+ hasPages() {
440
+ return this.pages.length > 0;
441
+ }
442
+ getSafePageUrl(page) {
443
+ try {
444
+ return page.url();
445
+ }
446
+ catch {
447
+ return '';
448
+ }
449
+ }
450
+ isIgnoredCDPPageUrl(url) {
451
+ if (!url)
452
+ return false;
453
+ const normalizedUrl = url.toLowerCase();
454
+ return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix));
455
+ }
456
+ isUsableCDPPage(page) {
457
+ if (page.isClosed())
458
+ return false;
459
+ const url = this.getSafePageUrl(page);
460
+ if (!url)
461
+ return false;
462
+ return !this.isIgnoredCDPPageUrl(url);
463
+ }
464
+ collectUsableCDPPages(contexts) {
465
+ return contexts
466
+ .flatMap((context) => context.pages())
467
+ .filter((page) => this.isUsableCDPPage(page));
468
+ }
469
+ /**
470
+ * Ensure at least one page exists. If the browser is launched but all pages
471
+ * were closed (stale session), creates a new page on the existing context.
472
+ * No-op if pages already exist.
473
+ */
474
+ async ensurePage() {
475
+ if (this.pages.length > 0)
476
+ return;
477
+ if (!this.browser && !this.isPersistentContext)
478
+ return;
479
+ // Use the last existing context, or create a new one
480
+ let context;
481
+ if (this.contexts.length > 0) {
482
+ context = this.contexts[this.contexts.length - 1];
483
+ }
484
+ else if (this.browser) {
485
+ context = await this.browser.newContext({
486
+ ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
487
+ ...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
488
+ ...(this.contextLocale && { locale: this.contextLocale }),
489
+ ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
490
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
491
+ });
492
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
493
+ context.setDefaultTimeout(getDefaultTimeout());
494
+ this.contexts.push(context);
495
+ this.setupContextTracking(context);
496
+ }
497
+ else {
498
+ return;
499
+ }
500
+ const page = await context.newPage();
501
+ if (!this.pages.includes(page)) {
502
+ this.pages.push(page);
503
+ this.setupPageTracking(page);
504
+ }
505
+ this.activePageIndex = this.pages.length - 1;
506
+ }
507
+ /**
508
+ * Get the current active page, throws if not launched
509
+ */
510
+ getPage() {
511
+ if (this.pages.length === 0) {
512
+ throw new Error('Browser not launched. Call launch first.');
513
+ }
514
+ const current = this.pages[this.activePageIndex];
515
+ if (current && this.isUsableCDPPage(current)) {
516
+ return current;
517
+ }
518
+ const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page));
519
+ if (usableIndex !== -1) {
520
+ this.activePageIndex = usableIndex;
521
+ return this.pages[this.activePageIndex];
522
+ }
523
+ const openIndex = this.pages.findIndex((page) => !page.isClosed());
524
+ if (openIndex !== -1) {
525
+ this.activePageIndex = openIndex;
526
+ return this.pages[this.activePageIndex];
527
+ }
528
+ return this.pages[this.activePageIndex];
529
+ }
530
+ /**
531
+ * Get the current frame (or page's main frame if no frame is selected)
532
+ */
533
+ getFrame() {
534
+ if (this.activeFrame) {
535
+ return this.activeFrame;
536
+ }
537
+ return this.getPage().mainFrame();
538
+ }
539
+ /**
540
+ * Switch to a frame by selector, name, or URL
541
+ */
542
+ async switchToFrame(options) {
543
+ const page = this.getPage();
544
+ if (options.selector) {
545
+ const frameElement = await page.$(options.selector);
546
+ if (!frameElement) {
547
+ throw new Error(`Frame not found: ${options.selector}`);
548
+ }
549
+ const frame = await frameElement.contentFrame();
550
+ if (!frame) {
551
+ throw new Error(`Element is not a frame: ${options.selector}`);
552
+ }
553
+ this.activeFrame = frame;
554
+ }
555
+ else if (options.name) {
556
+ const frame = page.frame({ name: options.name });
557
+ if (!frame) {
558
+ throw new Error(`Frame not found with name: ${options.name}`);
559
+ }
560
+ this.activeFrame = frame;
561
+ }
562
+ else if (options.url) {
563
+ const frame = page.frame({ url: options.url });
564
+ if (!frame) {
565
+ throw new Error(`Frame not found with URL: ${options.url}`);
566
+ }
567
+ this.activeFrame = frame;
568
+ }
569
+ }
570
+ /**
571
+ * Switch back to main frame
572
+ */
573
+ switchToMainFrame() {
574
+ this.activeFrame = null;
575
+ }
576
+ /**
577
+ * Set up dialog handler
578
+ */
579
+ setDialogHandler(response, promptText) {
580
+ const page = this.getPage();
581
+ // Remove existing handler if any
582
+ if (this.dialogHandler) {
583
+ page.removeListener('dialog', this.dialogHandler);
584
+ }
585
+ this.dialogHandler = async (dialog) => {
586
+ if (response === 'accept') {
587
+ await dialog.accept(promptText);
588
+ }
589
+ else {
590
+ await dialog.dismiss();
591
+ }
592
+ };
593
+ page.on('dialog', this.dialogHandler);
594
+ }
595
+ /**
596
+ * Clear dialog handler
597
+ */
598
+ clearDialogHandler() {
599
+ if (this.dialogHandler) {
600
+ const page = this.getPage();
601
+ page.removeListener('dialog', this.dialogHandler);
602
+ this.dialogHandler = null;
603
+ }
604
+ }
605
+ /**
606
+ * Start tracking requests
607
+ */
608
+ startRequestTracking() {
609
+ const page = this.getPage();
610
+ page.on('request', (request) => {
611
+ this.trackedRequests.push({
612
+ url: request.url(),
613
+ method: request.method(),
614
+ headers: request.headers(),
615
+ timestamp: Date.now(),
616
+ resourceType: request.resourceType(),
617
+ });
618
+ });
619
+ }
620
+ /**
621
+ * Get tracked requests
622
+ */
623
+ getRequests(filter) {
624
+ if (filter) {
625
+ return this.trackedRequests.filter((r) => r.url.includes(filter));
626
+ }
627
+ return this.trackedRequests;
628
+ }
629
+ /**
630
+ * Clear tracked requests
631
+ */
632
+ clearRequests() {
633
+ this.trackedRequests = [];
634
+ }
635
+ /**
636
+ * Add a route to intercept requests
637
+ */
638
+ async addRoute(url, options) {
639
+ const page = this.getPage();
640
+ const handler = async (route) => {
641
+ if (options.abort) {
642
+ await route.abort();
643
+ }
644
+ else if (options.response) {
645
+ await route.fulfill({
646
+ status: options.response.status ?? 200,
647
+ body: options.response.body ?? '',
648
+ contentType: options.response.contentType ?? 'text/plain',
649
+ headers: options.response.headers,
650
+ });
651
+ }
652
+ else {
653
+ await route.continue();
654
+ }
655
+ };
656
+ this.routes.set(url, handler);
657
+ await page.route(url, handler);
658
+ }
659
+ /**
660
+ * Remove a route
661
+ */
662
+ async removeRoute(url) {
663
+ const page = this.getPage();
664
+ if (url) {
665
+ const handler = this.routes.get(url);
666
+ if (handler) {
667
+ await page.unroute(url, handler);
668
+ this.routes.delete(url);
669
+ }
670
+ }
671
+ else {
672
+ // Remove all routes
673
+ for (const [routeUrl, handler] of this.routes) {
674
+ await page.unroute(routeUrl, handler);
675
+ }
676
+ this.routes.clear();
677
+ }
678
+ }
679
+ /**
680
+ * Set geolocation
681
+ */
682
+ async setGeolocation(latitude, longitude, accuracy) {
683
+ const context = this.contexts[0];
684
+ if (context) {
685
+ await context.setGeolocation({ latitude, longitude, accuracy });
686
+ }
687
+ }
688
+ /**
689
+ * Set permissions
690
+ */
691
+ async setPermissions(permissions, grant) {
692
+ const context = this.contexts[0];
693
+ if (context) {
694
+ if (grant) {
695
+ await context.grantPermissions(permissions);
696
+ }
697
+ else {
698
+ await context.clearPermissions();
699
+ }
700
+ }
701
+ }
702
+ /**
703
+ * Set viewport
704
+ */
705
+ async setViewport(width, height) {
706
+ const page = this.getPage();
707
+ await page.setViewportSize({ width, height });
708
+ }
709
+ /**
710
+ * Set device scale factor (devicePixelRatio) via CDP
711
+ * This sets window.devicePixelRatio which affects how the page renders and responds to media queries
712
+ *
713
+ * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
714
+ * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
715
+ * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
716
+ * deviceScaleFactor must be set at context creation time.
717
+ *
718
+ * Must be called after setViewport to work correctly
719
+ */
720
+ async setDeviceScaleFactor(deviceScaleFactor, width, height, mobile = false) {
721
+ const cdp = await this.getCDPSession();
722
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
723
+ width,
724
+ height,
725
+ deviceScaleFactor,
726
+ mobile,
727
+ });
728
+ }
729
+ /**
730
+ * Clear device metrics override to restore default devicePixelRatio
731
+ */
732
+ async clearDeviceMetricsOverride() {
733
+ const cdp = await this.getCDPSession();
734
+ await cdp.send('Emulation.clearDeviceMetricsOverride');
735
+ }
736
+ /**
737
+ * Get device descriptor
738
+ */
739
+ getDevice(deviceName) {
740
+ return devices[deviceName];
741
+ }
742
+ /**
743
+ * List available devices
744
+ */
745
+ listDevices() {
746
+ return Object.keys(devices);
747
+ }
748
+ /**
749
+ * Start console message tracking
750
+ */
751
+ startConsoleTracking() {
752
+ const page = this.getPage();
753
+ page.on('console', (msg) => {
754
+ this.consoleMessages.push({
755
+ type: msg.type(),
756
+ text: msg.text(),
757
+ timestamp: Date.now(),
758
+ });
759
+ });
760
+ }
761
+ /**
762
+ * Get console messages
763
+ */
764
+ getConsoleMessages() {
765
+ return this.consoleMessages;
766
+ }
767
+ /**
768
+ * Clear console messages
769
+ */
770
+ clearConsoleMessages() {
771
+ this.consoleMessages = [];
772
+ }
773
+ /**
774
+ * Start error tracking
775
+ */
776
+ startErrorTracking() {
777
+ const page = this.getPage();
778
+ page.on('pageerror', (error) => {
779
+ this.pageErrors.push({
780
+ message: error.message,
781
+ timestamp: Date.now(),
782
+ });
783
+ });
784
+ }
785
+ /**
786
+ * Get page errors
787
+ */
788
+ getPageErrors() {
789
+ return this.pageErrors;
790
+ }
791
+ /**
792
+ * Clear page errors
793
+ */
794
+ clearPageErrors() {
795
+ this.pageErrors = [];
796
+ }
797
+ /**
798
+ * Start HAR recording
799
+ */
800
+ async startHarRecording() {
801
+ // HAR is started at context level, flag for tracking
802
+ this.isRecordingHar = true;
803
+ }
804
+ /**
805
+ * Check if HAR recording
806
+ */
807
+ isHarRecording() {
808
+ return this.isRecordingHar;
809
+ }
810
+ /**
811
+ * Set offline mode
812
+ */
813
+ async setOffline(offline) {
814
+ const context = this.contexts[0];
815
+ if (context) {
816
+ await context.setOffline(offline);
817
+ }
818
+ }
819
+ /**
820
+ * Set extra HTTP headers (global - all requests)
821
+ */
822
+ async setExtraHeaders(headers) {
823
+ const context = this.contexts[0];
824
+ if (context) {
825
+ await context.setExtraHTTPHeaders(headers);
826
+ }
827
+ }
828
+ /**
829
+ * Set scoped HTTP headers (only for requests matching the origin)
830
+ * Uses route interception to add headers only to matching requests
831
+ */
832
+ async setScopedHeaders(origin, headers) {
833
+ const page = this.getPage();
834
+ // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
835
+ // Handle both full URLs and just hostnames
836
+ let urlPattern;
837
+ try {
838
+ const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
839
+ // Match any protocol, the host, and any path
840
+ urlPattern = `**://${url.host}/**`;
841
+ }
842
+ catch {
843
+ // If parsing fails, treat as hostname pattern
844
+ urlPattern = `**://${origin}/**`;
845
+ }
846
+ // Remove existing route for this origin if any
847
+ const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
848
+ if (existingHandler) {
849
+ await page.unroute(urlPattern, existingHandler);
850
+ }
851
+ // Create handler that adds headers to matching requests
852
+ const handler = async (route) => {
853
+ const requestHeaders = route.request().headers();
854
+ await route.continue({
855
+ headers: safeHeaderMerge(requestHeaders, headers),
856
+ });
857
+ };
858
+ // Store and register the route
859
+ this.scopedHeaderRoutes.set(urlPattern, handler);
860
+ await page.route(urlPattern, handler);
861
+ }
862
+ /**
863
+ * Clear scoped headers for an origin (or all if no origin specified)
864
+ */
865
+ async clearScopedHeaders(origin) {
866
+ const page = this.getPage();
867
+ if (origin) {
868
+ let urlPattern;
869
+ try {
870
+ const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
871
+ urlPattern = `**://${url.host}/**`;
872
+ }
873
+ catch {
874
+ urlPattern = `**://${origin}/**`;
875
+ }
876
+ const handler = this.scopedHeaderRoutes.get(urlPattern);
877
+ if (handler) {
878
+ await page.unroute(urlPattern, handler);
879
+ this.scopedHeaderRoutes.delete(urlPattern);
880
+ }
881
+ }
882
+ else {
883
+ // Clear all scoped header routes
884
+ for (const [pattern, handler] of this.scopedHeaderRoutes) {
885
+ await page.unroute(pattern, handler);
886
+ }
887
+ this.scopedHeaderRoutes.clear();
888
+ }
889
+ }
890
+ /**
891
+ * Start tracing
892
+ */
893
+ async startTracing(options) {
894
+ const context = this.contexts[0];
895
+ if (context) {
896
+ await context.tracing.start({
897
+ screenshots: options.screenshots ?? true,
898
+ snapshots: options.snapshots ?? true,
899
+ });
900
+ }
901
+ }
902
+ /**
903
+ * Stop tracing and save
904
+ */
905
+ async stopTracing(path) {
906
+ const context = this.contexts[0];
907
+ if (context) {
908
+ await context.tracing.stop(path ? { path } : undefined);
909
+ }
910
+ }
911
+ /**
912
+ * Get the current browser context (first context)
913
+ */
914
+ getContext() {
915
+ return this.contexts[0] ?? null;
916
+ }
917
+ /**
918
+ * Save storage state (cookies, localStorage, etc.)
919
+ */
920
+ async saveStorageState(path) {
921
+ const context = this.contexts[0];
922
+ if (context) {
923
+ await context.storageState({ path });
924
+ }
925
+ }
926
+ /**
927
+ * Get all pages
928
+ */
929
+ getPages() {
930
+ return this.pages;
931
+ }
932
+ /**
933
+ * Get current page index
934
+ */
935
+ getActiveIndex() {
936
+ return this.activePageIndex;
937
+ }
938
+ /**
939
+ * Get the current browser instance
940
+ */
941
+ getBrowser() {
942
+ return this.browser;
943
+ }
944
+ /**
945
+ * Check if an existing CDP connection is still alive
946
+ * by verifying we can access browser contexts and that at least one has pages
947
+ */
948
+ isCdpConnectionAlive() {
949
+ if (!this.browser)
950
+ return false;
951
+ try {
952
+ const contexts = this.browser.contexts();
953
+ if (contexts.length === 0)
954
+ return false;
955
+ return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page)));
956
+ }
957
+ catch {
958
+ return false;
959
+ }
960
+ }
961
+ /**
962
+ * Check if CDP connection needs to be re-established
963
+ */
964
+ needsCdpReconnect(cdpEndpoint) {
965
+ if (!this.browser?.isConnected())
966
+ return true;
967
+ if (this.cdpEndpoint !== cdpEndpoint)
968
+ return true;
969
+ if (!this.isCdpConnectionAlive())
970
+ return true;
971
+ return false;
972
+ }
973
+ /**
974
+ * Close a Browserbase session via API
975
+ */
976
+ async closeBrowserbaseSession(sessionId, apiKey) {
977
+ await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
978
+ method: 'DELETE',
979
+ headers: {
980
+ 'X-BB-API-Key': apiKey,
981
+ },
982
+ });
983
+ }
984
+ /**
985
+ * Close a Browser Use session via API
986
+ */
987
+ async closeBrowserUseSession(sessionId, apiKey) {
988
+ const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
989
+ method: 'PATCH',
990
+ headers: {
991
+ 'Content-Type': 'application/json',
992
+ 'X-Browser-Use-API-Key': apiKey,
993
+ },
994
+ body: JSON.stringify({ action: 'stop' }),
995
+ });
996
+ if (!response.ok) {
997
+ throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
998
+ }
999
+ }
1000
+ /**
1001
+ * Close a Kernel session via API
1002
+ */
1003
+ async closeKernelSession(sessionId, apiKey) {
1004
+ const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
1005
+ method: 'DELETE',
1006
+ headers: {
1007
+ Authorization: `Bearer ${apiKey}`,
1008
+ },
1009
+ });
1010
+ if (!response.ok) {
1011
+ throw new Error(`Failed to close Kernel session: ${response.statusText}`);
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Connect to Browserbase remote browser via CDP.
1016
+ * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
1017
+ */
1018
+ async connectToBrowserbase() {
1019
+ this.stealthConnectionKind = 'provider-browserbase';
1020
+ const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
1021
+ const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
1022
+ if (!browserbaseApiKey || !browserbaseProjectId) {
1023
+ throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
1024
+ }
1025
+ const response = await fetch('https://api.browserbase.com/v1/sessions', {
1026
+ method: 'POST',
1027
+ headers: {
1028
+ 'Content-Type': 'application/json',
1029
+ 'X-BB-API-Key': browserbaseApiKey,
1030
+ },
1031
+ body: JSON.stringify({
1032
+ projectId: browserbaseProjectId,
1033
+ }),
1034
+ });
1035
+ if (!response.ok) {
1036
+ throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
1037
+ }
1038
+ const session = (await response.json());
1039
+ const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
1040
+ throw new Error('Failed to connect to Browserbase session via CDP');
1041
+ });
1042
+ try {
1043
+ const contexts = browser.contexts();
1044
+ if (contexts.length === 0) {
1045
+ throw new Error('No browser context found in Browserbase session');
1046
+ }
1047
+ const context = contexts[0];
1048
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1049
+ const pages = context.pages();
1050
+ const page = pages[0] ?? (await context.newPage());
1051
+ this.browserbaseSessionId = session.id;
1052
+ this.browserbaseApiKey = browserbaseApiKey;
1053
+ this.browser = browser;
1054
+ context.setDefaultTimeout(10000);
1055
+ this.contexts.push(context);
1056
+ this.setupContextTracking(context);
1057
+ this.pages.push(page);
1058
+ this.activePageIndex = 0;
1059
+ this.setupPageTracking(page);
1060
+ }
1061
+ catch (error) {
1062
+ await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
1063
+ console.error('Failed to close Browserbase session during cleanup:', sessionError);
1064
+ });
1065
+ throw error;
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Find or create a Kernel profile by name.
1070
+ * Returns the profile object if successful.
1071
+ */
1072
+ async findOrCreateKernelProfile(profileName, apiKey) {
1073
+ // First, try to get the existing profile
1074
+ const getResponse = await fetch(`https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, {
1075
+ method: 'GET',
1076
+ headers: {
1077
+ Authorization: `Bearer ${apiKey}`,
1078
+ },
1079
+ });
1080
+ if (getResponse.ok) {
1081
+ // Profile exists, return it
1082
+ return { name: profileName };
1083
+ }
1084
+ if (getResponse.status !== 404) {
1085
+ throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`);
1086
+ }
1087
+ // Profile doesn't exist, create it
1088
+ const createResponse = await fetch('https://api.onkernel.com/profiles', {
1089
+ method: 'POST',
1090
+ headers: {
1091
+ 'Content-Type': 'application/json',
1092
+ Authorization: `Bearer ${apiKey}`,
1093
+ },
1094
+ body: JSON.stringify({ name: profileName }),
1095
+ });
1096
+ if (!createResponse.ok) {
1097
+ throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`);
1098
+ }
1099
+ return { name: profileName };
1100
+ }
1101
+ /**
1102
+ * Connect to Kernel remote browser via CDP.
1103
+ * Requires KERNEL_API_KEY environment variable.
1104
+ */
1105
+ async connectToKernel() {
1106
+ this.stealthConnectionKind = 'provider-kernel';
1107
+ const kernelApiKey = process.env.KERNEL_API_KEY;
1108
+ if (!kernelApiKey) {
1109
+ throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
1110
+ }
1111
+ // Find or create profile if KERNEL_PROFILE_NAME is set
1112
+ const profileName = process.env.KERNEL_PROFILE_NAME;
1113
+ let profileConfig;
1114
+ if (profileName) {
1115
+ await this.findOrCreateKernelProfile(profileName, kernelApiKey);
1116
+ profileConfig = {
1117
+ profile: {
1118
+ name: profileName,
1119
+ save_changes: true, // Save cookies/state back to the profile when session ends
1120
+ },
1121
+ };
1122
+ }
1123
+ const response = await fetch('https://api.onkernel.com/browsers', {
1124
+ method: 'POST',
1125
+ headers: {
1126
+ 'Content-Type': 'application/json',
1127
+ Authorization: `Bearer ${kernelApiKey}`,
1128
+ },
1129
+ body: JSON.stringify({
1130
+ // Kernel browsers are headful by default with stealth mode available
1131
+ // The user can configure these via environment variables if needed
1132
+ headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true',
1133
+ stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode
1134
+ timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10),
1135
+ // Load and save to a profile if specified
1136
+ ...profileConfig,
1137
+ }),
1138
+ });
1139
+ if (!response.ok) {
1140
+ throw new Error(`Failed to create Kernel session: ${response.statusText}`);
1141
+ }
1142
+ let session;
1143
+ try {
1144
+ session = (await response.json());
1145
+ }
1146
+ catch (error) {
1147
+ throw new Error(`Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}`);
1148
+ }
1149
+ if (!session.session_id || !session.cdp_ws_url) {
1150
+ throw new Error(`Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}`);
1151
+ }
1152
+ const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => {
1153
+ throw new Error('Failed to connect to Kernel session via CDP');
1154
+ });
1155
+ try {
1156
+ const contexts = browser.contexts();
1157
+ let context;
1158
+ let page;
1159
+ // Kernel browsers launch with a default context and page
1160
+ if (contexts.length === 0) {
1161
+ context = await browser.newContext();
1162
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1163
+ page = await context.newPage();
1164
+ }
1165
+ else {
1166
+ context = contexts[0];
1167
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1168
+ const pages = context.pages();
1169
+ page = pages[0] ?? (await context.newPage());
1170
+ }
1171
+ this.kernelSessionId = session.session_id;
1172
+ this.kernelApiKey = kernelApiKey;
1173
+ this.browser = browser;
1174
+ context.setDefaultTimeout(getDefaultTimeout());
1175
+ this.contexts.push(context);
1176
+ this.pages.push(page);
1177
+ this.activePageIndex = 0;
1178
+ this.setupPageTracking(page);
1179
+ this.setupContextTracking(context);
1180
+ }
1181
+ catch (error) {
1182
+ await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
1183
+ console.error('Failed to close Kernel session during cleanup:', sessionError);
1184
+ });
1185
+ throw error;
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Connect to Browser Use remote browser via CDP.
1190
+ * Requires BROWSER_USE_API_KEY environment variable.
1191
+ */
1192
+ async connectToBrowserUse() {
1193
+ this.stealthConnectionKind = 'provider-browseruse';
1194
+ const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
1195
+ if (!browserUseApiKey) {
1196
+ throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
1197
+ }
1198
+ const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
1199
+ method: 'POST',
1200
+ headers: {
1201
+ 'Content-Type': 'application/json',
1202
+ 'X-Browser-Use-API-Key': browserUseApiKey,
1203
+ },
1204
+ body: JSON.stringify({}),
1205
+ });
1206
+ if (!response.ok) {
1207
+ throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
1208
+ }
1209
+ let session;
1210
+ try {
1211
+ session = (await response.json());
1212
+ }
1213
+ catch (error) {
1214
+ throw new Error(`Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`);
1215
+ }
1216
+ if (!session.id || !session.cdpUrl) {
1217
+ throw new Error(`Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`);
1218
+ }
1219
+ const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
1220
+ throw new Error('Failed to connect to Browser Use session via CDP');
1221
+ });
1222
+ try {
1223
+ const contexts = browser.contexts();
1224
+ let context;
1225
+ let page;
1226
+ if (contexts.length === 0) {
1227
+ context = await browser.newContext();
1228
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1229
+ page = await context.newPage();
1230
+ }
1231
+ else {
1232
+ context = contexts[0];
1233
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1234
+ const pages = context.pages();
1235
+ page = pages[0] ?? (await context.newPage());
1236
+ }
1237
+ this.browserUseSessionId = session.id;
1238
+ this.browserUseApiKey = browserUseApiKey;
1239
+ this.browser = browser;
1240
+ context.setDefaultTimeout(getDefaultTimeout());
1241
+ this.contexts.push(context);
1242
+ this.pages.push(page);
1243
+ this.activePageIndex = 0;
1244
+ this.setupPageTracking(page);
1245
+ this.setupContextTracking(context);
1246
+ }
1247
+ catch (error) {
1248
+ await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
1249
+ console.error('Failed to close Browser Use session during cleanup:', sessionError);
1250
+ });
1251
+ throw error;
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Launch the browser with the specified options
1256
+ * If already launched, this is a no-op (browser stays open)
1257
+ */
1258
+ async launch(options) {
1259
+ // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
1260
+ const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
1261
+ const hasExtensions = !!options.extensions?.length;
1262
+ const hasStorageState = !!options.storageState;
1263
+ if (hasExtensions && cdpEndpoint) {
1264
+ throw new Error('Extensions cannot be used with CDP connection');
1265
+ }
1266
+ if (hasStorageState && hasExtensions) {
1267
+ throw new Error('Storage state cannot be used with extensions (extensions require persistent context)');
1268
+ }
1269
+ if (this.isLaunched()) {
1270
+ const needsRelaunch = (!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) ||
1271
+ (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) ||
1272
+ (!!options.autoConnect && !this.isCdpConnectionAlive());
1273
+ if (needsRelaunch) {
1274
+ await this.close();
1275
+ }
1276
+ else if (options.autoConnect && this.isCdpConnectionAlive()) {
1277
+ // Already connected via auto-connect, no need to reconnect
1278
+ return;
1279
+ }
1280
+ else {
1281
+ return;
1282
+ }
1283
+ }
1284
+ if (options.colorScheme) {
1285
+ this.colorScheme = options.colorScheme;
1286
+ }
1287
+ this.stealthEnabled = true;
1288
+ this.contextLocale = this.resolveStealthLocale(options.headers);
1289
+ this.contextTimezoneId = this.resolveStealthTimezoneId();
1290
+ this.contextHeaders = undefined;
1291
+ this.contextUserAgent = options.userAgent;
1292
+ // -p flag takes precedence over AGENT_BROWSER_PROVIDER.
1293
+ const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
1294
+ if (cdpEndpoint || options.autoConnect) {
1295
+ this.stealthConnectionKind = 'cdp';
1296
+ }
1297
+ else if (provider === 'browserbase') {
1298
+ this.stealthConnectionKind = 'provider-browserbase';
1299
+ }
1300
+ else if (provider === 'browseruse') {
1301
+ this.stealthConnectionKind = 'provider-browseruse';
1302
+ }
1303
+ else if (provider === 'kernel') {
1304
+ this.stealthConnectionKind = 'provider-kernel';
1305
+ }
1306
+ else {
1307
+ this.stealthConnectionKind = 'local';
1308
+ }
1309
+ this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
1310
+ if (cdpEndpoint) {
1311
+ await this.connectViaCDP(cdpEndpoint);
1312
+ return;
1313
+ }
1314
+ if (options.autoConnect) {
1315
+ await this.autoConnectViaCDP();
1316
+ return;
1317
+ }
1318
+ // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
1319
+ if (provider === 'browserbase') {
1320
+ await this.connectToBrowserbase();
1321
+ return;
1322
+ }
1323
+ if (provider === 'browseruse') {
1324
+ await this.connectToBrowserUse();
1325
+ return;
1326
+ }
1327
+ // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel
1328
+ if (provider === 'kernel') {
1329
+ await this.connectToKernel();
1330
+ return;
1331
+ }
1332
+ const browserType = options.browser ?? 'chromium';
1333
+ if (hasExtensions && browserType !== 'chromium') {
1334
+ throw new Error('Extensions are only supported in Chromium');
1335
+ }
1336
+ // allowFileAccess is only supported in Chromium
1337
+ if (options.allowFileAccess && browserType !== 'chromium') {
1338
+ throw new Error('allowFileAccess is only supported in Chromium');
1339
+ }
1340
+ const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
1341
+ // Chromium launches always use the Chrome channel unless a custom executable is provided.
1342
+ const chromeChannel = browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined;
1343
+ const stealthPolicy = this.getStealthPolicy(browserType);
1344
+ const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
1345
+ const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
1346
+ this.contextLocale = contextDefaults.locale;
1347
+ this.contextTimezoneId = contextDefaults.timezoneId;
1348
+ this.contextHeaders = contextDefaults.extraHTTPHeaders;
1349
+ let contextUserAgent = options.userAgent;
1350
+ if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') {
1351
+ const versionHint = this.getStealthUserAgentVersionHint();
1352
+ if (versionHint) {
1353
+ contextUserAgent = this.buildStealthChromiumUserAgent(versionHint);
1354
+ }
1355
+ }
1356
+ this.contextUserAgent = contextUserAgent;
1357
+ // Build base args array with file access flags and stealth args when policy allows.
1358
+ const fileAccessArgs = options.allowFileAccess
1359
+ ? ['--allow-file-access-from-files', '--allow-file-access']
1360
+ : [];
1361
+ const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
1362
+ const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent='));
1363
+ const launchUserAgentArgs = !hasUserAgentArg &&
1364
+ !options.userAgent &&
1365
+ stealthPolicy.enabled &&
1366
+ browserType === 'chromium' &&
1367
+ contextUserAgent
1368
+ ? [`--user-agent=${contextUserAgent}`]
1369
+ : [];
1370
+ const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs];
1371
+ const baseArgs = options.args
1372
+ ? [...implicitArgs, ...options.args]
1373
+ : implicitArgs.length > 0
1374
+ ? implicitArgs
1375
+ : undefined;
1376
+ // Auto-detect args that control window size and disable viewport emulation
1377
+ // so Playwright doesn't override the browser's own sizing behavior
1378
+ const hasWindowSizeArgs = baseArgs?.some((arg) => arg === '--start-maximized' || arg.startsWith('--window-size='));
1379
+ const viewport = options.viewport !== undefined
1380
+ ? options.viewport
1381
+ : hasWindowSizeArgs
1382
+ ? null
1383
+ : { width: 1280, height: 720 };
1384
+ let context;
1385
+ if (hasExtensions) {
1386
+ // Extensions require persistent context in a temp directory
1387
+ const extPaths = options.extensions.join(',');
1388
+ const session = process.env.AGENT_BROWSER_SESSION || 'default';
1389
+ // Combine extension args with custom args and file access args
1390
+ const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
1391
+ const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
1392
+ context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `agent-browser-ext-${session}`), {
1393
+ headless: false,
1394
+ executablePath: options.executablePath,
1395
+ ...(chromeChannel && { channel: chromeChannel }),
1396
+ args: allArgs,
1397
+ viewport,
1398
+ extraHTTPHeaders,
1399
+ userAgent: contextUserAgent,
1400
+ ...(this.contextLocale && { locale: this.contextLocale }),
1401
+ ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
1402
+ ...(options.proxy && { proxy: options.proxy }),
1403
+ ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1404
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1405
+ });
1406
+ this.isPersistentContext = true;
1407
+ }
1408
+ else {
1409
+ // Regular ephemeral browser
1410
+ this.browser = await launcher.launch({
1411
+ headless: options.headless ?? false,
1412
+ executablePath: options.executablePath,
1413
+ ...(chromeChannel && { channel: chromeChannel }),
1414
+ args: baseArgs,
1415
+ });
1416
+ this.cdpEndpoint = null;
1417
+ if (stealthPolicy.enabled && browserType === 'chromium') {
1418
+ await applyBrowserLevelStealth(this.browser, {
1419
+ userAgent: contextUserAgent,
1420
+ });
1421
+ }
1422
+ if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
1423
+ const runtimeVersion = this.extractChromiumVersion(this.browser.version());
1424
+ if (runtimeVersion) {
1425
+ contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion);
1426
+ this.contextUserAgent = contextUserAgent;
1427
+ }
1428
+ }
1429
+ // Check for auto-load state file (supports encrypted files)
1430
+ let storageState = options.storageState ? options.storageState : undefined;
1431
+ if (!storageState && options.autoStateFilePath) {
1432
+ try {
1433
+ const fs = await import('fs');
1434
+ if (fs.existsSync(options.autoStateFilePath)) {
1435
+ const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
1436
+ const parsed = JSON.parse(content);
1437
+ if (isEncryptedPayload(parsed)) {
1438
+ const key = getEncryptionKey();
1439
+ if (key) {
1440
+ try {
1441
+ const decrypted = decryptData(parsed, key);
1442
+ storageState = JSON.parse(decrypted);
1443
+ if (process.env.AGENT_BROWSER_DEBUG === '1') {
1444
+ console.error(`[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`);
1445
+ }
1446
+ }
1447
+ catch (decryptErr) {
1448
+ const warning = 'Failed to decrypt state file - wrong encryption key? Starting fresh.';
1449
+ this.launchWarnings.push(warning);
1450
+ console.error(`[WARN] ${warning}`);
1451
+ if (process.env.AGENT_BROWSER_DEBUG === '1') {
1452
+ console.error(`[DEBUG] Decryption error:`, decryptErr);
1453
+ }
1454
+ }
1455
+ }
1456
+ else {
1457
+ const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
1458
+ this.launchWarnings.push(warning);
1459
+ console.error(`[WARN] ${warning}`);
1460
+ }
1461
+ }
1462
+ else {
1463
+ storageState = options.autoStateFilePath;
1464
+ if (process.env.AGENT_BROWSER_DEBUG === '1') {
1465
+ console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
1466
+ }
1467
+ }
1468
+ }
1469
+ }
1470
+ catch (err) {
1471
+ if (process.env.AGENT_BROWSER_DEBUG === '1') {
1472
+ console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
1473
+ }
1474
+ }
1475
+ }
1476
+ context = await this.browser.newContext({
1477
+ viewport,
1478
+ extraHTTPHeaders,
1479
+ userAgent: contextUserAgent,
1480
+ storageState,
1481
+ ...(this.contextLocale && { locale: this.contextLocale }),
1482
+ ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
1483
+ ...(options.proxy && { proxy: options.proxy }),
1484
+ ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1485
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1486
+ });
1487
+ }
1488
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1489
+ context.setDefaultTimeout(getDefaultTimeout());
1490
+ this.contexts.push(context);
1491
+ this.setupContextTracking(context);
1492
+ const page = context.pages()[0] ?? (await context.newPage());
1493
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1494
+ if (!this.pages.includes(page)) {
1495
+ this.pages.push(page);
1496
+ this.setupPageTracking(page);
1497
+ }
1498
+ this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
1499
+ }
1500
+ /**
1501
+ * Connect to a running browser via CDP (Chrome DevTools Protocol)
1502
+ * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
1503
+ */
1504
+ async connectViaCDP(cdpEndpoint, options) {
1505
+ this.stealthConnectionKind = 'cdp';
1506
+ if (!cdpEndpoint) {
1507
+ throw new Error('CDP endpoint is required for CDP connection');
1508
+ }
1509
+ // Determine the connection URL:
1510
+ // - If it starts with ws://, wss://, http://, or https://, use it directly
1511
+ // - If it's a numeric string (e.g., "9222"), treat as port for localhost
1512
+ // - Otherwise, treat it as a port number for localhost
1513
+ let cdpUrl;
1514
+ if (cdpEndpoint.startsWith('ws://') ||
1515
+ cdpEndpoint.startsWith('wss://') ||
1516
+ cdpEndpoint.startsWith('http://') ||
1517
+ cdpEndpoint.startsWith('https://')) {
1518
+ cdpUrl = cdpEndpoint;
1519
+ }
1520
+ else if (/^\d+$/.test(cdpEndpoint)) {
1521
+ // Numeric string - treat as port number (handles JSON serialization quirks)
1522
+ cdpUrl = `http://localhost:${cdpEndpoint}`;
1523
+ }
1524
+ else {
1525
+ // Unknown format - still try as port for backward compatibility
1526
+ cdpUrl = `http://localhost:${cdpEndpoint}`;
1527
+ }
1528
+ const browser = await chromium
1529
+ .connectOverCDP(cdpUrl, { timeout: options?.timeout })
1530
+ .catch(() => {
1531
+ throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
1532
+ (cdpUrl.includes('localhost')
1533
+ ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
1534
+ : 'Make sure the remote browser is accessible and the URL is correct.'));
1535
+ });
1536
+ // Validate and set up state, cleaning up browser connection if anything fails
1537
+ try {
1538
+ const contexts = browser.contexts();
1539
+ if (contexts.length === 0) {
1540
+ throw new Error('No browser context found. Make sure the app has an open window.');
1541
+ }
1542
+ let allPages = this.collectUsableCDPPages(contexts);
1543
+ if (allPages.length === 0) {
1544
+ // Some Chrome instances (especially with custom UI pages) expose only internal/transient
1545
+ // pages over CDP. Create a fresh page so commands always have a stable target.
1546
+ let fallbackPage = null;
1547
+ for (const context of contexts) {
1548
+ try {
1549
+ const page = await context.newPage();
1550
+ if (!fallbackPage) {
1551
+ fallbackPage = page;
1552
+ }
1553
+ if (this.isUsableCDPPage(page)) {
1554
+ fallbackPage = page;
1555
+ break;
1556
+ }
1557
+ }
1558
+ catch {
1559
+ // Try next context
1560
+ }
1561
+ }
1562
+ if (!fallbackPage) {
1563
+ throw new Error('No page found. Make sure the app has loaded content.');
1564
+ }
1565
+ allPages = [fallbackPage];
1566
+ }
1567
+ // All validation passed - commit state
1568
+ this.browser = browser;
1569
+ this.cdpEndpoint = cdpEndpoint;
1570
+ for (const context of contexts) {
1571
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1572
+ context.setDefaultTimeout(10000);
1573
+ this.contexts.push(context);
1574
+ this.setupContextTracking(context);
1575
+ }
1576
+ for (const page of allPages) {
1577
+ this.pages.push(page);
1578
+ this.setupPageTracking(page);
1579
+ }
1580
+ this.activePageIndex = 0;
1581
+ }
1582
+ catch (error) {
1583
+ // Clean up browser connection if validation or setup failed
1584
+ await browser.close().catch(() => { });
1585
+ throw error;
1586
+ }
1587
+ }
1588
+ /**
1589
+ * Get Chrome's default user data directory paths for the current platform.
1590
+ * Returns an array of candidate paths to check (stable, then beta/canary).
1591
+ */
1592
+ getChromeUserDataDirs() {
1593
+ const home = os.homedir();
1594
+ const platform = os.platform();
1595
+ if (platform === 'darwin') {
1596
+ return [
1597
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
1598
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
1599
+ path.join(home, 'Library', 'Application Support', 'Chromium'),
1600
+ ];
1601
+ }
1602
+ else if (platform === 'win32') {
1603
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
1604
+ return [
1605
+ path.join(localAppData, 'Google', 'Chrome', 'User Data'),
1606
+ path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
1607
+ path.join(localAppData, 'Chromium', 'User Data'),
1608
+ ];
1609
+ }
1610
+ else {
1611
+ // Linux
1612
+ return [
1613
+ path.join(home, '.config', 'google-chrome'),
1614
+ path.join(home, '.config', 'google-chrome-unstable'),
1615
+ path.join(home, '.config', 'chromium'),
1616
+ ];
1617
+ }
1618
+ }
1619
+ /**
1620
+ * Try to read the DevToolsActivePort file from a Chrome user data directory.
1621
+ * Returns { port, wsPath } if found, or null if not available.
1622
+ */
1623
+ readDevToolsActivePort(userDataDir) {
1624
+ const filePath = path.join(userDataDir, 'DevToolsActivePort');
1625
+ try {
1626
+ if (!existsSync(filePath))
1627
+ return null;
1628
+ const content = readFileSync(filePath, 'utf-8').trim();
1629
+ const lines = content.split('\n');
1630
+ if (lines.length < 2)
1631
+ return null;
1632
+ const port = parseInt(lines[0].trim(), 10);
1633
+ const wsPath = lines[1].trim();
1634
+ if (isNaN(port) || port <= 0 || port > 65535)
1635
+ return null;
1636
+ if (!wsPath)
1637
+ return null;
1638
+ return { port, wsPath };
1639
+ }
1640
+ catch {
1641
+ return null;
1642
+ }
1643
+ }
1644
+ /**
1645
+ * Try to discover a Chrome CDP endpoint by querying an HTTP debug port.
1646
+ * Returns the WebSocket debugger URL if available.
1647
+ */
1648
+ async probeDebugPort(port) {
1649
+ try {
1650
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
1651
+ signal: AbortSignal.timeout(2000),
1652
+ });
1653
+ if (!response.ok)
1654
+ return null;
1655
+ const data = (await response.json());
1656
+ return data.webSocketDebuggerUrl ?? null;
1657
+ }
1658
+ catch {
1659
+ return null;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Auto-discover and connect to a running Chrome/Chromium instance.
1664
+ *
1665
+ * Discovery strategy:
1666
+ * 1. Read DevToolsActivePort from Chrome's default user data directories
1667
+ * 2. If found, connect using the port and WebSocket path from that file
1668
+ * 3. If not found, probe common debugging ports (9222, 9229, 9333)
1669
+ * 4. If a port responds, connect via CDP
1670
+ */
1671
+ async autoConnectViaCDP() {
1672
+ // Strategy 1: Check DevToolsActivePort files
1673
+ const userDataDirs = this.getChromeUserDataDirs();
1674
+ for (const dir of userDataDirs) {
1675
+ const activePort = this.readDevToolsActivePort(dir);
1676
+ if (activePort) {
1677
+ // Try HTTP discovery first (works with --remote-debugging-port mode)
1678
+ const wsUrl = await this.probeDebugPort(activePort.port);
1679
+ if (wsUrl) {
1680
+ await this.connectViaCDP(wsUrl);
1681
+ return;
1682
+ }
1683
+ // HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
1684
+ // WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
1685
+ // path read directly from DevToolsActivePort.
1686
+ const directWsUrl = `ws://127.0.0.1:${activePort.port}${activePort.wsPath}`;
1687
+ try {
1688
+ if (process.env.AGENT_BROWSER_DEBUG === '1') {
1689
+ console.error(`[DEBUG] HTTP probe failed on port ${activePort.port}, ` +
1690
+ `attempting direct WebSocket connection to ${directWsUrl}`);
1691
+ }
1692
+ await this.connectViaCDP(directWsUrl, { timeout: 60_000 });
1693
+ return;
1694
+ }
1695
+ catch {
1696
+ // Direct WebSocket also failed, try next directory
1697
+ }
1698
+ }
1699
+ }
1700
+ // Strategy 2: Probe common debugging ports
1701
+ const commonPorts = [9222, 9229, 9333];
1702
+ for (const port of commonPorts) {
1703
+ const wsUrl = await this.probeDebugPort(port);
1704
+ if (wsUrl) {
1705
+ await this.connectViaCDP(wsUrl);
1706
+ return;
1707
+ }
1708
+ }
1709
+ // Nothing found
1710
+ const platform = os.platform();
1711
+ let hint;
1712
+ if (platform === 'darwin') {
1713
+ hint =
1714
+ 'Start Chrome with: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
1715
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1716
+ }
1717
+ else if (platform === 'win32') {
1718
+ hint =
1719
+ 'Start Chrome with: chrome.exe --remote-debugging-port=9222\n' +
1720
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1721
+ }
1722
+ else {
1723
+ hint =
1724
+ 'Start Chrome with: google-chrome --remote-debugging-port=9222\n' +
1725
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1726
+ }
1727
+ throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`);
1728
+ }
1729
+ /**
1730
+ * Set up console, error, and close tracking for a page
1731
+ */
1732
+ setupPageTracking(page) {
1733
+ if (this.colorScheme) {
1734
+ page.emulateMedia({ colorScheme: this.colorScheme }).catch(() => { });
1735
+ }
1736
+ page.on('console', (msg) => {
1737
+ this.consoleMessages.push({
1738
+ type: msg.type(),
1739
+ text: msg.text(),
1740
+ timestamp: Date.now(),
1741
+ });
1742
+ });
1743
+ page.on('pageerror', (error) => {
1744
+ this.pageErrors.push({
1745
+ message: error.message,
1746
+ timestamp: Date.now(),
1747
+ });
1748
+ });
1749
+ page.on('close', () => {
1750
+ const index = this.pages.indexOf(page);
1751
+ if (index !== -1) {
1752
+ this.pages.splice(index, 1);
1753
+ if (index < this.activePageIndex) {
1754
+ this.activePageIndex--;
1755
+ }
1756
+ if (this.activePageIndex >= this.pages.length) {
1757
+ this.activePageIndex = Math.max(0, this.pages.length - 1);
1758
+ }
1759
+ }
1760
+ });
1761
+ }
1762
+ /**
1763
+ * Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
1764
+ * This handles pages created externally (e.g., via target="_blank" links, window.open)
1765
+ */
1766
+ setupContextTracking(context) {
1767
+ context.on('page', (page) => {
1768
+ const pageUrl = this.getSafePageUrl(page);
1769
+ if (this.isIgnoredCDPPageUrl(pageUrl)) {
1770
+ return;
1771
+ }
1772
+ // Only add if not already tracked (avoids duplicates when newTab() creates pages)
1773
+ if (!this.pages.includes(page)) {
1774
+ this.pages.push(page);
1775
+ this.setupPageTracking(page);
1776
+ }
1777
+ // Auto-switch to the newly opened tab so subsequent commands target it.
1778
+ // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
1779
+ // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
1780
+ // stays in sync with the browser.
1781
+ const newIndex = this.pages.indexOf(page);
1782
+ if (newIndex !== -1 && newIndex !== this.activePageIndex) {
1783
+ this.activePageIndex = newIndex;
1784
+ // Invalidate CDP session since the active page changed
1785
+ this.invalidateCDPSession().catch(() => { });
1786
+ }
1787
+ });
1788
+ }
1789
+ /**
1790
+ * Create a new tab in the current context
1791
+ */
1792
+ async newTab() {
1793
+ if (!this.browser || this.contexts.length === 0) {
1794
+ throw new Error('Browser not launched');
1795
+ }
1796
+ // Invalidate CDP session since we're switching to a new page
1797
+ await this.invalidateCDPSession();
1798
+ const context = this.contexts[0]; // Use first context for tabs
1799
+ const page = await context.newPage();
1800
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1801
+ if (!this.pages.includes(page)) {
1802
+ this.pages.push(page);
1803
+ this.setupPageTracking(page);
1804
+ }
1805
+ this.activePageIndex = this.pages.length - 1;
1806
+ return { index: this.activePageIndex, total: this.pages.length };
1807
+ }
1808
+ /**
1809
+ * Create a new window (new context)
1810
+ */
1811
+ async newWindow(viewport) {
1812
+ if (!this.browser) {
1813
+ throw new Error('Browser not launched');
1814
+ }
1815
+ const context = await this.browser.newContext({
1816
+ viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
1817
+ ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
1818
+ ...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
1819
+ ...(this.contextLocale && { locale: this.contextLocale }),
1820
+ ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
1821
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1822
+ });
1823
+ await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1824
+ context.setDefaultTimeout(getDefaultTimeout());
1825
+ this.contexts.push(context);
1826
+ this.setupContextTracking(context);
1827
+ const page = await context.newPage();
1828
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1829
+ if (!this.pages.includes(page)) {
1830
+ this.pages.push(page);
1831
+ this.setupPageTracking(page);
1832
+ }
1833
+ this.activePageIndex = this.pages.length - 1;
1834
+ return { index: this.activePageIndex, total: this.pages.length };
1835
+ }
1836
+ /**
1837
+ * Invalidate the current CDP session (must be called before switching pages)
1838
+ * This ensures screencast and input injection work correctly after tab switch
1839
+ */
1840
+ async invalidateCDPSession() {
1841
+ // Stop screencast if active (it's tied to the current page's CDP session)
1842
+ if (this.screencastActive) {
1843
+ await this.stopScreencast();
1844
+ }
1845
+ // Detach and clear the CDP session
1846
+ if (this.cdpSession) {
1847
+ await this.cdpSession.detach().catch(() => { });
1848
+ this.cdpSession = null;
1849
+ }
1850
+ }
1851
+ /**
1852
+ * Switch to a specific tab/page by index
1853
+ */
1854
+ async switchTo(index) {
1855
+ if (index < 0 || index >= this.pages.length) {
1856
+ throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
1857
+ }
1858
+ // Invalidate CDP session before switching (it's page-specific)
1859
+ if (index !== this.activePageIndex) {
1860
+ await this.invalidateCDPSession();
1861
+ }
1862
+ this.activePageIndex = index;
1863
+ const page = this.pages[index];
1864
+ return {
1865
+ index: this.activePageIndex,
1866
+ url: page.url(),
1867
+ title: '', // Title requires async, will be fetched separately
1868
+ };
1869
+ }
1870
+ /**
1871
+ * Close a specific tab/page
1872
+ */
1873
+ async closeTab(index) {
1874
+ const targetIndex = index ?? this.activePageIndex;
1875
+ if (targetIndex < 0 || targetIndex >= this.pages.length) {
1876
+ throw new Error(`Invalid tab index: ${targetIndex}`);
1877
+ }
1878
+ if (this.pages.length === 1) {
1879
+ throw new Error('Cannot close the last tab. Use "close" to close the browser.');
1880
+ }
1881
+ // If closing the active tab, invalidate CDP session first
1882
+ if (targetIndex === this.activePageIndex) {
1883
+ await this.invalidateCDPSession();
1884
+ }
1885
+ const page = this.pages[targetIndex];
1886
+ await page.close();
1887
+ this.pages.splice(targetIndex, 1);
1888
+ // Adjust active index if needed
1889
+ if (this.activePageIndex >= this.pages.length) {
1890
+ this.activePageIndex = this.pages.length - 1;
1891
+ }
1892
+ else if (this.activePageIndex > targetIndex) {
1893
+ this.activePageIndex--;
1894
+ }
1895
+ return { closed: targetIndex, remaining: this.pages.length };
1896
+ }
1897
+ /**
1898
+ * List all tabs with their info
1899
+ */
1900
+ async listTabs() {
1901
+ const tabs = await Promise.all(this.pages.map(async (page, index) => ({
1902
+ index,
1903
+ url: page.url(),
1904
+ title: await page.title().catch(() => ''),
1905
+ active: index === this.activePageIndex,
1906
+ })));
1907
+ return tabs;
1908
+ }
1909
+ /**
1910
+ * Get or create a CDP session for the current page
1911
+ * Only works with Chromium-based browsers
1912
+ */
1913
+ async getCDPSession() {
1914
+ if (this.cdpSession) {
1915
+ return this.cdpSession;
1916
+ }
1917
+ const page = this.getPage();
1918
+ const context = page.context();
1919
+ // Create a new CDP session attached to the page
1920
+ this.cdpSession = await context.newCDPSession(page);
1921
+ return this.cdpSession;
1922
+ }
1923
+ /**
1924
+ * Check if screencast is currently active
1925
+ */
1926
+ isScreencasting() {
1927
+ return this.screencastActive;
1928
+ }
1929
+ /**
1930
+ * Start screencast - streams viewport frames via CDP
1931
+ * @param callback Function called for each frame
1932
+ * @param options Screencast options
1933
+ */
1934
+ async startScreencast(callback, options) {
1935
+ if (this.screencastActive) {
1936
+ throw new Error('Screencast already active');
1937
+ }
1938
+ const cdp = await this.getCDPSession();
1939
+ this.frameCallback = callback;
1940
+ this.screencastActive = true;
1941
+ // Create and store the frame handler so we can remove it later
1942
+ this.screencastFrameHandler = async (params) => {
1943
+ const frame = {
1944
+ data: params.data,
1945
+ metadata: params.metadata,
1946
+ sessionId: params.sessionId,
1947
+ };
1948
+ // Acknowledge the frame to receive the next one
1949
+ await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
1950
+ // Call the callback with the frame
1951
+ if (this.frameCallback) {
1952
+ this.frameCallback(frame);
1953
+ }
1954
+ };
1955
+ // Listen for screencast frames
1956
+ cdp.on('Page.screencastFrame', this.screencastFrameHandler);
1957
+ // Start the screencast
1958
+ await cdp.send('Page.startScreencast', {
1959
+ format: options?.format ?? 'jpeg',
1960
+ quality: options?.quality ?? 80,
1961
+ maxWidth: options?.maxWidth ?? 1280,
1962
+ maxHeight: options?.maxHeight ?? 720,
1963
+ everyNthFrame: options?.everyNthFrame ?? 1,
1964
+ });
1965
+ }
1966
+ /**
1967
+ * Stop screencast
1968
+ */
1969
+ async stopScreencast() {
1970
+ if (!this.screencastActive) {
1971
+ return;
1972
+ }
1973
+ try {
1974
+ const cdp = await this.getCDPSession();
1975
+ await cdp.send('Page.stopScreencast');
1976
+ // Remove the event listener to prevent accumulation
1977
+ if (this.screencastFrameHandler) {
1978
+ cdp.off('Page.screencastFrame', this.screencastFrameHandler);
1979
+ }
1980
+ }
1981
+ catch {
1982
+ // Ignore errors when stopping
1983
+ }
1984
+ this.screencastActive = false;
1985
+ this.frameCallback = null;
1986
+ this.screencastFrameHandler = null;
1987
+ }
1988
+ /**
1989
+ * Check if profiling is currently active
1990
+ */
1991
+ isProfilingActive() {
1992
+ return this.profilingActive;
1993
+ }
1994
+ /**
1995
+ * Start CDP profiling (Tracing)
1996
+ */
1997
+ async startProfiling(options) {
1998
+ if (this.profilingActive) {
1999
+ throw new Error('Profiling already active');
2000
+ }
2001
+ const cdp = await this.getCDPSession();
2002
+ const dataHandler = (params) => {
2003
+ if (params.value) {
2004
+ for (const evt of params.value) {
2005
+ if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) {
2006
+ if (!this.profileEventsDropped) {
2007
+ this.profileEventsDropped = true;
2008
+ console.warn(`Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data`);
2009
+ }
2010
+ return;
2011
+ }
2012
+ this.profileChunks.push(evt);
2013
+ }
2014
+ }
2015
+ };
2016
+ const completeHandler = () => {
2017
+ if (this.profileCompleteResolver) {
2018
+ this.profileCompleteResolver();
2019
+ }
2020
+ };
2021
+ cdp.on('Tracing.dataCollected', dataHandler);
2022
+ cdp.on('Tracing.tracingComplete', completeHandler);
2023
+ const categories = options?.categories ?? [
2024
+ 'devtools.timeline',
2025
+ 'disabled-by-default-devtools.timeline',
2026
+ 'disabled-by-default-devtools.timeline.frame',
2027
+ 'disabled-by-default-devtools.timeline.stack',
2028
+ 'v8.execute',
2029
+ 'disabled-by-default-v8.cpu_profiler',
2030
+ 'disabled-by-default-v8.cpu_profiler.hires',
2031
+ 'v8',
2032
+ 'disabled-by-default-v8.runtime_stats',
2033
+ 'blink',
2034
+ 'blink.user_timing',
2035
+ 'latencyInfo',
2036
+ 'renderer.scheduler',
2037
+ 'sequence_manager',
2038
+ 'toplevel',
2039
+ ];
2040
+ try {
2041
+ await cdp.send('Tracing.start', {
2042
+ traceConfig: {
2043
+ includedCategories: categories,
2044
+ enableSampling: true,
2045
+ },
2046
+ transferMode: 'ReportEvents',
2047
+ });
2048
+ }
2049
+ catch (error) {
2050
+ cdp.off('Tracing.dataCollected', dataHandler);
2051
+ cdp.off('Tracing.tracingComplete', completeHandler);
2052
+ throw error;
2053
+ }
2054
+ // Only commit state after the CDP call succeeds
2055
+ this.profilingActive = true;
2056
+ this.profileChunks = [];
2057
+ this.profileEventsDropped = false;
2058
+ this.profileDataHandler = dataHandler;
2059
+ this.profileCompleteHandler = completeHandler;
2060
+ }
2061
+ /**
2062
+ * Stop CDP profiling and save to file
2063
+ */
2064
+ async stopProfiling(outputPath) {
2065
+ if (!this.profilingActive) {
2066
+ throw new Error('No profiling session active');
2067
+ }
2068
+ const cdp = await this.getCDPSession();
2069
+ const TRACE_TIMEOUT_MS = 30_000;
2070
+ const completePromise = new Promise((resolve, reject) => {
2071
+ const timer = setTimeout(() => reject(new Error('Profiling data collection timed out')), TRACE_TIMEOUT_MS);
2072
+ this.profileCompleteResolver = () => {
2073
+ clearTimeout(timer);
2074
+ resolve();
2075
+ };
2076
+ });
2077
+ await cdp.send('Tracing.end');
2078
+ let chunks;
2079
+ try {
2080
+ await completePromise;
2081
+ chunks = this.profileChunks;
2082
+ }
2083
+ finally {
2084
+ if (this.profileDataHandler) {
2085
+ cdp.off('Tracing.dataCollected', this.profileDataHandler);
2086
+ }
2087
+ if (this.profileCompleteHandler) {
2088
+ cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
2089
+ }
2090
+ this.profilingActive = false;
2091
+ this.profileChunks = [];
2092
+ this.profileEventsDropped = false;
2093
+ this.profileCompleteResolver = null;
2094
+ this.profileDataHandler = null;
2095
+ this.profileCompleteHandler = null;
2096
+ }
2097
+ const clockDomain = process.platform === 'linux'
2098
+ ? 'LINUX_CLOCK_MONOTONIC'
2099
+ : process.platform === 'darwin'
2100
+ ? 'MAC_MACH_ABSOLUTE_TIME'
2101
+ : undefined;
2102
+ const traceData = {
2103
+ traceEvents: chunks,
2104
+ };
2105
+ if (clockDomain) {
2106
+ traceData.metadata = { 'clock-domain': clockDomain };
2107
+ }
2108
+ const dir = path.dirname(outputPath);
2109
+ await mkdir(dir, { recursive: true });
2110
+ await writeFile(outputPath, JSON.stringify(traceData));
2111
+ const eventCount = chunks.length;
2112
+ return { path: outputPath, eventCount };
2113
+ }
2114
+ /**
2115
+ * Inject a mouse event via CDP
2116
+ */
2117
+ async injectMouseEvent(params) {
2118
+ const cdp = await this.getCDPSession();
2119
+ const cdpButton = params.button === 'left'
2120
+ ? 'left'
2121
+ : params.button === 'right'
2122
+ ? 'right'
2123
+ : params.button === 'middle'
2124
+ ? 'middle'
2125
+ : 'none';
2126
+ await cdp.send('Input.dispatchMouseEvent', {
2127
+ type: params.type,
2128
+ x: params.x,
2129
+ y: params.y,
2130
+ button: cdpButton,
2131
+ clickCount: params.clickCount ?? 1,
2132
+ deltaX: params.deltaX ?? 0,
2133
+ deltaY: params.deltaY ?? 0,
2134
+ modifiers: params.modifiers ?? 0,
2135
+ });
2136
+ }
2137
+ /**
2138
+ * Inject a keyboard event via CDP
2139
+ */
2140
+ async injectKeyboardEvent(params) {
2141
+ const cdp = await this.getCDPSession();
2142
+ await cdp.send('Input.dispatchKeyEvent', {
2143
+ type: params.type,
2144
+ key: params.key,
2145
+ code: params.code,
2146
+ text: params.text,
2147
+ modifiers: params.modifiers ?? 0,
2148
+ });
2149
+ }
2150
+ /**
2151
+ * Inject touch event via CDP (for mobile emulation)
2152
+ */
2153
+ async injectTouchEvent(params) {
2154
+ const cdp = await this.getCDPSession();
2155
+ await cdp.send('Input.dispatchTouchEvent', {
2156
+ type: params.type,
2157
+ touchPoints: params.touchPoints.map((tp, i) => ({
2158
+ x: tp.x,
2159
+ y: tp.y,
2160
+ id: tp.id ?? i,
2161
+ })),
2162
+ modifiers: params.modifiers ?? 0,
2163
+ });
2164
+ }
2165
+ /**
2166
+ * Check if video recording is currently active
2167
+ */
2168
+ isRecording() {
2169
+ return this.recordingContext !== null;
2170
+ }
2171
+ /**
2172
+ * Start recording to a video file using Playwright's native video recording.
2173
+ * Creates a fresh browser context with video recording enabled.
2174
+ * Automatically captures current URL and transfers cookies/storage if no URL provided.
2175
+ *
2176
+ * @param outputPath - Path to the output video file (will be .webm)
2177
+ * @param url - Optional URL to navigate to (defaults to current page URL)
2178
+ */
2179
+ async startRecording(outputPath, url) {
2180
+ if (this.recordingContext) {
2181
+ throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
2182
+ }
2183
+ if (!this.browser) {
2184
+ throw new Error('Browser not launched. Call launch first.');
2185
+ }
2186
+ // Check if output file already exists
2187
+ if (existsSync(outputPath)) {
2188
+ throw new Error(`Output file already exists: ${outputPath}`);
2189
+ }
2190
+ // Validate output path is .webm (Playwright native format)
2191
+ if (!outputPath.endsWith('.webm')) {
2192
+ throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
2193
+ }
2194
+ // Auto-capture current URL if none provided
2195
+ const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
2196
+ const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
2197
+ if (!url && currentPage) {
2198
+ const currentUrl = currentPage.url();
2199
+ if (currentUrl && currentUrl !== 'about:blank') {
2200
+ url = currentUrl;
2201
+ }
2202
+ }
2203
+ // Capture state from current context (cookies + storage)
2204
+ let storageState;
2205
+ if (currentContext) {
2206
+ try {
2207
+ storageState = await currentContext.storageState();
2208
+ }
2209
+ catch {
2210
+ // Ignore errors - context might be closed or invalid
2211
+ }
2212
+ }
2213
+ // Create a temp directory for video recording
2214
+ const session = process.env.AGENT_BROWSER_SESSION || 'default';
2215
+ this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
2216
+ mkdirSync(this.recordingTempDir, { recursive: true });
2217
+ this.recordingOutputPath = outputPath;
2218
+ // Create a new context with video recording enabled and restored state
2219
+ const viewport = { width: 1280, height: 720 };
2220
+ this.recordingContext = await this.browser.newContext({
2221
+ viewport,
2222
+ recordVideo: {
2223
+ dir: this.recordingTempDir,
2224
+ size: viewport,
2225
+ },
2226
+ storageState,
2227
+ });
2228
+ this.recordingContext.setDefaultTimeout(10000);
2229
+ // Create a page in the recording context
2230
+ this.recordingPage = await this.recordingContext.newPage();
2231
+ // Add the recording context and page to our managed lists
2232
+ this.contexts.push(this.recordingContext);
2233
+ this.pages.push(this.recordingPage);
2234
+ this.activePageIndex = this.pages.length - 1;
2235
+ // Set up page tracking
2236
+ this.setupPageTracking(this.recordingPage);
2237
+ // Invalidate CDP session since we switched pages
2238
+ await this.invalidateCDPSession();
2239
+ // Navigate to URL if provided or captured
2240
+ if (url) {
2241
+ await this.recordingPage.goto(url, { waitUntil: 'load' });
2242
+ }
2243
+ }
2244
+ /**
2245
+ * Stop recording and save the video file
2246
+ * @returns Recording result with path
2247
+ */
2248
+ async stopRecording() {
2249
+ if (!this.recordingContext || !this.recordingPage) {
2250
+ return { path: '', frames: 0, error: 'No recording in progress' };
2251
+ }
2252
+ const outputPath = this.recordingOutputPath;
2253
+ try {
2254
+ // Get the video object before closing the page
2255
+ const video = this.recordingPage.video();
2256
+ // Remove recording page/context from our managed lists before closing
2257
+ const pageIndex = this.pages.indexOf(this.recordingPage);
2258
+ if (pageIndex !== -1) {
2259
+ this.pages.splice(pageIndex, 1);
2260
+ }
2261
+ const contextIndex = this.contexts.indexOf(this.recordingContext);
2262
+ if (contextIndex !== -1) {
2263
+ this.contexts.splice(contextIndex, 1);
2264
+ }
2265
+ // Close the page to finalize the video
2266
+ await this.recordingPage.close();
2267
+ // Save the video to the desired output path
2268
+ if (video) {
2269
+ await video.saveAs(outputPath);
2270
+ }
2271
+ // Clean up temp directory
2272
+ if (this.recordingTempDir) {
2273
+ rmSync(this.recordingTempDir, { recursive: true, force: true });
2274
+ }
2275
+ // Close the recording context
2276
+ await this.recordingContext.close();
2277
+ // Reset recording state
2278
+ this.recordingContext = null;
2279
+ this.recordingPage = null;
2280
+ this.recordingOutputPath = '';
2281
+ this.recordingTempDir = '';
2282
+ // Adjust active page index
2283
+ if (this.pages.length > 0) {
2284
+ this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
2285
+ }
2286
+ else {
2287
+ this.activePageIndex = 0;
2288
+ }
2289
+ // Invalidate CDP session since we may have switched pages
2290
+ await this.invalidateCDPSession();
2291
+ return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
2292
+ }
2293
+ catch (error) {
2294
+ // Clean up temp directory on error
2295
+ if (this.recordingTempDir) {
2296
+ rmSync(this.recordingTempDir, { recursive: true, force: true });
2297
+ }
2298
+ // Reset state on error
2299
+ this.recordingContext = null;
2300
+ this.recordingPage = null;
2301
+ this.recordingOutputPath = '';
2302
+ this.recordingTempDir = '';
2303
+ const message = error instanceof Error ? error.message : String(error);
2304
+ return { path: outputPath, frames: 0, error: message };
2305
+ }
2306
+ }
2307
+ /**
2308
+ * Restart recording - stops current recording (if any) and starts a new one.
2309
+ * Convenience method that combines stopRecording and startRecording.
2310
+ *
2311
+ * @param outputPath - Path to the output video file (must be .webm)
2312
+ * @param url - Optional URL to navigate to (defaults to current page URL)
2313
+ * @returns Result from stopping the previous recording (if any)
2314
+ */
2315
+ async restartRecording(outputPath, url) {
2316
+ let previousPath;
2317
+ let stopped = false;
2318
+ // Stop current recording if active
2319
+ if (this.recordingContext) {
2320
+ const result = await this.stopRecording();
2321
+ previousPath = result.path;
2322
+ stopped = true;
2323
+ }
2324
+ // Start new recording
2325
+ await this.startRecording(outputPath, url);
2326
+ return { previousPath, stopped };
2327
+ }
2328
+ /**
2329
+ * Close the browser and clean up
2330
+ */
2331
+ async close() {
2332
+ // Stop recording if active (saves video)
2333
+ if (this.recordingContext) {
2334
+ await this.stopRecording();
2335
+ }
2336
+ // Stop screencast if active
2337
+ if (this.screencastActive) {
2338
+ await this.stopScreencast();
2339
+ }
2340
+ // Clean up profiling state if active (without saving)
2341
+ if (this.profilingActive) {
2342
+ const cdp = this.cdpSession;
2343
+ if (cdp) {
2344
+ if (this.profileDataHandler) {
2345
+ cdp.off('Tracing.dataCollected', this.profileDataHandler);
2346
+ }
2347
+ if (this.profileCompleteHandler) {
2348
+ cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
2349
+ }
2350
+ await cdp.send('Tracing.end').catch(() => { });
2351
+ }
2352
+ this.profilingActive = false;
2353
+ this.profileChunks = [];
2354
+ this.profileEventsDropped = false;
2355
+ this.profileCompleteResolver = null;
2356
+ this.profileDataHandler = null;
2357
+ this.profileCompleteHandler = null;
2358
+ }
2359
+ // Clean up CDP session
2360
+ if (this.cdpSession) {
2361
+ await this.cdpSession.detach().catch(() => { });
2362
+ this.cdpSession = null;
2363
+ }
2364
+ if (this.browserbaseSessionId && this.browserbaseApiKey) {
2365
+ await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch((error) => {
2366
+ console.error('Failed to close Browserbase session:', error);
2367
+ });
2368
+ this.browser = null;
2369
+ }
2370
+ else if (this.browserUseSessionId && this.browserUseApiKey) {
2371
+ await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch((error) => {
2372
+ console.error('Failed to close Browser Use session:', error);
2373
+ });
2374
+ this.browser = null;
2375
+ }
2376
+ else if (this.kernelSessionId && this.kernelApiKey) {
2377
+ await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => {
2378
+ console.error('Failed to close Kernel session:', error);
2379
+ });
2380
+ this.browser = null;
2381
+ }
2382
+ else if (this.cdpEndpoint !== null) {
2383
+ // CDP: only disconnect, don't close external app's pages
2384
+ if (this.browser) {
2385
+ await this.browser.close().catch(() => { });
2386
+ this.browser = null;
2387
+ }
2388
+ }
2389
+ else {
2390
+ // Regular browser: close everything
2391
+ for (const page of this.pages) {
2392
+ await page.close().catch(() => { });
2393
+ }
2394
+ for (const context of this.contexts) {
2395
+ await context.close().catch(() => { });
2396
+ }
2397
+ if (this.browser) {
2398
+ await this.browser.close().catch(() => { });
2399
+ this.browser = null;
2400
+ }
2401
+ }
2402
+ this.pages = [];
2403
+ this.contexts = [];
2404
+ this.cdpEndpoint = null;
2405
+ this.browserbaseSessionId = null;
2406
+ this.browserbaseApiKey = null;
2407
+ this.browserUseSessionId = null;
2408
+ this.browserUseApiKey = null;
2409
+ this.kernelSessionId = null;
2410
+ this.kernelApiKey = null;
2411
+ this.isPersistentContext = false;
2412
+ this.activePageIndex = 0;
2413
+ this.colorScheme = null;
2414
+ this.stealthEnabled = true;
2415
+ this.stealthConnectionKind = 'local';
2416
+ this.contextLocale = undefined;
2417
+ this.contextTimezoneId = undefined;
2418
+ this.contextHeaders = undefined;
2419
+ this.contextUserAgent = undefined;
2420
+ this.refMap = {};
2421
+ this.lastSnapshot = '';
2422
+ this.frameCallback = null;
2423
+ }
2424
+ }
2425
+ //# sourceMappingURL=browser.js.map