agent-browser-stealth 0.17.0-fork.2 → 0.24.0-fork.2

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 (122) hide show
  1. package/README.md +1256 -240
  2. package/bin/agent-browser-darwin-arm64 +0 -0
  3. package/bin/agent-browser-darwin-x64 +0 -0
  4. package/bin/agent-browser-linux-arm64 +0 -0
  5. package/bin/agent-browser-linux-x64 +0 -0
  6. package/bin/agent-browser-win32-x64.exe +0 -0
  7. package/bin/agent-browser.js +13 -2
  8. package/extensions/tab-group-cdp/content-script.js +425 -0
  9. package/extensions/tab-group-cdp/icons/icon.svg +7 -0
  10. package/extensions/tab-group-cdp/manifest.json +34 -0
  11. package/extensions/tab-group-cdp/page-bridge.js +133 -0
  12. package/extensions/tab-group-cdp/service-worker.js +2249 -0
  13. package/extensions/tab-group-cdp/sidepanel.css +258 -0
  14. package/extensions/tab-group-cdp/sidepanel.html +28 -0
  15. package/extensions/tab-group-cdp/sidepanel.js +1225 -0
  16. package/package.json +17 -69
  17. package/scripts/build-all-platforms.sh +6 -0
  18. package/scripts/check-version-sync.js +14 -2
  19. package/scripts/copy-native.js +8 -50
  20. package/scripts/postinstall.js +149 -165
  21. package/scripts/windows-debug/provision.sh +220 -0
  22. package/scripts/windows-debug/run.sh +92 -0
  23. package/scripts/windows-debug/start.sh +43 -0
  24. package/scripts/windows-debug/stop.sh +28 -0
  25. package/scripts/windows-debug/sync.sh +27 -0
  26. package/skills/agent-browser/SKILL.md +256 -159
  27. package/skills/agent-browser/references/authentication.md +101 -0
  28. package/skills/agent-browser/references/commands.md +34 -2
  29. package/skills/agent-browser/references/snapshot-refs.md +25 -0
  30. package/skills/agentcore/SKILL.md +115 -0
  31. package/skills/dogfood/SKILL.md +4 -2
  32. package/skills/electron/SKILL.md +26 -2
  33. package/skills/slack/SKILL.md +0 -9
  34. package/skills/slack/references/slack-tasks.md +2 -8
  35. package/skills/vercel-sandbox/SKILL.md +280 -0
  36. package/bin/agent-browser-local +0 -0
  37. package/bin/agent-browser-stealth +0 -0
  38. package/bin/agent-browser-stealth.d +0 -1
  39. package/dist/action-policy.d.ts +0 -14
  40. package/dist/action-policy.d.ts.map +0 -1
  41. package/dist/action-policy.js +0 -253
  42. package/dist/action-policy.js.map +0 -1
  43. package/dist/actions.d.ts +0 -21
  44. package/dist/actions.d.ts.map +0 -1
  45. package/dist/actions.js +0 -2139
  46. package/dist/actions.js.map +0 -1
  47. package/dist/auth-cli.d.ts +0 -2
  48. package/dist/auth-cli.d.ts.map +0 -1
  49. package/dist/auth-cli.js +0 -97
  50. package/dist/auth-cli.js.map +0 -1
  51. package/dist/auth-vault.d.ts +0 -36
  52. package/dist/auth-vault.d.ts.map +0 -1
  53. package/dist/auth-vault.js +0 -125
  54. package/dist/auth-vault.js.map +0 -1
  55. package/dist/browser.d.ts +0 -665
  56. package/dist/browser.d.ts.map +0 -1
  57. package/dist/browser.js +0 -3210
  58. package/dist/browser.js.map +0 -1
  59. package/dist/confirmation.d.ts +0 -8
  60. package/dist/confirmation.d.ts.map +0 -1
  61. package/dist/confirmation.js +0 -30
  62. package/dist/confirmation.js.map +0 -1
  63. package/dist/daemon.d.ts +0 -78
  64. package/dist/daemon.d.ts.map +0 -1
  65. package/dist/daemon.js +0 -744
  66. package/dist/daemon.js.map +0 -1
  67. package/dist/diff.d.ts +0 -18
  68. package/dist/diff.d.ts.map +0 -1
  69. package/dist/diff.js +0 -271
  70. package/dist/diff.js.map +0 -1
  71. package/dist/domain-filter.d.ts +0 -28
  72. package/dist/domain-filter.d.ts.map +0 -1
  73. package/dist/domain-filter.js +0 -149
  74. package/dist/domain-filter.js.map +0 -1
  75. package/dist/encryption.d.ts +0 -73
  76. package/dist/encryption.d.ts.map +0 -1
  77. package/dist/encryption.js +0 -171
  78. package/dist/encryption.js.map +0 -1
  79. package/dist/ios-actions.d.ts +0 -11
  80. package/dist/ios-actions.d.ts.map +0 -1
  81. package/dist/ios-actions.js +0 -228
  82. package/dist/ios-actions.js.map +0 -1
  83. package/dist/ios-manager.d.ts +0 -266
  84. package/dist/ios-manager.d.ts.map +0 -1
  85. package/dist/ios-manager.js +0 -1073
  86. package/dist/ios-manager.js.map +0 -1
  87. package/dist/protocol.d.ts +0 -26
  88. package/dist/protocol.d.ts.map +0 -1
  89. package/dist/protocol.js +0 -990
  90. package/dist/protocol.js.map +0 -1
  91. package/dist/snapshot.d.ts +0 -67
  92. package/dist/snapshot.d.ts.map +0 -1
  93. package/dist/snapshot.js +0 -514
  94. package/dist/snapshot.js.map +0 -1
  95. package/dist/state-utils.d.ts +0 -77
  96. package/dist/state-utils.d.ts.map +0 -1
  97. package/dist/state-utils.js +0 -178
  98. package/dist/state-utils.js.map +0 -1
  99. package/dist/stealth.d.ts +0 -41
  100. package/dist/stealth.d.ts.map +0 -1
  101. package/dist/stealth.js +0 -1743
  102. package/dist/stealth.js.map +0 -1
  103. package/dist/stream-server.d.ts +0 -117
  104. package/dist/stream-server.d.ts.map +0 -1
  105. package/dist/stream-server.js +0 -309
  106. package/dist/stream-server.js.map +0 -1
  107. package/dist/types.d.ts +0 -973
  108. package/dist/types.d.ts.map +0 -1
  109. package/dist/types.js +0 -2
  110. package/dist/types.js.map +0 -1
  111. package/scripts/check-creepjs-headless.js +0 -137
  112. package/scripts/check-daemon-pid-recovery.js +0 -148
  113. package/scripts/check-sannysoft-webdriver.js +0 -112
  114. package/scripts/check-stealth-regression.js +0 -199
  115. package/scripts/check-turnstile-testkey.ts +0 -125
  116. package/scripts/clawhub-sync.sh +0 -27
  117. package/scripts/sync-upstream.sh +0 -142
  118. package/scripts/verify-bundled-binaries.js +0 -71
  119. package/scripts/verify-native-version.js +0 -48
  120. package/scripts/verify-packed-host-binary.js +0 -88
  121. package/scripts/verify-registry-host-binary.js +0 -120
  122. package/skills/agent-browser-stealth/SKILL.md +0 -127
package/dist/browser.js DELETED
@@ -1,3210 +0,0 @@
1
- import { chromium, firefox, webkit, devices, } from 'playwright-core';
2
- import { spawn, spawnSync } from 'node:child_process';
3
- import path from 'node:path';
4
- import os from 'node:os';
5
- import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, statSync } from 'node:fs';
6
- import { writeFile, mkdir } from 'node:fs/promises';
7
- import { getEnhancedSnapshot, parseRef } from './snapshot.js';
8
- import { safeHeaderMerge } from './state-utils.js';
9
- import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
10
- import { getEncryptionKey, isEncryptedPayload, decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js';
11
- import { STEALTH_CHROMIUM_ARGS, applyStealthScripts, applyBrowserLevelStealth, wrapCDPSessionSourceUrlSanitizer, } from './stealth.js';
12
- /**
13
- * Returns the default Playwright timeout in milliseconds for standard operations.
14
- * Can be overridden via the AGENT_BROWSER_DEFAULT_TIMEOUT environment variable.
15
- * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure
16
- * Playwright errors are returned before the CLI gives up with EAGAIN.
17
- * CDP and recording contexts use a shorter fixed timeout (10s) and are not affected.
18
- */
19
- export function getDefaultTimeout() {
20
- const envValue = process.env.AGENT_BROWSER_DEFAULT_TIMEOUT;
21
- if (envValue) {
22
- const parsed = parseInt(envValue, 10);
23
- if (!isNaN(parsed) && parsed >= 1000) {
24
- return parsed;
25
- }
26
- }
27
- return 25000;
28
- }
29
- const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
30
- const MANAGED_CDP_PORT = 9333;
31
- const MANAGED_CDP_START_TIMEOUT_MS = 20_000;
32
- const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
33
- const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
34
- const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
35
- const TAB_GROUP_RESPONSE_MESSAGE_TYPE = 'AB_TAB_GROUP_RESPONSE';
36
- const TAB_GROUP_REQUEST_TIMEOUT_MS = 400;
37
- function getManagedCdpProfileDir() {
38
- return path.join(os.homedir(), '.agent-browser', 'chrome-bot-profile');
39
- }
40
- function cleanupManagedCdpProfileLocks(profileDir) {
41
- rmSync(path.join(profileDir, 'DevToolsActivePort'), { force: true });
42
- try {
43
- for (const entry of readdirSync(profileDir)) {
44
- if (entry.startsWith('Singleton')) {
45
- rmSync(path.join(profileDir, entry), { force: true, recursive: true });
46
- }
47
- }
48
- }
49
- catch {
50
- // Best-effort cleanup for stale lock files.
51
- }
52
- }
53
- function findManagedChromeExecutable() {
54
- const configured = process.env.AGENT_BROWSER_EXECUTABLE_PATH;
55
- if (configured && existsSync(configured)) {
56
- return configured;
57
- }
58
- const platform = os.platform();
59
- const candidates = platform === 'darwin'
60
- ? [
61
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
62
- '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
63
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
64
- ]
65
- : platform === 'win32'
66
- ? [
67
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
68
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
69
- ]
70
- : [];
71
- for (const candidate of candidates) {
72
- if (existsSync(candidate)) {
73
- return candidate;
74
- }
75
- }
76
- if (platform !== 'win32') {
77
- for (const name of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
78
- const result = spawnSync('which', [name], { encoding: 'utf8' });
79
- if (result.status === 0) {
80
- const resolved = result.stdout.trim();
81
- if (resolved.length > 0) {
82
- return resolved;
83
- }
84
- }
85
- }
86
- }
87
- throw new Error('Chrome not found. Install Chrome or set AGENT_BROWSER_EXECUTABLE_PATH.');
88
- }
89
- /**
90
- * Manages the Playwright browser lifecycle with multiple tabs/windows
91
- */
92
- export class BrowserManager {
93
- browser = null;
94
- cdpEndpoint = null; // stores port number or full URL
95
- isPersistentContext = false;
96
- browserbaseSessionId = null;
97
- browserbaseApiKey = null;
98
- browserUseSessionId = null;
99
- browserUseApiKey = null;
100
- kernelSessionId = null;
101
- kernelApiKey = null;
102
- contexts = [];
103
- pages = [];
104
- activePageIndex = 0;
105
- activeFrame = null;
106
- dialogHandler = null;
107
- trackedRequests = [];
108
- routes = new Map();
109
- consoleMessages = [];
110
- pageErrors = [];
111
- isRecordingHar = false;
112
- refMap = {};
113
- lastSnapshot = '';
114
- scopedHeaderRoutes = new Map();
115
- colorScheme = null;
116
- stealthEnabled = true;
117
- stealthConnectionKind = 'local';
118
- contextLocale = undefined;
119
- contextTimezoneId = undefined;
120
- contextHeaders = undefined;
121
- contextUserAgent = undefined;
122
- allowWebGLContextFallback = false;
123
- downloadPath = null;
124
- allowedDomains = [];
125
- tabGroupIntent = null;
126
- tabGroupCapabilityBySession = new Map();
127
- tabGroupInFlight = new WeakSet();
128
- isManagedCdpEndpoint(cdpEndpoint) {
129
- return (cdpEndpoint === String(MANAGED_CDP_PORT) ||
130
- cdpEndpoint === `http://localhost:${MANAGED_CDP_PORT}` ||
131
- cdpEndpoint === `http://127.0.0.1:${MANAGED_CDP_PORT}` ||
132
- cdpEndpoint === `ws://127.0.0.1:${MANAGED_CDP_PORT}` ||
133
- cdpEndpoint.includes(`127.0.0.1:${MANAGED_CDP_PORT}/devtools/browser/`) ||
134
- cdpEndpoint.includes(`localhost:${MANAGED_CDP_PORT}/devtools/browser/`));
135
- }
136
- async ensureManagedCdpBrowser() {
137
- if (await this.probeDebugPort(MANAGED_CDP_PORT)) {
138
- return;
139
- }
140
- const profileDir = getManagedCdpProfileDir();
141
- mkdirSync(profileDir, { recursive: true });
142
- cleanupManagedCdpProfileLocks(profileDir);
143
- const executablePath = findManagedChromeExecutable();
144
- const headed = process.env.AGENT_BROWSER_HEADED === '1' || process.env.AGENT_BROWSER_HEADED === 'true';
145
- const args = [
146
- '--remote-debugging-address=127.0.0.1',
147
- `--remote-debugging-port=${MANAGED_CDP_PORT}`,
148
- `--user-data-dir=${profileDir}`,
149
- '--no-first-run',
150
- '--no-default-browser-check',
151
- ];
152
- if (!headed) {
153
- args.push('--headless=new', '--window-size=1280,720');
154
- }
155
- const child = spawn(executablePath, args, {
156
- detached: true,
157
- stdio: 'ignore',
158
- });
159
- child.unref();
160
- const deadline = Date.now() + MANAGED_CDP_START_TIMEOUT_MS;
161
- while (Date.now() < deadline) {
162
- const wsUrl = await this.probeDebugPort(MANAGED_CDP_PORT);
163
- if (wsUrl) {
164
- return;
165
- }
166
- if (child.exitCode !== null) {
167
- throw new Error(`Managed Chrome exited before opening CDP port ${MANAGED_CDP_PORT}`);
168
- }
169
- await new Promise((resolve) => setTimeout(resolve, 250));
170
- }
171
- throw new Error(`Timed out waiting for managed Chrome on localhost:${MANAGED_CDP_PORT}`);
172
- }
173
- /**
174
- * Set the persistent color scheme preference.
175
- * Applied automatically to all new pages and contexts.
176
- */
177
- setColorScheme(scheme) {
178
- this.colorScheme = scheme;
179
- }
180
- /**
181
- * Centralized stealth policy so launch mode semantics stay consistent.
182
- * Local Chromium gets args + init scripts; CDP/providers get init scripts only.
183
- */
184
- getStealthPolicy(browserType = 'chromium') {
185
- const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium';
186
- const applyInitScripts = true;
187
- const providerManaged = this.stealthConnectionKind === 'provider-kernel';
188
- const capabilities = [];
189
- if (applyChromiumArgs) {
190
- capabilities.push('chromium-launch-args');
191
- }
192
- if (applyInitScripts) {
193
- capabilities.push('context-init-scripts');
194
- }
195
- if (providerManaged) {
196
- capabilities.push('provider-managed-stealth');
197
- }
198
- return {
199
- enabled: true,
200
- connectionKind: this.stealthConnectionKind,
201
- applyChromiumArgs,
202
- applyInitScripts,
203
- providerManaged,
204
- capabilities,
205
- };
206
- }
207
- logStealthPolicy(phase, browserType = 'chromium') {
208
- if (process.env.AGENT_BROWSER_DEBUG !== '1')
209
- return;
210
- const policy = this.getStealthPolicy(browserType);
211
- const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none';
212
- console.error(`[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}`);
213
- }
214
- getStealthStatus(browserType = 'chromium') {
215
- const policy = this.getStealthPolicy(browserType);
216
- return {
217
- enabled: policy.enabled,
218
- connectionKind: policy.connectionKind,
219
- capabilities: policy.capabilities,
220
- providerManaged: policy.providerManaged,
221
- };
222
- }
223
- normalizeLocaleTag(locale) {
224
- if (!locale)
225
- return undefined;
226
- const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-');
227
- if (!cleaned)
228
- return undefined;
229
- try {
230
- return new Intl.Locale(cleaned).toString();
231
- }
232
- catch {
233
- return undefined;
234
- }
235
- }
236
- buildAcceptLanguageHeader(locale) {
237
- const baseLanguage = locale.split('-')[0];
238
- if (!baseLanguage || baseLanguage === locale) {
239
- return `${locale};q=0.9`;
240
- }
241
- return `${locale},${baseLanguage};q=0.9`;
242
- }
243
- getHeaderValue(headers, name) {
244
- if (!headers)
245
- return undefined;
246
- const target = name.toLowerCase();
247
- for (const [key, value] of Object.entries(headers)) {
248
- if (key.toLowerCase() === target)
249
- return value;
250
- }
251
- return undefined;
252
- }
253
- // TLD -> {locale, timezone} mapping for automatic region consistency
254
- static TLD_REGION_MAP = {
255
- tw: { locale: 'zh-TW', timezone: 'Asia/Taipei' },
256
- cn: { locale: 'zh-CN', timezone: 'Asia/Shanghai' },
257
- hk: { locale: 'zh-HK', timezone: 'Asia/Hong_Kong' },
258
- jp: { locale: 'ja-JP', timezone: 'Asia/Tokyo' },
259
- kr: { locale: 'ko-KR', timezone: 'Asia/Seoul' },
260
- th: { locale: 'th-TH', timezone: 'Asia/Bangkok' },
261
- vn: { locale: 'vi-VN', timezone: 'Asia/Ho_Chi_Minh' },
262
- sg: { locale: 'en-SG', timezone: 'Asia/Singapore' },
263
- my: { locale: 'ms-MY', timezone: 'Asia/Kuala_Lumpur' },
264
- id: { locale: 'id-ID', timezone: 'Asia/Jakarta' },
265
- ph: { locale: 'en-PH', timezone: 'Asia/Manila' },
266
- br: { locale: 'pt-BR', timezone: 'America/Sao_Paulo' },
267
- mx: { locale: 'es-MX', timezone: 'America/Mexico_City' },
268
- ar: { locale: 'es-AR', timezone: 'America/Argentina/Buenos_Aires' },
269
- de: { locale: 'de-DE', timezone: 'Europe/Berlin' },
270
- fr: { locale: 'fr-FR', timezone: 'Europe/Paris' },
271
- uk: { locale: 'en-GB', timezone: 'Europe/London' },
272
- ru: { locale: 'ru-RU', timezone: 'Europe/Moscow' },
273
- in: { locale: 'hi-IN', timezone: 'Asia/Kolkata' },
274
- au: { locale: 'en-AU', timezone: 'Australia/Sydney' },
275
- };
276
- // Target URL set during navigation, used for region auto-detection
277
- targetUrl = undefined;
278
- /**
279
- * Set the target URL for region auto-detection.
280
- * Called from navigate/open commands so locale/timezone can adapt.
281
- * Applies CDP overrides to align locale/timezone with the target site's region.
282
- */
283
- async setTargetUrl(url) {
284
- this.targetUrl = url;
285
- const region = this.getRegionFromUrl(url);
286
- if (!region)
287
- return;
288
- // Skip if user has explicitly set locale/timezone via env
289
- const envLocale = process.env.AGENT_BROWSER_LOCALE;
290
- const envTimezone = process.env.AGENT_BROWSER_TIMEZONE || process.env.TZ;
291
- try {
292
- const page = this.getPage();
293
- const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page));
294
- if (!envTimezone) {
295
- await cdp
296
- .send('Emulation.setTimezoneOverride', { timezoneId: region.timezone })
297
- .catch(() => { });
298
- }
299
- if (!envLocale) {
300
- await cdp.send('Emulation.setLocaleOverride', { locale: region.locale }).catch(() => { });
301
- // Update Accept-Language header to match
302
- const langHeader = this.buildAcceptLanguageHeader(region.locale);
303
- const context = page.context();
304
- const currentHeaders = this.contextHeaders ?? {};
305
- await context.setExtraHTTPHeaders({ ...currentHeaders, 'Accept-Language': langHeader });
306
- }
307
- await cdp.detach().catch(() => { });
308
- }
309
- catch {
310
- // CDP not available (non-Chromium), skip dynamic override
311
- }
312
- }
313
- getRegionFromUrl(url) {
314
- if (!url)
315
- return undefined;
316
- try {
317
- const hostname = new URL(url).hostname;
318
- const parts = hostname.split('.');
319
- const tld = parts[parts.length - 1];
320
- // Check compound TLDs like co.th, com.tw, co.id
321
- const secondLevel = parts.length >= 2 ? parts[parts.length - 2] : '';
322
- const compoundTld = `${secondLevel}.${tld}`;
323
- // Try compound first (e.g., "co.th" -> "th", "com.tw" -> "tw")
324
- const compoundMatch = BrowserManager.TLD_REGION_MAP[tld];
325
- if (compoundMatch &&
326
- (secondLevel === 'co' ||
327
- secondLevel === 'com' ||
328
- secondLevel === 'or' ||
329
- secondLevel === 'org')) {
330
- return compoundMatch;
331
- }
332
- // Then direct TLD
333
- if (BrowserManager.TLD_REGION_MAP[tld]) {
334
- return BrowserManager.TLD_REGION_MAP[tld];
335
- }
336
- return undefined;
337
- }
338
- catch {
339
- return undefined;
340
- }
341
- }
342
- resolveStealthLocale(headers) {
343
- const headerLocale = this.getHeaderValue(headers, 'accept-language');
344
- const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale);
345
- if (normalizedHeaderLocale)
346
- return normalizedHeaderLocale;
347
- // Explicit env var takes priority
348
- const envLocale = this.normalizeLocaleTag(process.env.AGENT_BROWSER_LOCALE);
349
- if (envLocale)
350
- return envLocale;
351
- // Auto-detect from target URL TLD
352
- const urlRegion = this.getRegionFromUrl(this.targetUrl);
353
- if (urlRegion)
354
- return urlRegion.locale;
355
- const candidates = [
356
- process.env.LC_ALL,
357
- process.env.LC_MESSAGES,
358
- process.env.LANG,
359
- Intl.DateTimeFormat().resolvedOptions().locale,
360
- ];
361
- for (const candidate of candidates) {
362
- const normalized = this.normalizeLocaleTag(candidate);
363
- if (normalized)
364
- return normalized;
365
- }
366
- return 'en-US';
367
- }
368
- resolveStealthTimezoneId() {
369
- // Explicit env var takes priority
370
- const envTz = process.env.AGENT_BROWSER_TIMEZONE?.trim() || process.env.TZ?.trim();
371
- if (envTz && (envTz === 'UTC' || envTz.includes('/')))
372
- return envTz;
373
- // Auto-detect from target URL TLD
374
- const urlRegion = this.getRegionFromUrl(this.targetUrl);
375
- if (urlRegion)
376
- return urlRegion.timezone;
377
- const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
378
- if (systemTz)
379
- return systemTz;
380
- return undefined;
381
- }
382
- buildStealthContextDefaults(policy, headers) {
383
- if (!policy.enabled) {
384
- return { extraHTTPHeaders: headers };
385
- }
386
- const locale = this.resolveStealthLocale(headers);
387
- const timezoneId = this.resolveStealthTimezoneId();
388
- const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined;
389
- const extraHTTPHeaders = hasAcceptLanguage
390
- ? headers
391
- : {
392
- ...(headers ?? {}),
393
- 'Accept-Language': this.buildAcceptLanguageHeader(locale),
394
- };
395
- return {
396
- locale,
397
- timezoneId,
398
- extraHTTPHeaders,
399
- };
400
- }
401
- extractChromiumVersion(versionText) {
402
- const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/);
403
- return match?.[1];
404
- }
405
- buildStealthChromiumUserAgent(chromeVersion) {
406
- const platform = os.platform();
407
- let osToken = 'X11; Linux x86_64';
408
- if (platform === 'darwin') {
409
- osToken = 'Macintosh; Intel Mac OS X 10_15_7';
410
- }
411
- else if (platform === 'win32') {
412
- osToken = 'Windows NT 10.0; Win64; x64';
413
- }
414
- return (`Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` +
415
- `(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`);
416
- }
417
- getStealthUserAgentVersionHint() {
418
- const deviceUA = devices['Desktop Chrome']?.userAgent;
419
- if (!deviceUA)
420
- return undefined;
421
- return this.extractChromiumVersion(deviceUA);
422
- }
423
- /**
424
- * Apply context init-script stealth patches when policy allows.
425
- */
426
- async applyStealthIfEnabled(context, options = {}) {
427
- const policy = this.getStealthPolicy();
428
- if (!policy.applyInitScripts)
429
- return;
430
- await applyStealthScripts(context, {
431
- ...options,
432
- userAgent: this.contextUserAgent,
433
- allowWebGLContextFallback: this.allowWebGLContextFallback,
434
- });
435
- this.logStealthPolicy('init-script applied');
436
- }
437
- async probeNativeWebGL(page) {
438
- try {
439
- return await page.evaluate(() => {
440
- const doc = globalThis.document;
441
- if (!doc || typeof doc.createElement !== 'function')
442
- return null;
443
- const canvas = doc.createElement('canvas');
444
- const strict = canvas.getContext('webgl', { failIfMajorPerformanceCaveat: true }) ||
445
- canvas.getContext('experimental-webgl', { failIfMajorPerformanceCaveat: true }) ||
446
- canvas.getContext('webgl2', { failIfMajorPerformanceCaveat: true });
447
- const loose = canvas.getContext('webgl') ||
448
- canvas.getContext('experimental-webgl') ||
449
- canvas.getContext('webgl2');
450
- return { strict: !!strict, loose: !!loose };
451
- });
452
- }
453
- catch {
454
- return null;
455
- }
456
- }
457
- async configureWebGLFallbackFromPage(page, source) {
458
- const probe = await this.probeNativeWebGL(page);
459
- this.allowWebGLContextFallback = !!probe && probe.strict === false;
460
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
461
- console.error(`[DEBUG] WebGL probe (${source}): strict=${String(probe?.strict)} loose=${String(probe?.loose)} fallback=${this.allowWebGLContextFallback}`);
462
- }
463
- if (probe && probe.strict === false) {
464
- this.launchWarnings.push(`Strict WebGL context is unavailable on ${source} (often caused by GPU-disabled CDP browsers, e.g. --use-gl=disabled); enabling compatibility fallback context for fingerprint probes.`);
465
- }
466
- }
467
- // CDP session for screencast and input injection
468
- cdpSession = null;
469
- screencastActive = false;
470
- screencastSessionId = 0;
471
- frameCallback = null;
472
- screencastFrameHandler = null;
473
- // Video recording (Playwright native)
474
- recordingContext = null;
475
- recordingPage = null;
476
- recordingOutputPath = '';
477
- recordingTempDir = '';
478
- launchWarnings = [];
479
- /**
480
- * Get and clear launch warnings (e.g., decryption failures)
481
- */
482
- getAndClearWarnings() {
483
- const warnings = this.launchWarnings;
484
- this.launchWarnings = [];
485
- return warnings;
486
- }
487
- normalizeTabGroupName(name) {
488
- if (!name)
489
- return undefined;
490
- const trimmed = name.trim();
491
- if (!trimmed)
492
- return undefined;
493
- // Keep the title short for stable UI rendering in Chrome's tab strip.
494
- return trimmed.slice(0, 80);
495
- }
496
- normalizeTabGroupPluginId(pluginId) {
497
- if (!pluginId)
498
- return undefined;
499
- const trimmed = pluginId.trim();
500
- if (!trimmed)
501
- return undefined;
502
- return trimmed.slice(0, 128);
503
- }
504
- getAgentSessionName() {
505
- const session = process.env.AGENT_BROWSER_SESSION?.trim();
506
- return session && session.length > 0 ? session : 'default';
507
- }
508
- buildSessionTabGroupTitle(baseTitle, session) {
509
- const normalizedBase = this.normalizeTabGroupName(baseTitle) ?? DEFAULT_TAB_GROUP_NAME;
510
- if (session === 'default') {
511
- return normalizedBase;
512
- }
513
- const withSuffix = `${normalizedBase} • ${session}`;
514
- return this.normalizeTabGroupName(withSuffix) ?? normalizedBase;
515
- }
516
- configureTabGroupIntent(options) {
517
- const baseTitle = this.normalizeTabGroupName(options.tabGroup) ?? DEFAULT_TAB_GROUP_NAME;
518
- const session = this.getAgentSessionName();
519
- const groupTitle = this.buildSessionTabGroupTitle(baseTitle, session);
520
- const pluginId = this.normalizeTabGroupPluginId(options.tabGroupPluginId) ??
521
- this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ??
522
- DEFAULT_TAB_GROUP_PLUGIN_ID;
523
- this.tabGroupIntent = {
524
- session,
525
- groupTitle,
526
- pluginId,
527
- allowedDomains: [...this.allowedDomains],
528
- };
529
- if (!this.tabGroupCapabilityBySession.has(session)) {
530
- this.tabGroupCapabilityBySession.set(session, 'unknown');
531
- }
532
- }
533
- getTabGroupCapability(session) {
534
- return this.tabGroupCapabilityBySession.get(session) ?? 'unknown';
535
- }
536
- setTabGroupCapability(session, capability) {
537
- this.tabGroupCapabilityBySession.set(session, capability);
538
- }
539
- canInjectTabGroupScript(page) {
540
- const url = this.getSafePageUrl(page).toLowerCase();
541
- if (!url)
542
- return false;
543
- return (!url.startsWith('chrome://') &&
544
- !url.startsWith('chrome-extension://') &&
545
- !url.startsWith('devtools://') &&
546
- !url.startsWith('edge://'));
547
- }
548
- logTabGroupDebug(message) {
549
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
550
- console.error(`[DEBUG] ${message}`);
551
- }
552
- }
553
- async requestTabGroupPlugin(page, intent) {
554
- const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
555
- const result = await page.evaluate(({ requestType, responseType, nonce, session, groupTitle, pluginId, allowedDomains, timeoutMs, }) => {
556
- return new Promise((resolve) => {
557
- const win = globalThis;
558
- let settled = false;
559
- let timer;
560
- const finish = (value) => {
561
- if (settled)
562
- return;
563
- settled = true;
564
- win.removeEventListener('message', onMessage);
565
- if (typeof timer === 'number') {
566
- win.clearTimeout(timer);
567
- }
568
- resolve(value);
569
- };
570
- const onMessage = (event) => {
571
- if (event.source !== win)
572
- return;
573
- const data = event.data;
574
- if (!data || data.type !== responseType)
575
- return;
576
- if (data.nonce !== nonce)
577
- return;
578
- finish({
579
- ok: data.ok === true,
580
- extensionId: typeof data.extensionId === 'string' && data.extensionId.length > 0
581
- ? data.extensionId
582
- : undefined,
583
- error: typeof data.error === 'string' ? data.error : undefined,
584
- riskHints: Array.isArray(data.riskHints)
585
- ? data.riskHints.filter((item) => typeof item === 'string')
586
- : undefined,
587
- policy: data.policy && typeof data.policy === 'object'
588
- ? {
589
- enforced: data.policy.enforced === true,
590
- blocked: data.policy.blocked === true,
591
- reason: typeof data.policy.reason === 'string'
592
- ? data.policy.reason
593
- : undefined,
594
- }
595
- : undefined,
596
- });
597
- };
598
- win.addEventListener('message', onMessage);
599
- timer = win.setTimeout(() => finish(null), timeoutMs);
600
- try {
601
- win.postMessage({
602
- type: requestType,
603
- nonce,
604
- session,
605
- groupTitle,
606
- pluginId,
607
- allowedDomains,
608
- }, '*');
609
- }
610
- catch (error) {
611
- const message = error instanceof Error ? error.message : String(error);
612
- finish({ ok: false, error: message });
613
- }
614
- });
615
- }, {
616
- requestType: TAB_GROUP_REQUEST_MESSAGE_TYPE,
617
- responseType: TAB_GROUP_RESPONSE_MESSAGE_TYPE,
618
- nonce,
619
- session: intent.session,
620
- groupTitle: intent.groupTitle,
621
- pluginId: intent.pluginId,
622
- allowedDomains: intent.allowedDomains,
623
- timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS,
624
- });
625
- return result;
626
- }
627
- scheduleTabGrouping(page, source) {
628
- void this.tryApplyTabGrouping(page, source);
629
- }
630
- async tryApplyTabGrouping(page, source) {
631
- const intent = this.tabGroupIntent;
632
- if (!intent)
633
- return;
634
- if (this.stealthConnectionKind !== 'cdp')
635
- return;
636
- if (this.tabGroupInFlight.has(page))
637
- return;
638
- const capability = this.getTabGroupCapability(intent.session);
639
- if (capability === 'unavailable')
640
- return;
641
- if (page.isClosed() || !this.canInjectTabGroupScript(page)) {
642
- return;
643
- }
644
- this.tabGroupInFlight.add(page);
645
- try {
646
- const response = await this.requestTabGroupPlugin(page, intent);
647
- if (!response) {
648
- this.setTabGroupCapability(intent.session, 'unavailable');
649
- this.logTabGroupDebug(`Tab-group plugin unavailable (timeout, source=${source}, session=${intent.session})`);
650
- return;
651
- }
652
- if (!response.ok) {
653
- this.setTabGroupCapability(intent.session, 'unavailable');
654
- this.logTabGroupDebug(`Tab-group plugin returned error (source=${source}, session=${intent.session}): ${response.error ?? 'unknown'}`);
655
- return;
656
- }
657
- if (response.extensionId !== intent.pluginId) {
658
- this.setTabGroupCapability(intent.session, 'unavailable');
659
- this.logTabGroupDebug(`Tab-group plugin id mismatch (source=${source}, expected=${intent.pluginId}, actual=${response.extensionId ?? 'missing'})`);
660
- return;
661
- }
662
- this.setTabGroupCapability(intent.session, 'available');
663
- if (response.policy?.blocked) {
664
- this.logTabGroupDebug(`Tab-group policy blocked navigation (source=${source}, session=${intent.session}): ${response.policy.reason ?? 'domain-not-allowed'}`);
665
- }
666
- if (response.riskHints && response.riskHints.length > 0) {
667
- this.logTabGroupDebug(`Tab-group plugin risk hints (source=${source}, session=${intent.session}): ${response.riskHints.join(' | ')}`);
668
- }
669
- }
670
- catch (error) {
671
- this.setTabGroupCapability(intent.session, 'unavailable');
672
- const message = error instanceof Error ? error.message : String(error);
673
- this.logTabGroupDebug(`Tab-group plugin unavailable (source=${source}, session=${intent.session}): ${message}`);
674
- }
675
- finally {
676
- this.tabGroupInFlight.delete(page);
677
- }
678
- }
679
- triggerTabGroupingForActivePage(source = 'active-page') {
680
- if (!this.tabGroupIntent || this.pages.length === 0)
681
- return;
682
- const page = this.getPage();
683
- this.scheduleTabGrouping(page, source);
684
- }
685
- // CDP profiling state
686
- static MAX_PROFILE_EVENTS = 5_000_000;
687
- profilingActive = false;
688
- profileChunks = [];
689
- profileEventsDropped = false;
690
- profileCompleteResolver = null;
691
- profileDataHandler = null;
692
- profileCompleteHandler = null;
693
- /**
694
- * Check if browser is launched
695
- */
696
- isLaunched() {
697
- return this.browser !== null || this.isPersistentContext;
698
- }
699
- /**
700
- * Get enhanced snapshot with refs and cache the ref map
701
- */
702
- async getSnapshot(options) {
703
- const page = this.getPage();
704
- const snapshot = await getEnhancedSnapshot(page, options);
705
- this.refMap = snapshot.refs;
706
- this.lastSnapshot = snapshot.tree;
707
- return snapshot;
708
- }
709
- /**
710
- * Get the last snapshot tree text (empty string if no snapshot has been taken)
711
- */
712
- getLastSnapshot() {
713
- return this.lastSnapshot;
714
- }
715
- /**
716
- * Update the stored snapshot (used by diff to keep the baseline current)
717
- */
718
- setLastSnapshot(snapshot) {
719
- this.lastSnapshot = snapshot;
720
- }
721
- /**
722
- * Get the cached ref map from last snapshot
723
- */
724
- getRefMap() {
725
- return this.refMap;
726
- }
727
- /**
728
- * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
729
- * Returns null if ref doesn't exist or is invalid
730
- */
731
- getLocatorFromRef(refArg) {
732
- const ref = parseRef(refArg);
733
- if (!ref)
734
- return null;
735
- const refData = this.refMap[ref];
736
- if (!refData)
737
- return null;
738
- const page = this.getPage();
739
- // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role)
740
- // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector
741
- if (refData.role === 'clickable' || refData.role === 'focusable') {
742
- // The selector is a CSS selector, use it directly
743
- return page.locator(refData.selector);
744
- }
745
- // Build locator with exact: true to avoid substring matches
746
- let locator = page.getByRole(refData.role, {
747
- name: refData.name,
748
- exact: true,
749
- });
750
- // If an nth index is stored (for disambiguation), use it
751
- if (refData.nth !== undefined) {
752
- locator = locator.nth(refData.nth);
753
- }
754
- return locator;
755
- }
756
- /**
757
- * Check if a selector looks like a ref
758
- */
759
- isRef(selector) {
760
- return parseRef(selector) !== null;
761
- }
762
- /**
763
- * Install the domain filter on a context if an allowlist is configured.
764
- * Should be called before any pages navigate on the context.
765
- */
766
- async ensureDomainFilter(context) {
767
- if (this.allowedDomains.length > 0) {
768
- await installDomainFilter(context, this.allowedDomains);
769
- }
770
- }
771
- /**
772
- * After installing the domain filter, verify existing pages are on allowed
773
- * domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
774
- * already navigated to disallowed domains. Navigate them to about:blank.
775
- */
776
- async sanitizeExistingPages(pages) {
777
- if (this.allowedDomains.length === 0)
778
- return;
779
- for (const page of pages) {
780
- const url = page.url();
781
- if (!url || url === 'about:blank')
782
- continue;
783
- try {
784
- const hostname = new URL(url).hostname.toLowerCase();
785
- if (!isDomainAllowed(hostname, this.allowedDomains)) {
786
- await page.goto('about:blank');
787
- }
788
- }
789
- catch {
790
- await page.goto('about:blank').catch(() => { });
791
- }
792
- }
793
- }
794
- /**
795
- * Check if a URL is allowed by the domain allowlist.
796
- * Throws if the URL's domain is blocked. No-op if no allowlist is set.
797
- * Blocks non-http(s) schemes and unparseable URLs by default.
798
- */
799
- checkDomainAllowed(url) {
800
- if (this.allowedDomains.length === 0)
801
- return;
802
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
803
- throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
804
- }
805
- let hostname;
806
- try {
807
- hostname = new URL(url).hostname.toLowerCase();
808
- }
809
- catch {
810
- throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
811
- }
812
- if (!isDomainAllowed(hostname, this.allowedDomains)) {
813
- throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
814
- }
815
- }
816
- /**
817
- * Get locator - supports both refs and regular selectors
818
- */
819
- getLocator(selectorOrRef) {
820
- // Check if it's a ref first
821
- const locator = this.getLocatorFromRef(selectorOrRef);
822
- if (locator)
823
- return locator;
824
- // Otherwise treat as regular selector
825
- const page = this.getPage();
826
- return page.locator(selectorOrRef);
827
- }
828
- /**
829
- * Check if the browser has any usable pages
830
- */
831
- hasPages() {
832
- return this.pages.length > 0;
833
- }
834
- getSafePageUrl(page) {
835
- try {
836
- return page.url();
837
- }
838
- catch {
839
- return '';
840
- }
841
- }
842
- isIgnoredCDPPageUrl(url) {
843
- if (!url)
844
- return false;
845
- const normalizedUrl = url.toLowerCase();
846
- return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix));
847
- }
848
- isUsableCDPPage(page) {
849
- if (page.isClosed())
850
- return false;
851
- const url = this.getSafePageUrl(page);
852
- if (!url)
853
- return false;
854
- return !this.isIgnoredCDPPageUrl(url);
855
- }
856
- isMeaningfulCDPPage(page) {
857
- if (page.isClosed())
858
- return false;
859
- const url = this.getSafePageUrl(page).trim().toLowerCase();
860
- if (!url)
861
- return false;
862
- if (url === 'about:blank' || url.startsWith('about:blank#'))
863
- return false;
864
- if (url === 'chrome://newtab/' || url.startsWith('chrome://newtab'))
865
- return false;
866
- if (url === 'chrome://new-tab-page/' || url.startsWith('chrome://new-tab-page'))
867
- return false;
868
- return !this.isIgnoredCDPPageUrl(url);
869
- }
870
- collectUsableCDPPages(contexts) {
871
- return contexts
872
- .flatMap((context) => context.pages())
873
- .filter((page) => this.isUsableCDPPage(page));
874
- }
875
- /**
876
- * Ensure at least one page exists. If the browser is launched but all pages
877
- * were closed (stale session), creates a new page on the existing context.
878
- * No-op if pages already exist.
879
- */
880
- async ensurePage() {
881
- if (this.pages.length > 0)
882
- return;
883
- if (!this.browser && !this.isPersistentContext)
884
- return;
885
- // Use the last existing context, or create a new one
886
- let context;
887
- if (this.contexts.length > 0) {
888
- context = this.contexts[this.contexts.length - 1];
889
- }
890
- else if (this.browser) {
891
- context = await this.browser.newContext({
892
- ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
893
- ...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
894
- ...(this.contextLocale && { locale: this.contextLocale }),
895
- ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
896
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
897
- });
898
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
899
- context.setDefaultTimeout(getDefaultTimeout());
900
- this.contexts.push(context);
901
- this.setupContextTracking(context);
902
- await this.ensureDomainFilter(context);
903
- }
904
- else {
905
- return;
906
- }
907
- const page = await context.newPage();
908
- if (!this.pages.includes(page)) {
909
- this.pages.push(page);
910
- this.setupPageTracking(page);
911
- }
912
- this.activePageIndex = this.pages.length - 1;
913
- }
914
- /**
915
- * Get the current active page, throws if not launched
916
- */
917
- getPage() {
918
- if (this.pages.length === 0) {
919
- throw new Error('Browser not launched. Call launch first.');
920
- }
921
- const current = this.pages[this.activePageIndex];
922
- if (current && this.isUsableCDPPage(current)) {
923
- return current;
924
- }
925
- const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page));
926
- if (usableIndex !== -1) {
927
- this.activePageIndex = usableIndex;
928
- return this.pages[this.activePageIndex];
929
- }
930
- const openIndex = this.pages.findIndex((page) => !page.isClosed());
931
- if (openIndex !== -1) {
932
- this.activePageIndex = openIndex;
933
- return this.pages[this.activePageIndex];
934
- }
935
- return this.pages[this.activePageIndex];
936
- }
937
- /**
938
- * Get the current frame (or page's main frame if no frame is selected)
939
- */
940
- getFrame() {
941
- if (this.activeFrame) {
942
- return this.activeFrame;
943
- }
944
- return this.getPage().mainFrame();
945
- }
946
- /**
947
- * Switch to a frame by selector, name, or URL
948
- */
949
- async switchToFrame(options) {
950
- const page = this.getPage();
951
- if (options.selector) {
952
- const frameElement = await page.$(options.selector);
953
- if (!frameElement) {
954
- throw new Error(`Frame not found: ${options.selector}`);
955
- }
956
- const frame = await frameElement.contentFrame();
957
- if (!frame) {
958
- throw new Error(`Element is not a frame: ${options.selector}`);
959
- }
960
- this.activeFrame = frame;
961
- }
962
- else if (options.name) {
963
- const frame = page.frame({ name: options.name });
964
- if (!frame) {
965
- throw new Error(`Frame not found with name: ${options.name}`);
966
- }
967
- this.activeFrame = frame;
968
- }
969
- else if (options.url) {
970
- const frame = page.frame({ url: options.url });
971
- if (!frame) {
972
- throw new Error(`Frame not found with URL: ${options.url}`);
973
- }
974
- this.activeFrame = frame;
975
- }
976
- }
977
- /**
978
- * Switch back to main frame
979
- */
980
- switchToMainFrame() {
981
- this.activeFrame = null;
982
- }
983
- /**
984
- * Set up dialog handler
985
- */
986
- setDialogHandler(response, promptText) {
987
- const page = this.getPage();
988
- // Remove existing handler if any
989
- if (this.dialogHandler) {
990
- page.removeListener('dialog', this.dialogHandler);
991
- }
992
- this.dialogHandler = async (dialog) => {
993
- if (response === 'accept') {
994
- await dialog.accept(promptText);
995
- }
996
- else {
997
- await dialog.dismiss();
998
- }
999
- };
1000
- page.on('dialog', this.dialogHandler);
1001
- }
1002
- /**
1003
- * Clear dialog handler
1004
- */
1005
- clearDialogHandler() {
1006
- if (this.dialogHandler) {
1007
- const page = this.getPage();
1008
- page.removeListener('dialog', this.dialogHandler);
1009
- this.dialogHandler = null;
1010
- }
1011
- }
1012
- /**
1013
- * Start tracking requests
1014
- */
1015
- startRequestTracking() {
1016
- const page = this.getPage();
1017
- page.on('request', (request) => {
1018
- this.trackedRequests.push({
1019
- url: request.url(),
1020
- method: request.method(),
1021
- headers: request.headers(),
1022
- timestamp: Date.now(),
1023
- resourceType: request.resourceType(),
1024
- });
1025
- });
1026
- }
1027
- /**
1028
- * Get tracked requests
1029
- */
1030
- getRequests(filter) {
1031
- if (filter) {
1032
- return this.trackedRequests.filter((r) => r.url.includes(filter));
1033
- }
1034
- return this.trackedRequests;
1035
- }
1036
- /**
1037
- * Clear tracked requests
1038
- */
1039
- clearRequests() {
1040
- this.trackedRequests = [];
1041
- }
1042
- /**
1043
- * Add a route to intercept requests
1044
- */
1045
- async addRoute(url, options) {
1046
- const page = this.getPage();
1047
- const handler = async (route) => {
1048
- if (options.abort) {
1049
- await route.abort();
1050
- }
1051
- else if (options.response) {
1052
- await route.fulfill({
1053
- status: options.response.status ?? 200,
1054
- body: options.response.body ?? '',
1055
- contentType: options.response.contentType ?? 'text/plain',
1056
- headers: options.response.headers,
1057
- });
1058
- }
1059
- else {
1060
- await route.continue();
1061
- }
1062
- };
1063
- this.routes.set(url, handler);
1064
- await page.route(url, handler);
1065
- }
1066
- /**
1067
- * Remove a route
1068
- */
1069
- async removeRoute(url) {
1070
- const page = this.getPage();
1071
- if (url) {
1072
- const handler = this.routes.get(url);
1073
- if (handler) {
1074
- await page.unroute(url, handler);
1075
- this.routes.delete(url);
1076
- }
1077
- }
1078
- else {
1079
- // Remove all routes
1080
- for (const [routeUrl, handler] of this.routes) {
1081
- await page.unroute(routeUrl, handler);
1082
- }
1083
- this.routes.clear();
1084
- }
1085
- }
1086
- /**
1087
- * Set geolocation
1088
- */
1089
- async setGeolocation(latitude, longitude, accuracy) {
1090
- const context = this.contexts[0];
1091
- if (context) {
1092
- await context.setGeolocation({ latitude, longitude, accuracy });
1093
- }
1094
- }
1095
- /**
1096
- * Set permissions
1097
- */
1098
- async setPermissions(permissions, grant) {
1099
- const context = this.contexts[0];
1100
- if (context) {
1101
- if (grant) {
1102
- await context.grantPermissions(permissions);
1103
- }
1104
- else {
1105
- await context.clearPermissions();
1106
- }
1107
- }
1108
- }
1109
- /**
1110
- * Set viewport
1111
- */
1112
- async setViewport(width, height) {
1113
- const page = this.getPage();
1114
- await page.setViewportSize({ width, height });
1115
- }
1116
- /**
1117
- * Set device scale factor (devicePixelRatio) via CDP
1118
- * This sets window.devicePixelRatio which affects how the page renders and responds to media queries
1119
- *
1120
- * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
1121
- * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
1122
- * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
1123
- * deviceScaleFactor must be set at context creation time.
1124
- *
1125
- * Must be called after setViewport to work correctly
1126
- */
1127
- async setDeviceScaleFactor(deviceScaleFactor, width, height, mobile = false) {
1128
- const cdp = await this.getCDPSession();
1129
- await cdp.send('Emulation.setDeviceMetricsOverride', {
1130
- width,
1131
- height,
1132
- deviceScaleFactor,
1133
- mobile,
1134
- });
1135
- }
1136
- /**
1137
- * Clear device metrics override to restore default devicePixelRatio
1138
- */
1139
- async clearDeviceMetricsOverride() {
1140
- const cdp = await this.getCDPSession();
1141
- await cdp.send('Emulation.clearDeviceMetricsOverride');
1142
- }
1143
- /**
1144
- * Get device descriptor
1145
- */
1146
- getDevice(deviceName) {
1147
- return devices[deviceName];
1148
- }
1149
- /**
1150
- * List available devices
1151
- */
1152
- listDevices() {
1153
- return Object.keys(devices);
1154
- }
1155
- /**
1156
- * Start console message tracking
1157
- */
1158
- startConsoleTracking() {
1159
- const page = this.getPage();
1160
- page.on('console', (msg) => {
1161
- this.consoleMessages.push({
1162
- type: msg.type(),
1163
- text: msg.text(),
1164
- timestamp: Date.now(),
1165
- });
1166
- });
1167
- }
1168
- /**
1169
- * Get console messages
1170
- */
1171
- getConsoleMessages() {
1172
- return this.consoleMessages;
1173
- }
1174
- /**
1175
- * Clear console messages
1176
- */
1177
- clearConsoleMessages() {
1178
- this.consoleMessages = [];
1179
- }
1180
- /**
1181
- * Start error tracking
1182
- */
1183
- startErrorTracking() {
1184
- const page = this.getPage();
1185
- page.on('pageerror', (error) => {
1186
- this.pageErrors.push({
1187
- message: error.message,
1188
- timestamp: Date.now(),
1189
- });
1190
- });
1191
- }
1192
- /**
1193
- * Get page errors
1194
- */
1195
- getPageErrors() {
1196
- return this.pageErrors;
1197
- }
1198
- /**
1199
- * Clear page errors
1200
- */
1201
- clearPageErrors() {
1202
- this.pageErrors = [];
1203
- }
1204
- /**
1205
- * Start HAR recording
1206
- */
1207
- async startHarRecording() {
1208
- // HAR is started at context level, flag for tracking
1209
- this.isRecordingHar = true;
1210
- }
1211
- /**
1212
- * Check if HAR recording
1213
- */
1214
- isHarRecording() {
1215
- return this.isRecordingHar;
1216
- }
1217
- /**
1218
- * Set offline mode
1219
- */
1220
- async setOffline(offline) {
1221
- const context = this.contexts[0];
1222
- if (context) {
1223
- await context.setOffline(offline);
1224
- }
1225
- }
1226
- /**
1227
- * Set extra HTTP headers (global - all requests)
1228
- */
1229
- async setExtraHeaders(headers) {
1230
- const context = this.contexts[0];
1231
- if (context) {
1232
- await context.setExtraHTTPHeaders(headers);
1233
- }
1234
- }
1235
- /**
1236
- * Set scoped HTTP headers (only for requests matching the origin)
1237
- * Uses route interception to add headers only to matching requests
1238
- */
1239
- async setScopedHeaders(origin, headers) {
1240
- const page = this.getPage();
1241
- // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
1242
- // Handle both full URLs and just hostnames
1243
- let urlPattern;
1244
- try {
1245
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
1246
- // Match any protocol, the host, and any path
1247
- urlPattern = `**://${url.host}/**`;
1248
- }
1249
- catch {
1250
- // If parsing fails, treat as hostname pattern
1251
- urlPattern = `**://${origin}/**`;
1252
- }
1253
- // Remove existing route for this origin if any
1254
- const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
1255
- if (existingHandler) {
1256
- await page.unroute(urlPattern, existingHandler);
1257
- }
1258
- // Create handler that adds headers to matching requests
1259
- const handler = async (route) => {
1260
- const requestHeaders = route.request().headers();
1261
- await route.continue({
1262
- headers: safeHeaderMerge(requestHeaders, headers),
1263
- });
1264
- };
1265
- // Store and register the route
1266
- this.scopedHeaderRoutes.set(urlPattern, handler);
1267
- await page.route(urlPattern, handler);
1268
- }
1269
- /**
1270
- * Clear scoped headers for an origin (or all if no origin specified)
1271
- */
1272
- async clearScopedHeaders(origin) {
1273
- const page = this.getPage();
1274
- if (origin) {
1275
- let urlPattern;
1276
- try {
1277
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
1278
- urlPattern = `**://${url.host}/**`;
1279
- }
1280
- catch {
1281
- urlPattern = `**://${origin}/**`;
1282
- }
1283
- const handler = this.scopedHeaderRoutes.get(urlPattern);
1284
- if (handler) {
1285
- await page.unroute(urlPattern, handler);
1286
- this.scopedHeaderRoutes.delete(urlPattern);
1287
- }
1288
- }
1289
- else {
1290
- // Clear all scoped header routes
1291
- for (const [pattern, handler] of this.scopedHeaderRoutes) {
1292
- await page.unroute(pattern, handler);
1293
- }
1294
- this.scopedHeaderRoutes.clear();
1295
- }
1296
- }
1297
- /**
1298
- * Start tracing
1299
- */
1300
- async startTracing(options) {
1301
- const context = this.contexts[0];
1302
- if (context) {
1303
- await context.tracing.start({
1304
- screenshots: options.screenshots ?? true,
1305
- snapshots: options.snapshots ?? true,
1306
- });
1307
- }
1308
- }
1309
- /**
1310
- * Stop tracing and save
1311
- */
1312
- async stopTracing(path) {
1313
- const context = this.contexts[0];
1314
- if (context) {
1315
- await context.tracing.stop(path ? { path } : undefined);
1316
- }
1317
- }
1318
- /**
1319
- * Get the current browser context (first context)
1320
- */
1321
- getContext() {
1322
- return this.contexts[0] ?? null;
1323
- }
1324
- /**
1325
- * Save storage state (cookies, localStorage, etc.)
1326
- */
1327
- async saveStorageState(path) {
1328
- const context = this.contexts[0];
1329
- if (context) {
1330
- await context.storageState({ path });
1331
- }
1332
- }
1333
- /**
1334
- * Get all pages
1335
- */
1336
- getPages() {
1337
- return this.pages;
1338
- }
1339
- /**
1340
- * Get current page index
1341
- */
1342
- getActiveIndex() {
1343
- return this.activePageIndex;
1344
- }
1345
- /**
1346
- * Get the current browser instance
1347
- */
1348
- getBrowser() {
1349
- return this.browser;
1350
- }
1351
- /**
1352
- * Check if an existing CDP connection is still alive
1353
- * by verifying we can access browser contexts and that at least one has pages
1354
- */
1355
- isCdpConnectionAlive() {
1356
- if (!this.browser)
1357
- return false;
1358
- try {
1359
- const contexts = this.browser.contexts();
1360
- if (contexts.length === 0)
1361
- return false;
1362
- return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page)));
1363
- }
1364
- catch {
1365
- return false;
1366
- }
1367
- }
1368
- /**
1369
- * Check if CDP connection needs to be re-established
1370
- */
1371
- needsCdpReconnect(cdpEndpoint) {
1372
- if (!this.browser?.isConnected())
1373
- return true;
1374
- if (this.cdpEndpoint !== cdpEndpoint)
1375
- return true;
1376
- if (!this.isCdpConnectionAlive())
1377
- return true;
1378
- return false;
1379
- }
1380
- /**
1381
- * Close a Browserbase session via API
1382
- */
1383
- async closeBrowserbaseSession(sessionId, apiKey) {
1384
- await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
1385
- method: 'DELETE',
1386
- headers: {
1387
- 'X-BB-API-Key': apiKey,
1388
- },
1389
- });
1390
- }
1391
- /**
1392
- * Close a Browser Use session via API
1393
- */
1394
- async closeBrowserUseSession(sessionId, apiKey) {
1395
- const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
1396
- method: 'PATCH',
1397
- headers: {
1398
- 'Content-Type': 'application/json',
1399
- 'X-Browser-Use-API-Key': apiKey,
1400
- },
1401
- body: JSON.stringify({ action: 'stop' }),
1402
- });
1403
- if (!response.ok) {
1404
- throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
1405
- }
1406
- }
1407
- /**
1408
- * Close a Kernel session via API
1409
- */
1410
- async closeKernelSession(sessionId, apiKey) {
1411
- const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
1412
- method: 'DELETE',
1413
- headers: {
1414
- Authorization: `Bearer ${apiKey}`,
1415
- },
1416
- });
1417
- if (!response.ok) {
1418
- throw new Error(`Failed to close Kernel session: ${response.statusText}`);
1419
- }
1420
- }
1421
- /**
1422
- * Connect to Browserbase remote browser via CDP.
1423
- * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
1424
- */
1425
- async connectToBrowserbase() {
1426
- this.stealthConnectionKind = 'provider-browserbase';
1427
- const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
1428
- const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
1429
- if (!browserbaseApiKey || !browserbaseProjectId) {
1430
- throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
1431
- }
1432
- const response = await fetch('https://api.browserbase.com/v1/sessions', {
1433
- method: 'POST',
1434
- headers: {
1435
- 'Content-Type': 'application/json',
1436
- 'X-BB-API-Key': browserbaseApiKey,
1437
- },
1438
- body: JSON.stringify({
1439
- projectId: browserbaseProjectId,
1440
- }),
1441
- });
1442
- if (!response.ok) {
1443
- throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
1444
- }
1445
- const session = (await response.json());
1446
- const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
1447
- throw new Error('Failed to connect to Browserbase session via CDP');
1448
- });
1449
- try {
1450
- const contexts = browser.contexts();
1451
- if (contexts.length === 0) {
1452
- throw new Error('No browser context found in Browserbase session');
1453
- }
1454
- const context = contexts[0];
1455
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1456
- const pages = context.pages();
1457
- const page = pages[0] ?? (await context.newPage());
1458
- this.browserbaseSessionId = session.id;
1459
- this.browserbaseApiKey = browserbaseApiKey;
1460
- this.browser = browser;
1461
- context.setDefaultTimeout(10000);
1462
- this.contexts.push(context);
1463
- this.setupContextTracking(context);
1464
- await this.ensureDomainFilter(context);
1465
- await this.sanitizeExistingPages([page]);
1466
- this.pages.push(page);
1467
- this.activePageIndex = 0;
1468
- this.setupPageTracking(page);
1469
- }
1470
- catch (error) {
1471
- await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
1472
- console.error('Failed to close Browserbase session during cleanup:', sessionError);
1473
- });
1474
- throw error;
1475
- }
1476
- }
1477
- /**
1478
- * Find or create a Kernel profile by name.
1479
- * Returns the profile object if successful.
1480
- */
1481
- async findOrCreateKernelProfile(profileName, apiKey) {
1482
- // First, try to get the existing profile
1483
- const getResponse = await fetch(`https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, {
1484
- method: 'GET',
1485
- headers: {
1486
- Authorization: `Bearer ${apiKey}`,
1487
- },
1488
- });
1489
- if (getResponse.ok) {
1490
- // Profile exists, return it
1491
- return { name: profileName };
1492
- }
1493
- if (getResponse.status !== 404) {
1494
- throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`);
1495
- }
1496
- // Profile doesn't exist, create it
1497
- const createResponse = await fetch('https://api.onkernel.com/profiles', {
1498
- method: 'POST',
1499
- headers: {
1500
- 'Content-Type': 'application/json',
1501
- Authorization: `Bearer ${apiKey}`,
1502
- },
1503
- body: JSON.stringify({ name: profileName }),
1504
- });
1505
- if (!createResponse.ok) {
1506
- throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`);
1507
- }
1508
- return { name: profileName };
1509
- }
1510
- /**
1511
- * Connect to Kernel remote browser via CDP.
1512
- * Requires KERNEL_API_KEY environment variable.
1513
- */
1514
- async connectToKernel() {
1515
- this.stealthConnectionKind = 'provider-kernel';
1516
- const kernelApiKey = process.env.KERNEL_API_KEY;
1517
- if (!kernelApiKey) {
1518
- throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
1519
- }
1520
- // Find or create profile if KERNEL_PROFILE_NAME is set
1521
- const profileName = process.env.KERNEL_PROFILE_NAME;
1522
- let profileConfig;
1523
- if (profileName) {
1524
- await this.findOrCreateKernelProfile(profileName, kernelApiKey);
1525
- profileConfig = {
1526
- profile: {
1527
- name: profileName,
1528
- save_changes: true, // Save cookies/state back to the profile when session ends
1529
- },
1530
- };
1531
- }
1532
- const response = await fetch('https://api.onkernel.com/browsers', {
1533
- method: 'POST',
1534
- headers: {
1535
- 'Content-Type': 'application/json',
1536
- Authorization: `Bearer ${kernelApiKey}`,
1537
- },
1538
- body: JSON.stringify({
1539
- // Kernel browsers are headful by default with stealth mode available
1540
- // The user can configure these via environment variables if needed
1541
- headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true',
1542
- stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode
1543
- timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10),
1544
- // Load and save to a profile if specified
1545
- ...profileConfig,
1546
- }),
1547
- });
1548
- if (!response.ok) {
1549
- throw new Error(`Failed to create Kernel session: ${response.statusText}`);
1550
- }
1551
- let session;
1552
- try {
1553
- session = (await response.json());
1554
- }
1555
- catch (error) {
1556
- throw new Error(`Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}`);
1557
- }
1558
- if (!session.session_id || !session.cdp_ws_url) {
1559
- throw new Error(`Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}`);
1560
- }
1561
- const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => {
1562
- throw new Error('Failed to connect to Kernel session via CDP');
1563
- });
1564
- try {
1565
- const contexts = browser.contexts();
1566
- let context;
1567
- let page;
1568
- // Kernel browsers launch with a default context and page
1569
- if (contexts.length === 0) {
1570
- context = await browser.newContext();
1571
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1572
- page = await context.newPage();
1573
- }
1574
- else {
1575
- context = contexts[0];
1576
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1577
- const pages = context.pages();
1578
- page = pages[0] ?? (await context.newPage());
1579
- }
1580
- this.kernelSessionId = session.session_id;
1581
- this.kernelApiKey = kernelApiKey;
1582
- this.browser = browser;
1583
- context.setDefaultTimeout(getDefaultTimeout());
1584
- this.contexts.push(context);
1585
- this.setupContextTracking(context);
1586
- await this.ensureDomainFilter(context);
1587
- await this.sanitizeExistingPages([page]);
1588
- this.pages.push(page);
1589
- this.activePageIndex = 0;
1590
- this.setupPageTracking(page);
1591
- }
1592
- catch (error) {
1593
- await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
1594
- console.error('Failed to close Kernel session during cleanup:', sessionError);
1595
- });
1596
- throw error;
1597
- }
1598
- }
1599
- /**
1600
- * Connect to Browser Use remote browser via CDP.
1601
- * Requires BROWSER_USE_API_KEY environment variable.
1602
- */
1603
- async connectToBrowserUse() {
1604
- this.stealthConnectionKind = 'provider-browseruse';
1605
- const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
1606
- if (!browserUseApiKey) {
1607
- throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
1608
- }
1609
- const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
1610
- method: 'POST',
1611
- headers: {
1612
- 'Content-Type': 'application/json',
1613
- 'X-Browser-Use-API-Key': browserUseApiKey,
1614
- },
1615
- body: JSON.stringify({}),
1616
- });
1617
- if (!response.ok) {
1618
- throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
1619
- }
1620
- let session;
1621
- try {
1622
- session = (await response.json());
1623
- }
1624
- catch (error) {
1625
- throw new Error(`Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`);
1626
- }
1627
- if (!session.id || !session.cdpUrl) {
1628
- throw new Error(`Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`);
1629
- }
1630
- const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
1631
- throw new Error('Failed to connect to Browser Use session via CDP');
1632
- });
1633
- try {
1634
- const contexts = browser.contexts();
1635
- let context;
1636
- let page;
1637
- if (contexts.length === 0) {
1638
- context = await browser.newContext();
1639
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1640
- page = await context.newPage();
1641
- }
1642
- else {
1643
- context = contexts[0];
1644
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1645
- const pages = context.pages();
1646
- page = pages[0] ?? (await context.newPage());
1647
- }
1648
- this.browserUseSessionId = session.id;
1649
- this.browserUseApiKey = browserUseApiKey;
1650
- this.browser = browser;
1651
- context.setDefaultTimeout(getDefaultTimeout());
1652
- this.contexts.push(context);
1653
- this.setupContextTracking(context);
1654
- await this.ensureDomainFilter(context);
1655
- await this.sanitizeExistingPages([page]);
1656
- this.pages.push(page);
1657
- this.activePageIndex = 0;
1658
- this.setupPageTracking(page);
1659
- }
1660
- catch (error) {
1661
- await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
1662
- console.error('Failed to close Browser Use session during cleanup:', sessionError);
1663
- });
1664
- throw error;
1665
- }
1666
- }
1667
- /**
1668
- * Launch the browser with the specified options
1669
- * If already launched, this is a no-op (browser stays open)
1670
- */
1671
- async launch(options) {
1672
- // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
1673
- const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
1674
- const configuredExtensions = options.extensions ? [...options.extensions] : [];
1675
- const hasStorageState = !!options.storageState;
1676
- if (configuredExtensions.length > 0 && cdpEndpoint) {
1677
- throw new Error('Extensions cannot be used with CDP connection');
1678
- }
1679
- if (hasStorageState && configuredExtensions.length > 0) {
1680
- throw new Error('Storage state cannot be used with extensions (extensions require persistent context)');
1681
- }
1682
- if (this.isLaunched()) {
1683
- // Explicit --auto-connect should switch away from managed/local/provider sessions
1684
- // so commands always target a discovered user browser.
1685
- const shouldSwitchToAutoConnect = !!options.autoConnect &&
1686
- (this.cdpEndpoint === null || this.stealthConnectionKind !== 'cdp');
1687
- const needsRelaunch = shouldSwitchToAutoConnect ||
1688
- (!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) ||
1689
- (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) ||
1690
- (!!options.autoConnect && !this.isCdpConnectionAlive());
1691
- if (needsRelaunch) {
1692
- await this.close();
1693
- }
1694
- else if (options.autoConnect && this.isCdpConnectionAlive()) {
1695
- // Already connected via auto-connect, no need to reconnect
1696
- return;
1697
- }
1698
- else {
1699
- return;
1700
- }
1701
- }
1702
- if (options.colorScheme) {
1703
- this.colorScheme = options.colorScheme;
1704
- }
1705
- this.stealthEnabled = true;
1706
- this.contextLocale = this.resolveStealthLocale(options.headers);
1707
- this.contextTimezoneId = this.resolveStealthTimezoneId();
1708
- this.contextHeaders = undefined;
1709
- this.contextUserAgent = options.userAgent;
1710
- this.allowWebGLContextFallback = false;
1711
- // -p flag takes precedence over AGENT_BROWSER_PROVIDER.
1712
- const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
1713
- if (cdpEndpoint || options.autoConnect) {
1714
- this.stealthConnectionKind = 'cdp';
1715
- }
1716
- else if (provider === 'browserbase') {
1717
- this.stealthConnectionKind = 'provider-browserbase';
1718
- }
1719
- else if (provider === 'browseruse') {
1720
- this.stealthConnectionKind = 'provider-browseruse';
1721
- }
1722
- else if (provider === 'kernel') {
1723
- this.stealthConnectionKind = 'provider-kernel';
1724
- }
1725
- else {
1726
- this.stealthConnectionKind = 'local';
1727
- }
1728
- this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
1729
- const hasExtensions = configuredExtensions.length > 0;
1730
- if (options.downloadPath) {
1731
- this.downloadPath = options.downloadPath;
1732
- }
1733
- if (options.allowedDomains && options.allowedDomains.length > 0) {
1734
- this.allowedDomains = options.allowedDomains.map((d) => d.toLowerCase());
1735
- }
1736
- else {
1737
- const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
1738
- if (envDomains) {
1739
- this.allowedDomains = parseDomainList(envDomains);
1740
- }
1741
- }
1742
- this.configureTabGroupIntent(options);
1743
- if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
1744
- const warning = "--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
1745
- this.launchWarnings.push(warning);
1746
- console.error(`[WARN] ${warning}`);
1747
- }
1748
- if (cdpEndpoint) {
1749
- try {
1750
- await this.connectViaCDP(cdpEndpoint);
1751
- }
1752
- catch (error) {
1753
- if (!this.isManagedCdpEndpoint(cdpEndpoint)) {
1754
- throw error;
1755
- }
1756
- await this.ensureManagedCdpBrowser();
1757
- await this.connectViaCDP(cdpEndpoint);
1758
- }
1759
- return;
1760
- }
1761
- if (options.autoConnect) {
1762
- await this.autoConnectViaCDP();
1763
- return;
1764
- }
1765
- // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
1766
- // -p flag takes precedence over AGENT_BROWSER_PROVIDER.
1767
- if (this.downloadPath && provider) {
1768
- const warning = "--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
1769
- this.launchWarnings.push(warning);
1770
- console.error(`[WARN] ${warning}`);
1771
- }
1772
- if (provider === 'browserbase') {
1773
- await this.connectToBrowserbase();
1774
- return;
1775
- }
1776
- if (provider === 'browseruse') {
1777
- await this.connectToBrowserUse();
1778
- return;
1779
- }
1780
- // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel
1781
- if (provider === 'kernel') {
1782
- await this.connectToKernel();
1783
- return;
1784
- }
1785
- if (this.downloadPath) {
1786
- const resolved = path.resolve(this.downloadPath);
1787
- const stat = statSync(resolved, { throwIfNoEntry: false });
1788
- if (stat && !stat.isDirectory()) {
1789
- throw new Error(`Download path is not a directory: ${resolved}`);
1790
- }
1791
- if (!stat) {
1792
- try {
1793
- mkdirSync(resolved, { recursive: true });
1794
- }
1795
- catch (e) {
1796
- const msg = e instanceof Error ? e.message : String(e);
1797
- throw new Error(`Cannot create download directory '${resolved}': ${msg}`);
1798
- }
1799
- }
1800
- this.downloadPath = resolved;
1801
- }
1802
- const browserType = options.browser ?? 'chromium';
1803
- if (hasExtensions && browserType !== 'chromium') {
1804
- throw new Error('Extensions are only supported in Chromium');
1805
- }
1806
- // allowFileAccess is only supported in Chromium
1807
- if (options.allowFileAccess && browserType !== 'chromium') {
1808
- throw new Error('allowFileAccess is only supported in Chromium');
1809
- }
1810
- const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
1811
- // Chromium launches always use the Chrome channel unless a custom executable is provided.
1812
- const chromeChannel = browserType === 'chromium' && !options.executablePath ? 'chrome' : undefined;
1813
- const stealthPolicy = this.getStealthPolicy(browserType);
1814
- const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers);
1815
- const extraHTTPHeaders = contextDefaults.extraHTTPHeaders;
1816
- this.contextLocale = contextDefaults.locale;
1817
- this.contextTimezoneId = contextDefaults.timezoneId;
1818
- this.contextHeaders = contextDefaults.extraHTTPHeaders;
1819
- let contextUserAgent = options.userAgent;
1820
- if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') {
1821
- const versionHint = this.getStealthUserAgentVersionHint();
1822
- if (versionHint) {
1823
- contextUserAgent = this.buildStealthChromiumUserAgent(versionHint);
1824
- }
1825
- }
1826
- this.contextUserAgent = contextUserAgent;
1827
- // Build base args array with file access flags and stealth args when policy allows.
1828
- const fileAccessArgs = options.allowFileAccess
1829
- ? ['--allow-file-access-from-files', '--allow-file-access']
1830
- : [];
1831
- const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
1832
- const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent='));
1833
- const launchUserAgentArgs = !hasUserAgentArg &&
1834
- !options.userAgent &&
1835
- stealthPolicy.enabled &&
1836
- browserType === 'chromium' &&
1837
- contextUserAgent
1838
- ? [`--user-agent=${contextUserAgent}`]
1839
- : [];
1840
- const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs];
1841
- const baseArgs = options.args
1842
- ? [...implicitArgs, ...options.args]
1843
- : implicitArgs.length > 0
1844
- ? implicitArgs
1845
- : undefined;
1846
- // Auto-detect args that control window size and disable viewport emulation
1847
- // so Playwright doesn't override the browser's own sizing behavior
1848
- const hasWindowSizeArgs = baseArgs?.some((arg) => arg === '--start-maximized' || arg.startsWith('--window-size='));
1849
- const viewport = options.viewport !== undefined
1850
- ? options.viewport
1851
- : hasWindowSizeArgs
1852
- ? null
1853
- : { width: 1280, height: 720 };
1854
- let context;
1855
- if (hasExtensions) {
1856
- // Extensions require persistent context in a temp directory. In this fork,
1857
- // extension launches stay headed by default unless headless is explicitly requested.
1858
- const extPaths = configuredExtensions.join(',');
1859
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
1860
- // Combine extension args with custom args and file access args
1861
- const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
1862
- const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
1863
- context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `agent-browser-ext-${session}`), {
1864
- headless: options.headless ?? false,
1865
- executablePath: options.executablePath,
1866
- ...(chromeChannel && { channel: chromeChannel }),
1867
- args: allArgs,
1868
- viewport,
1869
- extraHTTPHeaders,
1870
- userAgent: contextUserAgent,
1871
- ...(this.contextLocale && { locale: this.contextLocale }),
1872
- ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
1873
- ...(options.proxy && { proxy: options.proxy }),
1874
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1875
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
1876
- ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1877
- });
1878
- this.isPersistentContext = true;
1879
- }
1880
- else {
1881
- // Regular ephemeral browser
1882
- this.browser = await launcher.launch({
1883
- headless: options.headless ?? false,
1884
- executablePath: options.executablePath,
1885
- ...(chromeChannel && { channel: chromeChannel }),
1886
- args: baseArgs,
1887
- ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1888
- });
1889
- this.cdpEndpoint = null;
1890
- if (stealthPolicy.enabled && browserType === 'chromium') {
1891
- await applyBrowserLevelStealth(this.browser, {
1892
- userAgent: contextUserAgent,
1893
- });
1894
- }
1895
- if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') {
1896
- const runtimeVersion = this.extractChromiumVersion(this.browser.version());
1897
- if (runtimeVersion) {
1898
- contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion);
1899
- this.contextUserAgent = contextUserAgent;
1900
- }
1901
- }
1902
- // Check for auto-load state file (supports encrypted files)
1903
- let storageState = options.storageState ? options.storageState : undefined;
1904
- if (!storageState && options.autoStateFilePath) {
1905
- try {
1906
- const fs = await import('fs');
1907
- if (fs.existsSync(options.autoStateFilePath)) {
1908
- const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
1909
- const parsed = JSON.parse(content);
1910
- if (isEncryptedPayload(parsed)) {
1911
- const key = getEncryptionKey();
1912
- if (key) {
1913
- try {
1914
- const decrypted = decryptData(parsed, key);
1915
- storageState = JSON.parse(decrypted);
1916
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
1917
- console.error(`[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`);
1918
- }
1919
- }
1920
- catch (decryptErr) {
1921
- const warning = 'Failed to decrypt state file - wrong encryption key? Starting fresh.';
1922
- this.launchWarnings.push(warning);
1923
- console.error(`[WARN] ${warning}`);
1924
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
1925
- console.error(`[DEBUG] Decryption error:`, decryptErr);
1926
- }
1927
- }
1928
- }
1929
- else {
1930
- const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
1931
- this.launchWarnings.push(warning);
1932
- console.error(`[WARN] ${warning}`);
1933
- }
1934
- }
1935
- else {
1936
- storageState = options.autoStateFilePath;
1937
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
1938
- console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
1939
- }
1940
- }
1941
- }
1942
- }
1943
- catch (err) {
1944
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
1945
- console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
1946
- }
1947
- }
1948
- }
1949
- context = await this.browser.newContext({
1950
- viewport,
1951
- extraHTTPHeaders,
1952
- userAgent: contextUserAgent,
1953
- storageState,
1954
- ...(this.contextLocale && { locale: this.contextLocale }),
1955
- ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
1956
- ...(options.proxy && { proxy: options.proxy }),
1957
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1958
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
1959
- });
1960
- }
1961
- let probePage = context.pages()[0];
1962
- const createdProbePage = !probePage;
1963
- if (!probePage) {
1964
- probePage = await context.newPage();
1965
- }
1966
- await this.configureWebGLFallbackFromPage(probePage, 'local');
1967
- if (createdProbePage) {
1968
- await probePage.close().catch(() => { });
1969
- }
1970
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
1971
- context.setDefaultTimeout(getDefaultTimeout());
1972
- this.contexts.push(context);
1973
- this.setupContextTracking(context);
1974
- await this.ensureDomainFilter(context);
1975
- const page = context.pages()[0] ?? (await context.newPage());
1976
- await this.sanitizeExistingPages([page]);
1977
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1978
- if (!this.pages.includes(page)) {
1979
- this.pages.push(page);
1980
- this.setupPageTracking(page);
1981
- }
1982
- this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
1983
- }
1984
- /**
1985
- * Connect to a running browser via CDP (Chrome DevTools Protocol)
1986
- * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
1987
- */
1988
- async connectViaCDP(cdpEndpoint, options) {
1989
- this.stealthConnectionKind = 'cdp';
1990
- if (!cdpEndpoint) {
1991
- throw new Error('CDP endpoint is required for CDP connection');
1992
- }
1993
- // Determine the connection URL:
1994
- // - If it starts with ws://, wss://, http://, or https://, use it directly
1995
- // - If it's a numeric string (e.g., "9222"), treat as port for localhost
1996
- // - Otherwise, treat it as a port number for localhost
1997
- let cdpUrl;
1998
- if (cdpEndpoint.startsWith('ws://') ||
1999
- cdpEndpoint.startsWith('wss://') ||
2000
- cdpEndpoint.startsWith('http://') ||
2001
- cdpEndpoint.startsWith('https://')) {
2002
- cdpUrl = cdpEndpoint;
2003
- }
2004
- else if (/^\d+$/.test(cdpEndpoint)) {
2005
- // Numeric string - treat as port number (handles JSON serialization quirks)
2006
- cdpUrl = `http://localhost:${cdpEndpoint}`;
2007
- }
2008
- else {
2009
- // Unknown format - still try as port for backward compatibility
2010
- cdpUrl = `http://localhost:${cdpEndpoint}`;
2011
- }
2012
- const browser = await chromium
2013
- .connectOverCDP(cdpUrl, { timeout: options?.timeout })
2014
- .catch(() => {
2015
- throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
2016
- (cdpUrl.includes('localhost')
2017
- ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
2018
- : 'Make sure the remote browser is accessible and the URL is correct.'));
2019
- });
2020
- // Validate and set up state, cleaning up browser connection if anything fails
2021
- try {
2022
- const contexts = browser.contexts();
2023
- if (contexts.length === 0) {
2024
- throw new Error('No browser context found. Make sure the app has an open window.');
2025
- }
2026
- let allPages = this.collectUsableCDPPages(contexts);
2027
- const allowCreatePageFallback = options?.allowCreatePageFallback ?? true;
2028
- if (allPages.length === 0) {
2029
- if (!allowCreatePageFallback) {
2030
- throw new Error('No existing user tabs found on this CDP endpoint.');
2031
- }
2032
- // Some Chrome instances (especially with custom UI pages) expose only internal/transient
2033
- // pages over CDP. Create a fresh page so commands always have a stable target.
2034
- let fallbackPage = null;
2035
- for (const context of contexts) {
2036
- try {
2037
- const page = await context.newPage();
2038
- if (!fallbackPage) {
2039
- fallbackPage = page;
2040
- }
2041
- if (this.isUsableCDPPage(page)) {
2042
- fallbackPage = page;
2043
- break;
2044
- }
2045
- }
2046
- catch {
2047
- // Try next context
2048
- }
2049
- }
2050
- if (!fallbackPage) {
2051
- throw new Error('No page found. Make sure the app has loaded content.');
2052
- }
2053
- allPages = [fallbackPage];
2054
- }
2055
- if (options?.requireMeaningfulPage) {
2056
- const meaningfulPages = allPages.filter((page) => this.isMeaningfulCDPPage(page));
2057
- if (meaningfulPages.length === 0) {
2058
- throw new Error('No existing user tabs found on this CDP endpoint.');
2059
- }
2060
- allPages = meaningfulPages;
2061
- }
2062
- // All validation passed - commit state
2063
- this.browser = browser;
2064
- this.cdpEndpoint = cdpEndpoint;
2065
- await this.configureWebGLFallbackFromPage(allPages[0], 'cdp');
2066
- for (const context of contexts) {
2067
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
2068
- context.setDefaultTimeout(10000);
2069
- this.contexts.push(context);
2070
- this.setupContextTracking(context);
2071
- await this.ensureDomainFilter(context);
2072
- }
2073
- await this.sanitizeExistingPages(allPages);
2074
- for (const page of allPages) {
2075
- this.pages.push(page);
2076
- this.setupPageTracking(page);
2077
- }
2078
- this.activePageIndex = 0;
2079
- }
2080
- catch (error) {
2081
- // Clean up browser connection if validation or setup failed
2082
- await browser.close().catch(() => { });
2083
- throw error;
2084
- }
2085
- }
2086
- /**
2087
- * Get Chrome's default user data directory paths for the current platform.
2088
- * Returns an array of candidate paths to check (stable, then beta/canary).
2089
- */
2090
- getChromeUserDataDirs() {
2091
- const home = os.homedir();
2092
- const platform = os.platform();
2093
- if (platform === 'darwin') {
2094
- return [
2095
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
2096
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
2097
- path.join(home, 'Library', 'Application Support', 'Chromium'),
2098
- ];
2099
- }
2100
- else if (platform === 'win32') {
2101
- const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
2102
- return [
2103
- path.join(localAppData, 'Google', 'Chrome', 'User Data'),
2104
- path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
2105
- path.join(localAppData, 'Chromium', 'User Data'),
2106
- ];
2107
- }
2108
- else {
2109
- // Linux
2110
- return [
2111
- path.join(home, '.config', 'google-chrome'),
2112
- path.join(home, '.config', 'google-chrome-unstable'),
2113
- path.join(home, '.config', 'chromium'),
2114
- ];
2115
- }
2116
- }
2117
- /**
2118
- * Try to read the DevToolsActivePort file from a Chrome user data directory.
2119
- * Returns { port, wsPath } if found, or null if not available.
2120
- */
2121
- readDevToolsActivePort(userDataDir) {
2122
- const filePath = path.join(userDataDir, 'DevToolsActivePort');
2123
- try {
2124
- if (!existsSync(filePath))
2125
- return null;
2126
- const content = readFileSync(filePath, 'utf-8').trim();
2127
- const lines = content.split('\n');
2128
- if (lines.length < 2)
2129
- return null;
2130
- const port = parseInt(lines[0].trim(), 10);
2131
- const wsPath = lines[1].trim();
2132
- if (isNaN(port) || port <= 0 || port > 65535)
2133
- return null;
2134
- if (!wsPath)
2135
- return null;
2136
- return { port, wsPath };
2137
- }
2138
- catch {
2139
- return null;
2140
- }
2141
- }
2142
- /**
2143
- * Try to discover a Chrome CDP endpoint by querying an HTTP debug port.
2144
- * Returns the WebSocket debugger URL if available.
2145
- */
2146
- async probeDebugPort(port) {
2147
- try {
2148
- const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
2149
- signal: AbortSignal.timeout(2000),
2150
- });
2151
- if (!response.ok)
2152
- return null;
2153
- const data = (await response.json());
2154
- return data.webSocketDebuggerUrl ?? null;
2155
- }
2156
- catch {
2157
- return null;
2158
- }
2159
- }
2160
- /**
2161
- * Auto-discover and connect to a running Chrome/Chromium instance.
2162
- *
2163
- * Discovery strategy:
2164
- * 1. Read DevToolsActivePort from Chrome's default user data directories
2165
- * 2. If found, connect using the port and WebSocket path from that file
2166
- * 3. If not found, probe common debugging ports (9222, 9229, 9333)
2167
- * 4. If a port responds, connect via CDP
2168
- */
2169
- async autoConnectViaCDP() {
2170
- let sawEndpointWithoutUserTabs = false;
2171
- // Strategy 0: Prefer project-default resident CDP port first.
2172
- // This keeps user + agent on the same browser session when 9333 is available.
2173
- {
2174
- const wsUrl = await this.probeDebugPort(9333);
2175
- if (wsUrl) {
2176
- try {
2177
- await this.connectViaCDP(wsUrl, {
2178
- allowCreatePageFallback: false,
2179
- requireMeaningfulPage: true,
2180
- });
2181
- return;
2182
- }
2183
- catch (error) {
2184
- const message = error instanceof Error ? error.message : String(error);
2185
- if (message.includes('No existing user tabs found on this CDP endpoint')) {
2186
- sawEndpointWithoutUserTabs = true;
2187
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
2188
- console.error(`[DEBUG] Skipping preferred CDP endpoint without user tabs (${wsUrl}): ${message}`);
2189
- }
2190
- }
2191
- else if (process.env.AGENT_BROWSER_DEBUG === '1') {
2192
- console.error(`[DEBUG] Failed preferred CDP candidate (${wsUrl}): ${message}`);
2193
- }
2194
- }
2195
- }
2196
- }
2197
- // Strategy 1: Check DevToolsActivePort files
2198
- const userDataDirs = this.getChromeUserDataDirs();
2199
- for (const dir of userDataDirs) {
2200
- const activePort = this.readDevToolsActivePort(dir);
2201
- if (activePort) {
2202
- // Try HTTP discovery first (works with --remote-debugging-port mode)
2203
- const wsUrl = await this.probeDebugPort(activePort.port);
2204
- if (wsUrl) {
2205
- try {
2206
- await this.connectViaCDP(wsUrl, {
2207
- allowCreatePageFallback: false,
2208
- requireMeaningfulPage: true,
2209
- });
2210
- return;
2211
- }
2212
- catch (error) {
2213
- const message = error instanceof Error ? error.message : String(error);
2214
- if (message.includes('No existing user tabs found on this CDP endpoint')) {
2215
- sawEndpointWithoutUserTabs = true;
2216
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
2217
- console.error(`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`);
2218
- }
2219
- }
2220
- else if (process.env.AGENT_BROWSER_DEBUG === '1') {
2221
- console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
2222
- }
2223
- }
2224
- }
2225
- // HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
2226
- // WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
2227
- // path read directly from DevToolsActivePort.
2228
- const directWsUrl = `ws://127.0.0.1:${activePort.port}${activePort.wsPath}`;
2229
- try {
2230
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
2231
- console.error(`[DEBUG] HTTP probe failed on port ${activePort.port}, ` +
2232
- `attempting direct WebSocket connection to ${directWsUrl}`);
2233
- }
2234
- await this.connectViaCDP(directWsUrl, {
2235
- timeout: 60_000,
2236
- allowCreatePageFallback: false,
2237
- requireMeaningfulPage: true,
2238
- });
2239
- return;
2240
- }
2241
- catch (error) {
2242
- const message = error instanceof Error ? error.message : String(error);
2243
- if (message.includes('No existing user tabs found on this CDP endpoint')) {
2244
- sawEndpointWithoutUserTabs = true;
2245
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
2246
- console.error(`[DEBUG] Skipping CDP endpoint without user tabs (${directWsUrl}): ${message}`);
2247
- }
2248
- }
2249
- else if (process.env.AGENT_BROWSER_DEBUG === '1') {
2250
- console.error(`[DEBUG] Failed CDP candidate (${directWsUrl}): ${message}`);
2251
- }
2252
- // Direct WebSocket also failed, try next directory
2253
- }
2254
- }
2255
- }
2256
- // Strategy 2: Probe common debugging ports
2257
- const commonPorts = [9222, 9229];
2258
- for (const port of commonPorts) {
2259
- const wsUrl = await this.probeDebugPort(port);
2260
- if (wsUrl) {
2261
- try {
2262
- await this.connectViaCDP(wsUrl, {
2263
- allowCreatePageFallback: false,
2264
- requireMeaningfulPage: true,
2265
- });
2266
- return;
2267
- }
2268
- catch (error) {
2269
- const message = error instanceof Error ? error.message : String(error);
2270
- if (message.includes('No existing user tabs found on this CDP endpoint')) {
2271
- sawEndpointWithoutUserTabs = true;
2272
- if (process.env.AGENT_BROWSER_DEBUG === '1') {
2273
- console.error(`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`);
2274
- }
2275
- }
2276
- else if (process.env.AGENT_BROWSER_DEBUG === '1') {
2277
- console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
2278
- }
2279
- }
2280
- }
2281
- }
2282
- if (sawEndpointWithoutUserTabs) {
2283
- throw new Error('Found CDP endpoints, but none exposed existing user tabs. Ensure you are attaching to the same Chrome instance/profile you are using manually.');
2284
- }
2285
- // Nothing found
2286
- const platform = os.platform();
2287
- let hint;
2288
- if (platform === 'darwin') {
2289
- hint =
2290
- 'Start Chrome with: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
2291
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
2292
- }
2293
- else if (platform === 'win32') {
2294
- hint =
2295
- 'Start Chrome with: chrome.exe --remote-debugging-port=9222\n' +
2296
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
2297
- }
2298
- else {
2299
- hint =
2300
- 'Start Chrome with: google-chrome --remote-debugging-port=9222\n' +
2301
- 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
2302
- }
2303
- throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`);
2304
- }
2305
- addDoctorCheck(checks, name, status, message, details) {
2306
- checks.push({ name, status, message, ...(details ? { details } : {}) });
2307
- }
2308
- /**
2309
- * Probe whether CDP Runtime.evaluate responses still leak automation-only
2310
- * sourceURL labels such as `__playwright_evaluation_script__`.
2311
- */
2312
- async runDoctorSourceUrlProbe(checks, launched) {
2313
- if (!launched) {
2314
- this.addDoctorCheck(checks, 'cdp:sourceurl-sanitized', 'skip', 'Browser is not launched; sourceURL probe skipped');
2315
- return;
2316
- }
2317
- try {
2318
- const cdp = await this.getCDPSession();
2319
- const response = await cdp.send('Runtime.evaluate', {
2320
- expression: "(() => { throw new Error('doctor-sourceurl'); })()\\n//# sourceURL=__playwright_evaluation_script__",
2321
- returnByValue: true,
2322
- });
2323
- const raw = JSON.stringify(response);
2324
- const leakedMarkers = [
2325
- '__playwright_evaluation_script__',
2326
- '__puppeteer_evaluation_script__',
2327
- 'sourceURL=',
2328
- ].filter((marker) => raw.includes(marker));
2329
- const leaked = leakedMarkers.length > 0;
2330
- this.addDoctorCheck(checks, 'cdp:sourceurl-sanitized', leaked ? 'fail' : 'pass', leaked
2331
- ? 'CDP Runtime.evaluate response still exposes automation sourceURL markers'
2332
- : 'CDP Runtime.evaluate response is sourceURL-sanitized', leaked ? { leakedMarkers } : undefined);
2333
- }
2334
- catch (error) {
2335
- this.addDoctorCheck(checks, 'cdp:sourceurl-sanitized', 'warn', `Unable to run Runtime.evaluate sourceURL probe: ${error instanceof Error ? error.message : String(error)}`);
2336
- }
2337
- }
2338
- buildDoctorTabGroupIntent() {
2339
- const session = this.getAgentSessionName();
2340
- const pluginId = this.tabGroupIntent?.pluginId ??
2341
- this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ??
2342
- DEFAULT_TAB_GROUP_PLUGIN_ID;
2343
- const groupTitle = this.tabGroupIntent?.groupTitle ??
2344
- this.buildSessionTabGroupTitle(DEFAULT_TAB_GROUP_NAME, session);
2345
- return {
2346
- session,
2347
- groupTitle,
2348
- pluginId,
2349
- allowedDomains: (this.tabGroupIntent?.allowedDomains?.length ?? 0) > 0
2350
- ? [...(this.tabGroupIntent?.allowedDomains ?? [])]
2351
- : [...this.allowedDomains],
2352
- };
2353
- }
2354
- /**
2355
- * Run connection diagnostics for CDP discovery, sourceURL sanitization, and
2356
- * tab-group plugin readiness/handshake.
2357
- * This is intentionally side-effect-light: it does not navigate or force launch.
2358
- */
2359
- async runDoctor() {
2360
- const checks = [];
2361
- const launched = this.isLaunched();
2362
- const preferredPort = 9333;
2363
- const discovered = [];
2364
- const devToolsActivePort = [];
2365
- const seenPorts = new Set();
2366
- const pushDiscovery = (port, source, wsUrl, status, note) => {
2367
- discovered.push({
2368
- port,
2369
- source,
2370
- status,
2371
- ...(wsUrl ? { wsUrl } : {}),
2372
- ...(note ? { note } : {}),
2373
- });
2374
- seenPorts.add(port);
2375
- };
2376
- const preferredWsUrl = await this.probeDebugPort(preferredPort);
2377
- pushDiscovery(preferredPort, 'preferred-port', preferredWsUrl, preferredWsUrl ? 'pass' : 'fail');
2378
- this.addDoctorCheck(checks, 'cdp:preferred-9333', preferredWsUrl ? 'pass' : 'fail', preferredWsUrl
2379
- ? `CDP :${preferredPort} reachable`
2380
- : `CDP :${preferredPort} is not reachable via http://127.0.0.1:${preferredPort}/json/version`);
2381
- for (const port of [9222, 9229]) {
2382
- const wsUrl = await this.probeDebugPort(port);
2383
- pushDiscovery(port, 'common-port', wsUrl, wsUrl ? 'pass' : 'fail');
2384
- }
2385
- for (const userDataDir of this.getChromeUserDataDirs()) {
2386
- const activePort = this.readDevToolsActivePort(userDataDir);
2387
- if (!activePort) {
2388
- devToolsActivePort.push({
2389
- userDataDir,
2390
- status: 'skip',
2391
- });
2392
- continue;
2393
- }
2394
- devToolsActivePort.push({
2395
- userDataDir,
2396
- status: 'pass',
2397
- port: activePort.port,
2398
- wsPath: activePort.wsPath,
2399
- });
2400
- if (!seenPorts.has(activePort.port)) {
2401
- const wsUrl = await this.probeDebugPort(activePort.port);
2402
- pushDiscovery(activePort.port, 'devtools-active-port', wsUrl, wsUrl ? 'pass' : 'warn', wsUrl
2403
- ? 'resolved from DevToolsActivePort'
2404
- : 'DevToolsActivePort exists, but /json/version is unavailable (likely WS-only debug server)');
2405
- }
2406
- }
2407
- const reachableEndpoints = discovered.filter((entry) => entry.status === 'pass');
2408
- this.addDoctorCheck(checks, 'cdp:any-reachable-endpoint', reachableEndpoints.length > 0 ? 'pass' : 'fail', reachableEndpoints.length > 0
2409
- ? `Found ${reachableEndpoints.length} reachable CDP endpoint(s)`
2410
- : 'No reachable CDP endpoints found on preferred/common/local profile ports', {
2411
- endpoints: discovered.map((entry) => ({
2412
- port: entry.port,
2413
- source: entry.source,
2414
- status: entry.status,
2415
- })),
2416
- });
2417
- await this.runDoctorSourceUrlProbe(checks, launched);
2418
- const pluginIntent = this.buildDoctorTabGroupIntent();
2419
- const pluginResult = {
2420
- configuredPluginId: pluginIntent.pluginId,
2421
- status: 'skip',
2422
- mode: 'not-launched',
2423
- message: 'Browser is not launched; plugin handshake skipped',
2424
- };
2425
- if (!launched) {
2426
- this.addDoctorCheck(checks, 'plugin:handshake-context', 'skip', 'Browser is not launched; plugin context check skipped');
2427
- this.addDoctorCheck(checks, 'plugin:tab-group-handshake', pluginResult.status, pluginResult.message, { configuredPluginId: pluginIntent.pluginId });
2428
- }
2429
- else if (this.stealthConnectionKind !== 'cdp') {
2430
- this.addDoctorCheck(checks, 'plugin:handshake-context', 'skip', `Current connection mode is ${this.stealthConnectionKind}; plugin context check only applies to CDP`);
2431
- pluginResult.mode = 'non-cdp';
2432
- pluginResult.status = 'skip';
2433
- pluginResult.message = `Current connection mode is ${this.stealthConnectionKind}; plugin handshake only applies to CDP`;
2434
- this.addDoctorCheck(checks, 'plugin:tab-group-handshake', pluginResult.status, pluginResult.message, { configuredPluginId: pluginIntent.pluginId });
2435
- }
2436
- else {
2437
- pluginResult.mode = 'cdp';
2438
- try {
2439
- const page = this.getPage();
2440
- if (page.isClosed()) {
2441
- this.addDoctorCheck(checks, 'plugin:handshake-context', 'fail', 'Active page is closed; cannot test plugin handshake context');
2442
- pluginResult.status = 'fail';
2443
- pluginResult.message = 'Active page is closed; cannot run plugin handshake';
2444
- }
2445
- else if (!this.canInjectTabGroupScript(page)) {
2446
- this.addDoctorCheck(checks, 'plugin:handshake-context', 'warn', 'Active page is an internal browser page; open a normal http(s) page before testing plugin handshake', { url: this.getSafePageUrl(page) });
2447
- pluginResult.status = 'warn';
2448
- pluginResult.message =
2449
- 'Active page is an internal browser page; open a normal http(s) page to test plugin handshake';
2450
- }
2451
- else {
2452
- this.addDoctorCheck(checks, 'plugin:handshake-context', 'pass', 'Active page is a normal page; plugin handshake can be tested', { url: this.getSafePageUrl(page) });
2453
- const response = await this.requestTabGroupPlugin(page, pluginIntent);
2454
- if (!response) {
2455
- pluginResult.status = 'fail';
2456
- pluginResult.message = 'Plugin handshake timed out';
2457
- this.setTabGroupCapability(pluginIntent.session, 'unavailable');
2458
- }
2459
- else if (!response.ok) {
2460
- pluginResult.status = 'fail';
2461
- pluginResult.message = response.error
2462
- ? `Plugin handshake failed: ${response.error}`
2463
- : 'Plugin handshake failed';
2464
- this.setTabGroupCapability(pluginIntent.session, 'unavailable');
2465
- }
2466
- else if (response.extensionId !== pluginIntent.pluginId) {
2467
- pluginResult.status = 'fail';
2468
- pluginResult.message = `Plugin id mismatch: expected ${pluginIntent.pluginId}, got ${response.extensionId ?? 'missing'}`;
2469
- pluginResult.extensionId = response.extensionId;
2470
- this.setTabGroupCapability(pluginIntent.session, 'unavailable');
2471
- }
2472
- else {
2473
- pluginResult.status = 'pass';
2474
- pluginResult.message = 'Plugin handshake succeeded';
2475
- pluginResult.extensionId = response.extensionId;
2476
- this.setTabGroupCapability(pluginIntent.session, 'available');
2477
- }
2478
- }
2479
- }
2480
- catch (error) {
2481
- pluginResult.status = 'fail';
2482
- pluginResult.message =
2483
- error instanceof Error ? error.message : `Plugin handshake failed: ${String(error)}`;
2484
- }
2485
- this.addDoctorCheck(checks, 'plugin:tab-group-handshake', pluginResult.status, pluginResult.message, {
2486
- configuredPluginId: pluginIntent.pluginId,
2487
- ...(pluginResult.extensionId ? { extensionId: pluginResult.extensionId } : {}),
2488
- });
2489
- }
2490
- const ok = checks.every((check) => check.status !== 'fail');
2491
- return {
2492
- ok,
2493
- checks,
2494
- context: {
2495
- launched: this.isLaunched(),
2496
- connectionKind: this.stealthConnectionKind,
2497
- cdpEndpoint: this.cdpEndpoint,
2498
- session: this.getAgentSessionName(),
2499
- },
2500
- cdp: {
2501
- preferredPort,
2502
- discovered,
2503
- devToolsActivePort,
2504
- },
2505
- plugin: pluginResult,
2506
- };
2507
- }
2508
- /**
2509
- * Set up console, error, and close tracking for a page
2510
- */
2511
- setupPageTracking(page) {
2512
- if (this.colorScheme) {
2513
- page.emulateMedia({ colorScheme: this.colorScheme }).catch(() => { });
2514
- }
2515
- page.on('console', (msg) => {
2516
- this.consoleMessages.push({
2517
- type: msg.type(),
2518
- text: msg.text(),
2519
- timestamp: Date.now(),
2520
- });
2521
- });
2522
- page.on('pageerror', (error) => {
2523
- this.pageErrors.push({
2524
- message: error.message,
2525
- timestamp: Date.now(),
2526
- });
2527
- });
2528
- page.on('close', () => {
2529
- const index = this.pages.indexOf(page);
2530
- if (index !== -1) {
2531
- this.pages.splice(index, 1);
2532
- if (index < this.activePageIndex) {
2533
- this.activePageIndex--;
2534
- }
2535
- if (this.activePageIndex >= this.pages.length) {
2536
- this.activePageIndex = Math.max(0, this.pages.length - 1);
2537
- }
2538
- }
2539
- });
2540
- }
2541
- /**
2542
- * Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
2543
- * This handles pages created externally (e.g., via target="_blank" links, window.open)
2544
- */
2545
- setupContextTracking(context) {
2546
- context.on('page', (page) => {
2547
- const pageUrl = this.getSafePageUrl(page);
2548
- if (this.isIgnoredCDPPageUrl(pageUrl)) {
2549
- return;
2550
- }
2551
- // Only add if not already tracked (avoids duplicates when newTab() creates pages)
2552
- if (!this.pages.includes(page)) {
2553
- this.pages.push(page);
2554
- this.setupPageTracking(page);
2555
- }
2556
- // Auto-switch to the newly opened tab so subsequent commands target it.
2557
- // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
2558
- // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
2559
- // stays in sync with the browser.
2560
- const newIndex = this.pages.indexOf(page);
2561
- if (newIndex !== -1 && newIndex !== this.activePageIndex) {
2562
- this.activePageIndex = newIndex;
2563
- // Invalidate CDP session since the active page changed
2564
- this.invalidateCDPSession().catch(() => { });
2565
- }
2566
- this.scheduleTabGrouping(page, 'context-page');
2567
- });
2568
- }
2569
- /**
2570
- * Create a new tab in the current context
2571
- */
2572
- async newTab() {
2573
- if (!this.browser || this.contexts.length === 0) {
2574
- throw new Error('Browser not launched');
2575
- }
2576
- // Invalidate CDP session since we're switching to a new page
2577
- await this.invalidateCDPSession();
2578
- const context = this.contexts[0]; // Use first context for tabs
2579
- const page = await context.newPage();
2580
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
2581
- if (!this.pages.includes(page)) {
2582
- this.pages.push(page);
2583
- this.setupPageTracking(page);
2584
- }
2585
- this.activePageIndex = this.pages.length - 1;
2586
- this.scheduleTabGrouping(page, 'new-tab');
2587
- return { index: this.activePageIndex, total: this.pages.length };
2588
- }
2589
- /**
2590
- * Create a new window (new context)
2591
- */
2592
- async newWindow(viewport) {
2593
- if (!this.browser) {
2594
- throw new Error('Browser not launched');
2595
- }
2596
- const context = await this.browser.newContext({
2597
- viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
2598
- ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }),
2599
- ...(this.contextUserAgent && { userAgent: this.contextUserAgent }),
2600
- ...(this.contextLocale && { locale: this.contextLocale }),
2601
- ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }),
2602
- ...(this.colorScheme && { colorScheme: this.colorScheme }),
2603
- });
2604
- await this.applyStealthIfEnabled(context, { locale: this.contextLocale });
2605
- context.setDefaultTimeout(getDefaultTimeout());
2606
- this.contexts.push(context);
2607
- this.setupContextTracking(context);
2608
- await this.ensureDomainFilter(context);
2609
- const page = await context.newPage();
2610
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
2611
- if (!this.pages.includes(page)) {
2612
- this.pages.push(page);
2613
- this.setupPageTracking(page);
2614
- }
2615
- this.activePageIndex = this.pages.length - 1;
2616
- return { index: this.activePageIndex, total: this.pages.length };
2617
- }
2618
- /**
2619
- * Invalidate the current CDP session (must be called before switching pages)
2620
- * This ensures screencast and input injection work correctly after tab switch
2621
- */
2622
- async invalidateCDPSession() {
2623
- // Stop screencast if active (it's tied to the current page's CDP session)
2624
- if (this.screencastActive) {
2625
- await this.stopScreencast();
2626
- }
2627
- // Detach and clear the CDP session
2628
- if (this.cdpSession) {
2629
- await this.cdpSession.detach().catch(() => { });
2630
- this.cdpSession = null;
2631
- }
2632
- }
2633
- /**
2634
- * Switch to a specific tab/page by index
2635
- */
2636
- async switchTo(index) {
2637
- if (index < 0 || index >= this.pages.length) {
2638
- throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
2639
- }
2640
- // Invalidate CDP session before switching (it's page-specific)
2641
- if (index !== this.activePageIndex) {
2642
- await this.invalidateCDPSession();
2643
- }
2644
- this.activePageIndex = index;
2645
- const page = this.pages[index];
2646
- return {
2647
- index: this.activePageIndex,
2648
- url: page.url(),
2649
- title: '', // Title requires async, will be fetched separately
2650
- };
2651
- }
2652
- /**
2653
- * Close a specific tab/page
2654
- */
2655
- async closeTab(index) {
2656
- const targetIndex = index ?? this.activePageIndex;
2657
- if (targetIndex < 0 || targetIndex >= this.pages.length) {
2658
- throw new Error(`Invalid tab index: ${targetIndex}`);
2659
- }
2660
- if (this.pages.length === 1) {
2661
- throw new Error('Cannot close the last tab. Use "close" to close the browser.');
2662
- }
2663
- // If closing the active tab, invalidate CDP session first
2664
- if (targetIndex === this.activePageIndex) {
2665
- await this.invalidateCDPSession();
2666
- }
2667
- const page = this.pages[targetIndex];
2668
- await page.close();
2669
- this.pages.splice(targetIndex, 1);
2670
- // Adjust active index if needed
2671
- if (this.activePageIndex >= this.pages.length) {
2672
- this.activePageIndex = this.pages.length - 1;
2673
- }
2674
- else if (this.activePageIndex > targetIndex) {
2675
- this.activePageIndex--;
2676
- }
2677
- return { closed: targetIndex, remaining: this.pages.length };
2678
- }
2679
- /**
2680
- * List all tabs with their info
2681
- */
2682
- async listTabs() {
2683
- const tabs = await Promise.all(this.pages.map(async (page, index) => ({
2684
- index,
2685
- url: page.url(),
2686
- title: await page.title().catch(() => ''),
2687
- active: index === this.activePageIndex,
2688
- })));
2689
- return tabs;
2690
- }
2691
- /**
2692
- * Get or create a CDP session for the current page
2693
- * Only works with Chromium-based browsers
2694
- */
2695
- async getCDPSession() {
2696
- if (this.cdpSession) {
2697
- return this.cdpSession;
2698
- }
2699
- const page = this.getPage();
2700
- const context = page.context();
2701
- // Create a new CDP session attached to the page
2702
- this.cdpSession = wrapCDPSessionSourceUrlSanitizer(await context.newCDPSession(page));
2703
- return this.cdpSession;
2704
- }
2705
- /**
2706
- * Check if screencast is currently active
2707
- */
2708
- isScreencasting() {
2709
- return this.screencastActive;
2710
- }
2711
- /**
2712
- * Start screencast - streams viewport frames via CDP
2713
- * @param callback Function called for each frame
2714
- * @param options Screencast options
2715
- */
2716
- async startScreencast(callback, options) {
2717
- if (this.screencastActive) {
2718
- throw new Error('Screencast already active');
2719
- }
2720
- const cdp = await this.getCDPSession();
2721
- this.frameCallback = callback;
2722
- this.screencastActive = true;
2723
- // Create and store the frame handler so we can remove it later
2724
- this.screencastFrameHandler = async (params) => {
2725
- const frame = {
2726
- data: params.data,
2727
- metadata: params.metadata,
2728
- sessionId: params.sessionId,
2729
- };
2730
- // Acknowledge the frame to receive the next one
2731
- await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
2732
- // Call the callback with the frame
2733
- if (this.frameCallback) {
2734
- this.frameCallback(frame);
2735
- }
2736
- };
2737
- // Listen for screencast frames
2738
- cdp.on('Page.screencastFrame', this.screencastFrameHandler);
2739
- // Start the screencast
2740
- await cdp.send('Page.startScreencast', {
2741
- format: options?.format ?? 'jpeg',
2742
- quality: options?.quality ?? 80,
2743
- maxWidth: options?.maxWidth ?? 1280,
2744
- maxHeight: options?.maxHeight ?? 720,
2745
- everyNthFrame: options?.everyNthFrame ?? 1,
2746
- });
2747
- }
2748
- /**
2749
- * Stop screencast
2750
- */
2751
- async stopScreencast() {
2752
- if (!this.screencastActive) {
2753
- return;
2754
- }
2755
- try {
2756
- const cdp = await this.getCDPSession();
2757
- await cdp.send('Page.stopScreencast');
2758
- // Remove the event listener to prevent accumulation
2759
- if (this.screencastFrameHandler) {
2760
- cdp.off('Page.screencastFrame', this.screencastFrameHandler);
2761
- }
2762
- }
2763
- catch {
2764
- // Ignore errors when stopping
2765
- }
2766
- this.screencastActive = false;
2767
- this.frameCallback = null;
2768
- this.screencastFrameHandler = null;
2769
- }
2770
- /**
2771
- * Check if profiling is currently active
2772
- */
2773
- isProfilingActive() {
2774
- return this.profilingActive;
2775
- }
2776
- /**
2777
- * Start CDP profiling (Tracing)
2778
- */
2779
- async startProfiling(options) {
2780
- if (this.profilingActive) {
2781
- throw new Error('Profiling already active');
2782
- }
2783
- const cdp = await this.getCDPSession();
2784
- const dataHandler = (params) => {
2785
- if (params.value) {
2786
- for (const evt of params.value) {
2787
- if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) {
2788
- if (!this.profileEventsDropped) {
2789
- this.profileEventsDropped = true;
2790
- console.warn(`Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data`);
2791
- }
2792
- return;
2793
- }
2794
- this.profileChunks.push(evt);
2795
- }
2796
- }
2797
- };
2798
- const completeHandler = () => {
2799
- if (this.profileCompleteResolver) {
2800
- this.profileCompleteResolver();
2801
- }
2802
- };
2803
- cdp.on('Tracing.dataCollected', dataHandler);
2804
- cdp.on('Tracing.tracingComplete', completeHandler);
2805
- const categories = options?.categories ?? [
2806
- 'devtools.timeline',
2807
- 'disabled-by-default-devtools.timeline',
2808
- 'disabled-by-default-devtools.timeline.frame',
2809
- 'disabled-by-default-devtools.timeline.stack',
2810
- 'v8.execute',
2811
- 'disabled-by-default-v8.cpu_profiler',
2812
- 'disabled-by-default-v8.cpu_profiler.hires',
2813
- 'v8',
2814
- 'disabled-by-default-v8.runtime_stats',
2815
- 'blink',
2816
- 'blink.user_timing',
2817
- 'latencyInfo',
2818
- 'renderer.scheduler',
2819
- 'sequence_manager',
2820
- 'toplevel',
2821
- ];
2822
- try {
2823
- await cdp.send('Tracing.start', {
2824
- traceConfig: {
2825
- includedCategories: categories,
2826
- enableSampling: true,
2827
- },
2828
- transferMode: 'ReportEvents',
2829
- });
2830
- }
2831
- catch (error) {
2832
- cdp.off('Tracing.dataCollected', dataHandler);
2833
- cdp.off('Tracing.tracingComplete', completeHandler);
2834
- throw error;
2835
- }
2836
- // Only commit state after the CDP call succeeds
2837
- this.profilingActive = true;
2838
- this.profileChunks = [];
2839
- this.profileEventsDropped = false;
2840
- this.profileDataHandler = dataHandler;
2841
- this.profileCompleteHandler = completeHandler;
2842
- }
2843
- /**
2844
- * Stop CDP profiling and save to file
2845
- */
2846
- async stopProfiling(outputPath) {
2847
- if (!this.profilingActive) {
2848
- throw new Error('No profiling session active');
2849
- }
2850
- const cdp = await this.getCDPSession();
2851
- const TRACE_TIMEOUT_MS = 30_000;
2852
- const completePromise = new Promise((resolve, reject) => {
2853
- const timer = setTimeout(() => reject(new Error('Profiling data collection timed out')), TRACE_TIMEOUT_MS);
2854
- this.profileCompleteResolver = () => {
2855
- clearTimeout(timer);
2856
- resolve();
2857
- };
2858
- });
2859
- await cdp.send('Tracing.end');
2860
- let chunks;
2861
- try {
2862
- await completePromise;
2863
- chunks = this.profileChunks;
2864
- }
2865
- finally {
2866
- if (this.profileDataHandler) {
2867
- cdp.off('Tracing.dataCollected', this.profileDataHandler);
2868
- }
2869
- if (this.profileCompleteHandler) {
2870
- cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
2871
- }
2872
- this.profilingActive = false;
2873
- this.profileChunks = [];
2874
- this.profileEventsDropped = false;
2875
- this.profileCompleteResolver = null;
2876
- this.profileDataHandler = null;
2877
- this.profileCompleteHandler = null;
2878
- }
2879
- const clockDomain = process.platform === 'linux'
2880
- ? 'LINUX_CLOCK_MONOTONIC'
2881
- : process.platform === 'darwin'
2882
- ? 'MAC_MACH_ABSOLUTE_TIME'
2883
- : undefined;
2884
- const traceData = {
2885
- traceEvents: chunks,
2886
- };
2887
- if (clockDomain) {
2888
- traceData.metadata = { 'clock-domain': clockDomain };
2889
- }
2890
- const dir = path.dirname(outputPath);
2891
- await mkdir(dir, { recursive: true });
2892
- await writeFile(outputPath, JSON.stringify(traceData));
2893
- const eventCount = chunks.length;
2894
- return { path: outputPath, eventCount };
2895
- }
2896
- /**
2897
- * Inject a mouse event via CDP
2898
- */
2899
- async injectMouseEvent(params) {
2900
- const cdp = await this.getCDPSession();
2901
- const cdpButton = params.button === 'left'
2902
- ? 'left'
2903
- : params.button === 'right'
2904
- ? 'right'
2905
- : params.button === 'middle'
2906
- ? 'middle'
2907
- : 'none';
2908
- await cdp.send('Input.dispatchMouseEvent', {
2909
- type: params.type,
2910
- x: params.x,
2911
- y: params.y,
2912
- button: cdpButton,
2913
- clickCount: params.clickCount ?? 1,
2914
- deltaX: params.deltaX ?? 0,
2915
- deltaY: params.deltaY ?? 0,
2916
- modifiers: params.modifiers ?? 0,
2917
- });
2918
- }
2919
- /**
2920
- * Inject a keyboard event via CDP
2921
- */
2922
- async injectKeyboardEvent(params) {
2923
- const cdp = await this.getCDPSession();
2924
- await cdp.send('Input.dispatchKeyEvent', {
2925
- type: params.type,
2926
- key: params.key,
2927
- code: params.code,
2928
- text: params.text,
2929
- modifiers: params.modifiers ?? 0,
2930
- });
2931
- }
2932
- /**
2933
- * Inject touch event via CDP (for mobile emulation)
2934
- */
2935
- async injectTouchEvent(params) {
2936
- const cdp = await this.getCDPSession();
2937
- await cdp.send('Input.dispatchTouchEvent', {
2938
- type: params.type,
2939
- touchPoints: params.touchPoints.map((tp, i) => ({
2940
- x: tp.x,
2941
- y: tp.y,
2942
- id: tp.id ?? i,
2943
- })),
2944
- modifiers: params.modifiers ?? 0,
2945
- });
2946
- }
2947
- /**
2948
- * Check if video recording is currently active
2949
- */
2950
- isRecording() {
2951
- return this.recordingContext !== null;
2952
- }
2953
- /**
2954
- * Start recording to a video file using Playwright's native video recording.
2955
- * Creates a fresh browser context with video recording enabled.
2956
- * Automatically captures current URL and transfers cookies/storage if no URL provided.
2957
- *
2958
- * @param outputPath - Path to the output video file (will be .webm)
2959
- * @param url - Optional URL to navigate to (defaults to current page URL)
2960
- */
2961
- async startRecording(outputPath, url) {
2962
- if (this.recordingContext) {
2963
- throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
2964
- }
2965
- if (!this.browser) {
2966
- throw new Error('Browser not launched. Call launch first.');
2967
- }
2968
- // Check if output file already exists
2969
- if (existsSync(outputPath)) {
2970
- throw new Error(`Output file already exists: ${outputPath}`);
2971
- }
2972
- // Validate output path is .webm (Playwright native format)
2973
- if (!outputPath.endsWith('.webm')) {
2974
- throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
2975
- }
2976
- // Auto-capture current URL if none provided
2977
- const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
2978
- const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
2979
- if (!url && currentPage) {
2980
- const currentUrl = currentPage.url();
2981
- if (currentUrl && currentUrl !== 'about:blank') {
2982
- url = currentUrl;
2983
- }
2984
- }
2985
- // Capture state from current context (cookies + storage)
2986
- let storageState;
2987
- if (currentContext) {
2988
- try {
2989
- storageState = await currentContext.storageState();
2990
- }
2991
- catch {
2992
- // Ignore errors - context might be closed or invalid
2993
- }
2994
- }
2995
- // Create a temp directory for video recording
2996
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
2997
- this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
2998
- mkdirSync(this.recordingTempDir, { recursive: true });
2999
- this.recordingOutputPath = outputPath;
3000
- // Create a new context with video recording enabled and restored state
3001
- const viewport = { width: 1280, height: 720 };
3002
- this.recordingContext = await this.browser.newContext({
3003
- viewport,
3004
- recordVideo: {
3005
- dir: this.recordingTempDir,
3006
- size: viewport,
3007
- },
3008
- storageState,
3009
- });
3010
- this.recordingContext.setDefaultTimeout(10000);
3011
- // Create a page in the recording context
3012
- this.recordingPage = await this.recordingContext.newPage();
3013
- // Add the recording context and page to our managed lists
3014
- this.contexts.push(this.recordingContext);
3015
- this.pages.push(this.recordingPage);
3016
- this.activePageIndex = this.pages.length - 1;
3017
- // Set up page tracking
3018
- this.setupPageTracking(this.recordingPage);
3019
- // Invalidate CDP session since we switched pages
3020
- await this.invalidateCDPSession();
3021
- // Navigate to URL if provided or captured
3022
- if (url) {
3023
- await this.recordingPage.goto(url, { waitUntil: 'load' });
3024
- }
3025
- }
3026
- /**
3027
- * Stop recording and save the video file
3028
- * @returns Recording result with path
3029
- */
3030
- async stopRecording() {
3031
- if (!this.recordingContext || !this.recordingPage) {
3032
- return { path: '', frames: 0, error: 'No recording in progress' };
3033
- }
3034
- const outputPath = this.recordingOutputPath;
3035
- try {
3036
- // Get the video object before closing the page
3037
- const video = this.recordingPage.video();
3038
- // Remove recording page/context from our managed lists before closing
3039
- const pageIndex = this.pages.indexOf(this.recordingPage);
3040
- if (pageIndex !== -1) {
3041
- this.pages.splice(pageIndex, 1);
3042
- }
3043
- const contextIndex = this.contexts.indexOf(this.recordingContext);
3044
- if (contextIndex !== -1) {
3045
- this.contexts.splice(contextIndex, 1);
3046
- }
3047
- // Close the page to finalize the video
3048
- await this.recordingPage.close();
3049
- // Save the video to the desired output path
3050
- if (video) {
3051
- await video.saveAs(outputPath);
3052
- }
3053
- // Clean up temp directory
3054
- if (this.recordingTempDir) {
3055
- rmSync(this.recordingTempDir, { recursive: true, force: true });
3056
- }
3057
- // Close the recording context
3058
- await this.recordingContext.close();
3059
- // Reset recording state
3060
- this.recordingContext = null;
3061
- this.recordingPage = null;
3062
- this.recordingOutputPath = '';
3063
- this.recordingTempDir = '';
3064
- // Adjust active page index
3065
- if (this.pages.length > 0) {
3066
- this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
3067
- }
3068
- else {
3069
- this.activePageIndex = 0;
3070
- }
3071
- // Invalidate CDP session since we may have switched pages
3072
- await this.invalidateCDPSession();
3073
- return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
3074
- }
3075
- catch (error) {
3076
- // Clean up temp directory on error
3077
- if (this.recordingTempDir) {
3078
- rmSync(this.recordingTempDir, { recursive: true, force: true });
3079
- }
3080
- // Reset state on error
3081
- this.recordingContext = null;
3082
- this.recordingPage = null;
3083
- this.recordingOutputPath = '';
3084
- this.recordingTempDir = '';
3085
- const message = error instanceof Error ? error.message : String(error);
3086
- return { path: outputPath, frames: 0, error: message };
3087
- }
3088
- }
3089
- /**
3090
- * Restart recording - stops current recording (if any) and starts a new one.
3091
- * Convenience method that combines stopRecording and startRecording.
3092
- *
3093
- * @param outputPath - Path to the output video file (must be .webm)
3094
- * @param url - Optional URL to navigate to (defaults to current page URL)
3095
- * @returns Result from stopping the previous recording (if any)
3096
- */
3097
- async restartRecording(outputPath, url) {
3098
- let previousPath;
3099
- let stopped = false;
3100
- // Stop current recording if active
3101
- if (this.recordingContext) {
3102
- const result = await this.stopRecording();
3103
- previousPath = result.path;
3104
- stopped = true;
3105
- }
3106
- // Start new recording
3107
- await this.startRecording(outputPath, url);
3108
- return { previousPath, stopped };
3109
- }
3110
- /**
3111
- * Close the browser and clean up
3112
- */
3113
- async close() {
3114
- // Stop recording if active (saves video)
3115
- if (this.recordingContext) {
3116
- await this.stopRecording();
3117
- }
3118
- // Stop screencast if active
3119
- if (this.screencastActive) {
3120
- await this.stopScreencast();
3121
- }
3122
- // Clean up profiling state if active (without saving)
3123
- if (this.profilingActive) {
3124
- const cdp = this.cdpSession;
3125
- if (cdp) {
3126
- if (this.profileDataHandler) {
3127
- cdp.off('Tracing.dataCollected', this.profileDataHandler);
3128
- }
3129
- if (this.profileCompleteHandler) {
3130
- cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
3131
- }
3132
- await cdp.send('Tracing.end').catch(() => { });
3133
- }
3134
- this.profilingActive = false;
3135
- this.profileChunks = [];
3136
- this.profileEventsDropped = false;
3137
- this.profileCompleteResolver = null;
3138
- this.profileDataHandler = null;
3139
- this.profileCompleteHandler = null;
3140
- }
3141
- // Clean up CDP session
3142
- if (this.cdpSession) {
3143
- await this.cdpSession.detach().catch(() => { });
3144
- this.cdpSession = null;
3145
- }
3146
- if (this.browserbaseSessionId && this.browserbaseApiKey) {
3147
- await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch((error) => {
3148
- console.error('Failed to close Browserbase session:', error);
3149
- });
3150
- this.browser = null;
3151
- }
3152
- else if (this.browserUseSessionId && this.browserUseApiKey) {
3153
- await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch((error) => {
3154
- console.error('Failed to close Browser Use session:', error);
3155
- });
3156
- this.browser = null;
3157
- }
3158
- else if (this.kernelSessionId && this.kernelApiKey) {
3159
- await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => {
3160
- console.error('Failed to close Kernel session:', error);
3161
- });
3162
- this.browser = null;
3163
- }
3164
- else if (this.cdpEndpoint !== null) {
3165
- // CDP: only disconnect, don't close external app's pages
3166
- if (this.browser) {
3167
- await this.browser.close().catch(() => { });
3168
- this.browser = null;
3169
- }
3170
- }
3171
- else {
3172
- // Regular browser: close everything
3173
- for (const page of this.pages) {
3174
- await page.close().catch(() => { });
3175
- }
3176
- for (const context of this.contexts) {
3177
- await context.close().catch(() => { });
3178
- }
3179
- if (this.browser) {
3180
- await this.browser.close().catch(() => { });
3181
- this.browser = null;
3182
- }
3183
- }
3184
- this.pages = [];
3185
- this.contexts = [];
3186
- this.cdpEndpoint = null;
3187
- this.browserbaseSessionId = null;
3188
- this.browserbaseApiKey = null;
3189
- this.browserUseSessionId = null;
3190
- this.browserUseApiKey = null;
3191
- this.kernelSessionId = null;
3192
- this.kernelApiKey = null;
3193
- this.isPersistentContext = false;
3194
- this.activePageIndex = 0;
3195
- this.colorScheme = null;
3196
- this.stealthEnabled = true;
3197
- this.stealthConnectionKind = 'local';
3198
- this.contextLocale = undefined;
3199
- this.contextTimezoneId = undefined;
3200
- this.contextHeaders = undefined;
3201
- this.contextUserAgent = undefined;
3202
- this.tabGroupIntent = null;
3203
- this.tabGroupCapabilityBySession.clear();
3204
- this.tabGroupInFlight = new WeakSet();
3205
- this.refMap = {};
3206
- this.lastSnapshot = '';
3207
- this.frameCallback = null;
3208
- }
3209
- }
3210
- //# sourceMappingURL=browser.js.map