@kritchoff/agent-browser 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +8 -1
  2. package/dist/cli.cjs +654 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.d.cts +1 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +627 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/daemon.cjs +5738 -0
  9. package/dist/daemon.cjs.map +1 -0
  10. package/dist/daemon.d.cts +60 -0
  11. package/dist/daemon.d.ts +14 -13
  12. package/dist/daemon.js +5675 -387
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/index.cjs +630 -0
  15. package/dist/index.cjs.map +1 -0
  16. package/dist/index.d.cts +128 -0
  17. package/dist/index.d.ts +82 -10
  18. package/dist/index.js +577 -234
  19. package/dist/index.js.map +1 -1
  20. package/docker-compose.sdk.yml +2 -1
  21. package/package.json +28 -6
  22. package/bin/agent-browser.js +0 -70
  23. package/dist/actions.d.ts +0 -17
  24. package/dist/actions.d.ts.map +0 -1
  25. package/dist/actions.js +0 -1452
  26. package/dist/actions.js.map +0 -1
  27. package/dist/browser.d.ts +0 -478
  28. package/dist/browser.d.ts.map +0 -1
  29. package/dist/browser.js +0 -1577
  30. package/dist/browser.js.map +0 -1
  31. package/dist/cdp-client.d.ts +0 -103
  32. package/dist/cdp-client.d.ts.map +0 -1
  33. package/dist/cdp-client.js +0 -223
  34. package/dist/cdp-client.js.map +0 -1
  35. package/dist/daemon.d.ts.map +0 -1
  36. package/dist/dualmode-config.d.ts +0 -37
  37. package/dist/dualmode-config.d.ts.map +0 -1
  38. package/dist/dualmode-config.js +0 -44
  39. package/dist/dualmode-config.js.map +0 -1
  40. package/dist/dualmode-fetcher.d.ts +0 -60
  41. package/dist/dualmode-fetcher.d.ts.map +0 -1
  42. package/dist/dualmode-fetcher.js +0 -449
  43. package/dist/dualmode-fetcher.js.map +0 -1
  44. package/dist/dualmode-types.d.ts +0 -183
  45. package/dist/dualmode-types.d.ts.map +0 -1
  46. package/dist/dualmode-types.js +0 -8
  47. package/dist/dualmode-types.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/ios-actions.d.ts +0 -11
  50. package/dist/ios-actions.d.ts.map +0 -1
  51. package/dist/ios-actions.js +0 -228
  52. package/dist/ios-actions.js.map +0 -1
  53. package/dist/ios-manager.d.ts +0 -266
  54. package/dist/ios-manager.d.ts.map +0 -1
  55. package/dist/ios-manager.js +0 -1073
  56. package/dist/ios-manager.js.map +0 -1
  57. package/dist/orchestrator.d.ts +0 -15
  58. package/dist/orchestrator.d.ts.map +0 -1
  59. package/dist/orchestrator.js +0 -265
  60. package/dist/orchestrator.js.map +0 -1
  61. package/dist/protocol.d.ts +0 -26
  62. package/dist/protocol.d.ts.map +0 -1
  63. package/dist/protocol.js +0 -832
  64. package/dist/protocol.js.map +0 -1
  65. package/dist/snapshot.d.ts +0 -76
  66. package/dist/snapshot.d.ts.map +0 -1
  67. package/dist/snapshot.js +0 -492
  68. package/dist/snapshot.js.map +0 -1
  69. package/dist/stream-server.d.ts +0 -117
  70. package/dist/stream-server.d.ts.map +0 -1
  71. package/dist/stream-server.js +0 -305
  72. package/dist/stream-server.js.map +0 -1
  73. package/dist/taxtree.d.ts +0 -6
  74. package/dist/taxtree.d.ts.map +0 -1
  75. package/dist/taxtree.js +0 -287
  76. package/dist/taxtree.js.map +0 -1
  77. package/dist/types.d.ts +0 -742
  78. package/dist/types.d.ts.map +0 -1
  79. package/dist/types.js +0 -2
  80. package/dist/types.js.map +0 -1
package/dist/browser.js DELETED
@@ -1,1577 +0,0 @@
1
- import { chromium, firefox, webkit, devices, } from 'playwright-core';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
5
- import { parseRef } from './snapshot.js';
6
- import { getDualModeSnapshot } from './taxtree.js';
7
- /**
8
- * Manages the Playwright browser lifecycle with multiple tabs/windows
9
- */
10
- export class BrowserManager {
11
- browser = null;
12
- cdpEndpoint = null; // stores port number or full URL
13
- isPersistentContext = false;
14
- browserbaseSessionId = null;
15
- browserbaseApiKey = null;
16
- browserUseSessionId = null;
17
- browserUseApiKey = null;
18
- kernelSessionId = null;
19
- kernelApiKey = null;
20
- contexts = [];
21
- pages = [];
22
- activePageIndex = 0;
23
- activeFrame = null;
24
- dialogHandler = null;
25
- trackedRequests = [];
26
- routes = new Map();
27
- consoleMessages = [];
28
- pageErrors = [];
29
- isRecordingHar = false;
30
- refMap = {};
31
- lastSnapshot = '';
32
- scopedHeaderRoutes = new Map();
33
- // CDP session for screencast and input injection
34
- cdpSession = null;
35
- screencastActive = false;
36
- screencastSessionId = 0;
37
- frameCallback = null;
38
- screencastFrameHandler = null;
39
- // Video recording (Playwright native)
40
- recordingContext = null;
41
- recordingPage = null;
42
- recordingOutputPath = '';
43
- recordingTempDir = '';
44
- /**
45
- * Check if browser is launched
46
- */
47
- isLaunched() {
48
- return this.browser !== null || this.isPersistentContext;
49
- }
50
- /**
51
- * Get enhanced snapshot with refs and cache the ref map
52
- */
53
- async getSnapshot(options) {
54
- const page = this.getPage();
55
- // Use DualMode AXTree (WootzApp) instead of Playwright's ariaSnapshot
56
- const snapshot = await getDualModeSnapshot(page, options);
57
- this.refMap = snapshot.refs;
58
- this.lastSnapshot = snapshot.tree;
59
- return snapshot;
60
- }
61
- /**
62
- * Get the cached ref map from last snapshot
63
- */
64
- getRefMap() {
65
- return this.refMap;
66
- }
67
- /**
68
- * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
69
- * Returns null if ref doesn't exist or is invalid
70
- */
71
- getLocatorFromRef(refArg) {
72
- const ref = parseRef(refArg);
73
- if (!ref)
74
- return null;
75
- const refData = this.refMap[ref];
76
- if (!refData)
77
- return null;
78
- const page = this.getPage();
79
- // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role)
80
- // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector
81
- if (refData.role === 'clickable' || refData.role === 'focusable') {
82
- // The selector is a CSS selector, use it directly
83
- return page.locator(refData.selector);
84
- }
85
- // Build locator with exact: true to avoid substring matches
86
- let locator;
87
- if (refData.name) {
88
- locator = page.getByRole(refData.role, { name: refData.name, exact: true });
89
- }
90
- else {
91
- locator = page.getByRole(refData.role);
92
- }
93
- // If an nth index is stored (for disambiguation), use it
94
- if (refData.nth !== undefined) {
95
- locator = locator.nth(refData.nth);
96
- }
97
- return locator;
98
- }
99
- /**
100
- * Check if a selector looks like a ref
101
- */
102
- isRef(selector) {
103
- return parseRef(selector) !== null;
104
- }
105
- /**
106
- * Get raw ref data (including bounds/nodeId)
107
- */
108
- getRefData(refArg) {
109
- const ref = parseRef(refArg);
110
- if (!ref)
111
- return null;
112
- return this.refMap[ref] || null;
113
- }
114
- /**
115
- * Get locator - supports both refs and regular selectors
116
- */
117
- getLocator(selectorOrRef) {
118
- // Check if it's a ref first
119
- const locator = this.getLocatorFromRef(selectorOrRef);
120
- if (locator)
121
- return locator;
122
- // Otherwise treat as regular selector
123
- const page = this.getPage();
124
- return page.locator(selectorOrRef);
125
- }
126
- /**
127
- * Get the current active page, throws if not launched
128
- */
129
- getPage() {
130
- if (this.pages.length === 0) {
131
- throw new Error('Browser not launched. Call launch first.');
132
- }
133
- return this.pages[this.activePageIndex];
134
- }
135
- /**
136
- * Get the current frame (or page's main frame if no frame is selected)
137
- */
138
- getFrame() {
139
- if (this.activeFrame) {
140
- return this.activeFrame;
141
- }
142
- return this.getPage().mainFrame();
143
- }
144
- /**
145
- * Switch to a frame by selector, name, or URL
146
- */
147
- async switchToFrame(options) {
148
- const page = this.getPage();
149
- if (options.selector) {
150
- const frameElement = await page.$(options.selector);
151
- if (!frameElement) {
152
- throw new Error(`Frame not found: ${options.selector}`);
153
- }
154
- const frame = await frameElement.contentFrame();
155
- if (!frame) {
156
- throw new Error(`Element is not a frame: ${options.selector}`);
157
- }
158
- this.activeFrame = frame;
159
- }
160
- else if (options.name) {
161
- const frame = page.frame({ name: options.name });
162
- if (!frame) {
163
- throw new Error(`Frame not found with name: ${options.name}`);
164
- }
165
- this.activeFrame = frame;
166
- }
167
- else if (options.url) {
168
- const frame = page.frame({ url: options.url });
169
- if (!frame) {
170
- throw new Error(`Frame not found with URL: ${options.url}`);
171
- }
172
- this.activeFrame = frame;
173
- }
174
- }
175
- /**
176
- * Switch back to main frame
177
- */
178
- switchToMainFrame() {
179
- this.activeFrame = null;
180
- }
181
- /**
182
- * Set up dialog handler
183
- */
184
- setDialogHandler(response, promptText) {
185
- const page = this.getPage();
186
- // Remove existing handler if any
187
- if (this.dialogHandler) {
188
- page.removeListener('dialog', this.dialogHandler);
189
- }
190
- this.dialogHandler = async (dialog) => {
191
- if (response === 'accept') {
192
- await dialog.accept(promptText);
193
- }
194
- else {
195
- await dialog.dismiss();
196
- }
197
- };
198
- page.on('dialog', this.dialogHandler);
199
- }
200
- /**
201
- * Clear dialog handler
202
- */
203
- clearDialogHandler() {
204
- if (this.dialogHandler) {
205
- const page = this.getPage();
206
- page.removeListener('dialog', this.dialogHandler);
207
- this.dialogHandler = null;
208
- }
209
- }
210
- /**
211
- * Start tracking requests
212
- */
213
- startRequestTracking() {
214
- const page = this.getPage();
215
- page.on('request', (request) => {
216
- this.trackedRequests.push({
217
- url: request.url(),
218
- method: request.method(),
219
- headers: request.headers(),
220
- timestamp: Date.now(),
221
- resourceType: request.resourceType(),
222
- });
223
- });
224
- }
225
- /**
226
- * Get tracked requests
227
- */
228
- getRequests(filter) {
229
- if (filter) {
230
- return this.trackedRequests.filter((r) => r.url.includes(filter));
231
- }
232
- return this.trackedRequests;
233
- }
234
- /**
235
- * Clear tracked requests
236
- */
237
- clearRequests() {
238
- this.trackedRequests = [];
239
- }
240
- /**
241
- * Add a route to intercept requests
242
- */
243
- async addRoute(url, options) {
244
- const page = this.getPage();
245
- const handler = async (route) => {
246
- if (options.abort) {
247
- await route.abort();
248
- }
249
- else if (options.response) {
250
- await route.fulfill({
251
- status: options.response.status ?? 200,
252
- body: options.response.body ?? '',
253
- contentType: options.response.contentType ?? 'text/plain',
254
- headers: options.response.headers,
255
- });
256
- }
257
- else {
258
- await route.continue();
259
- }
260
- };
261
- this.routes.set(url, handler);
262
- await page.route(url, handler);
263
- }
264
- /**
265
- * Remove a route
266
- */
267
- async removeRoute(url) {
268
- const page = this.getPage();
269
- if (url) {
270
- const handler = this.routes.get(url);
271
- if (handler) {
272
- await page.unroute(url, handler);
273
- this.routes.delete(url);
274
- }
275
- }
276
- else {
277
- // Remove all routes
278
- for (const [routeUrl, handler] of this.routes) {
279
- await page.unroute(routeUrl, handler);
280
- }
281
- this.routes.clear();
282
- }
283
- }
284
- /**
285
- * Set geolocation
286
- */
287
- async setGeolocation(latitude, longitude, accuracy) {
288
- const context = this.contexts[0];
289
- if (context) {
290
- await context.setGeolocation({ latitude, longitude, accuracy });
291
- }
292
- }
293
- /**
294
- * Set permissions
295
- */
296
- async setPermissions(permissions, grant) {
297
- const context = this.contexts[0];
298
- if (context) {
299
- if (grant) {
300
- await context.grantPermissions(permissions);
301
- }
302
- else {
303
- await context.clearPermissions();
304
- }
305
- }
306
- }
307
- /**
308
- * Set viewport
309
- */
310
- async setViewport(width, height) {
311
- const page = this.getPage();
312
- await page.setViewportSize({ width, height });
313
- }
314
- /**
315
- * Set device scale factor (devicePixelRatio) via CDP
316
- * This sets window.devicePixelRatio which affects how the page renders and responds to media queries
317
- *
318
- * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
319
- * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
320
- * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
321
- * deviceScaleFactor must be set at context creation time.
322
- *
323
- * Must be called after setViewport to work correctly
324
- */
325
- async setDeviceScaleFactor(deviceScaleFactor, width, height, mobile = false) {
326
- const cdp = await this.getCDPSession();
327
- await cdp.send('Emulation.setDeviceMetricsOverride', {
328
- width,
329
- height,
330
- deviceScaleFactor,
331
- mobile,
332
- });
333
- }
334
- /**
335
- * Clear device metrics override to restore default devicePixelRatio
336
- */
337
- async clearDeviceMetricsOverride() {
338
- const cdp = await this.getCDPSession();
339
- await cdp.send('Emulation.clearDeviceMetricsOverride');
340
- }
341
- /**
342
- * Get device descriptor
343
- */
344
- getDevice(deviceName) {
345
- return devices[deviceName];
346
- }
347
- /**
348
- * List available devices
349
- */
350
- listDevices() {
351
- return Object.keys(devices);
352
- }
353
- /**
354
- * Start console message tracking
355
- */
356
- startConsoleTracking() {
357
- const page = this.getPage();
358
- page.on('console', (msg) => {
359
- this.consoleMessages.push({
360
- type: msg.type(),
361
- text: msg.text(),
362
- timestamp: Date.now(),
363
- });
364
- });
365
- }
366
- /**
367
- * Get console messages
368
- */
369
- getConsoleMessages() {
370
- return this.consoleMessages;
371
- }
372
- /**
373
- * Clear console messages
374
- */
375
- clearConsoleMessages() {
376
- this.consoleMessages = [];
377
- }
378
- /**
379
- * Start error tracking
380
- */
381
- startErrorTracking() {
382
- const page = this.getPage();
383
- page.on('pageerror', (error) => {
384
- this.pageErrors.push({
385
- message: error.message,
386
- timestamp: Date.now(),
387
- });
388
- });
389
- }
390
- /**
391
- * Get page errors
392
- */
393
- getPageErrors() {
394
- return this.pageErrors;
395
- }
396
- /**
397
- * Clear page errors
398
- */
399
- clearPageErrors() {
400
- this.pageErrors = [];
401
- }
402
- /**
403
- * Start HAR recording
404
- */
405
- async startHarRecording() {
406
- // HAR is started at context level, flag for tracking
407
- this.isRecordingHar = true;
408
- }
409
- /**
410
- * Check if HAR recording
411
- */
412
- isHarRecording() {
413
- return this.isRecordingHar;
414
- }
415
- /**
416
- * Set offline mode
417
- */
418
- async setOffline(offline) {
419
- const context = this.contexts[0];
420
- if (context) {
421
- await context.setOffline(offline);
422
- }
423
- }
424
- /**
425
- * Set extra HTTP headers (global - all requests)
426
- */
427
- async setExtraHeaders(headers) {
428
- const context = this.contexts[0];
429
- if (context) {
430
- await context.setExtraHTTPHeaders(headers);
431
- }
432
- }
433
- /**
434
- * Set scoped HTTP headers (only for requests matching the origin)
435
- * Uses route interception to add headers only to matching requests
436
- */
437
- async setScopedHeaders(origin, headers) {
438
- const page = this.getPage();
439
- // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
440
- // Handle both full URLs and just hostnames
441
- let urlPattern;
442
- try {
443
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
444
- // Match any protocol, the host, and any path
445
- urlPattern = `**://${url.host}/**`;
446
- }
447
- catch {
448
- // If parsing fails, treat as hostname pattern
449
- urlPattern = `**://${origin}/**`;
450
- }
451
- // Remove existing route for this origin if any
452
- const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
453
- if (existingHandler) {
454
- await page.unroute(urlPattern, existingHandler);
455
- }
456
- // Create handler that adds headers to matching requests
457
- const handler = async (route) => {
458
- const requestHeaders = route.request().headers();
459
- await route.continue({
460
- headers: {
461
- ...requestHeaders,
462
- ...headers,
463
- },
464
- });
465
- };
466
- // Store and register the route
467
- this.scopedHeaderRoutes.set(urlPattern, handler);
468
- await page.route(urlPattern, handler);
469
- }
470
- /**
471
- * Clear scoped headers for an origin (or all if no origin specified)
472
- */
473
- async clearScopedHeaders(origin) {
474
- const page = this.getPage();
475
- if (origin) {
476
- let urlPattern;
477
- try {
478
- const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
479
- urlPattern = `**://${url.host}/**`;
480
- }
481
- catch {
482
- urlPattern = `**://${origin}/**`;
483
- }
484
- const handler = this.scopedHeaderRoutes.get(urlPattern);
485
- if (handler) {
486
- await page.unroute(urlPattern, handler);
487
- this.scopedHeaderRoutes.delete(urlPattern);
488
- }
489
- }
490
- else {
491
- // Clear all scoped header routes
492
- for (const [pattern, handler] of this.scopedHeaderRoutes) {
493
- await page.unroute(pattern, handler);
494
- }
495
- this.scopedHeaderRoutes.clear();
496
- }
497
- }
498
- /**
499
- * Start tracing
500
- */
501
- async startTracing(options) {
502
- const context = this.contexts[0];
503
- if (context) {
504
- await context.tracing.start({
505
- screenshots: options.screenshots ?? true,
506
- snapshots: options.snapshots ?? true,
507
- });
508
- }
509
- }
510
- /**
511
- * Stop tracing and save
512
- */
513
- async stopTracing(path) {
514
- const context = this.contexts[0];
515
- if (context) {
516
- await context.tracing.stop({ path });
517
- }
518
- }
519
- /**
520
- * Save storage state (cookies, localStorage, etc.)
521
- */
522
- async saveStorageState(path) {
523
- const context = this.contexts[0];
524
- if (context) {
525
- await context.storageState({ path });
526
- }
527
- }
528
- /**
529
- * Get all pages
530
- */
531
- getPages() {
532
- return this.pages;
533
- }
534
- /**
535
- * Get current page index
536
- */
537
- getActiveIndex() {
538
- return this.activePageIndex;
539
- }
540
- /**
541
- * Get the current browser instance
542
- */
543
- getBrowser() {
544
- return this.browser;
545
- }
546
- /**
547
- * Check if an existing CDP connection is still alive
548
- * by verifying we can access browser contexts and that at least one has pages
549
- */
550
- isCdpConnectionAlive() {
551
- if (!this.browser)
552
- return false;
553
- try {
554
- const contexts = this.browser.contexts();
555
- if (contexts.length === 0)
556
- return false;
557
- return contexts.some((context) => context.pages().length > 0);
558
- }
559
- catch {
560
- return false;
561
- }
562
- }
563
- /**
564
- * Check if CDP connection needs to be re-established
565
- */
566
- needsCdpReconnect(cdpEndpoint) {
567
- if (!this.browser?.isConnected())
568
- return true;
569
- if (this.cdpEndpoint !== cdpEndpoint)
570
- return true;
571
- if (!this.isCdpConnectionAlive())
572
- return true;
573
- return false;
574
- }
575
- /**
576
- * Close a Browserbase session via API
577
- */
578
- async closeBrowserbaseSession(sessionId, apiKey) {
579
- await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
580
- method: 'DELETE',
581
- headers: {
582
- 'X-BB-API-Key': apiKey,
583
- },
584
- });
585
- }
586
- /**
587
- * Close a Browser Use session via API
588
- */
589
- async closeBrowserUseSession(sessionId, apiKey) {
590
- const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
591
- method: 'PATCH',
592
- headers: {
593
- 'Content-Type': 'application/json',
594
- 'X-Browser-Use-API-Key': apiKey,
595
- },
596
- body: JSON.stringify({ action: 'stop' }),
597
- });
598
- if (!response.ok) {
599
- throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
600
- }
601
- }
602
- /**
603
- * Close a Kernel session via API
604
- */
605
- async closeKernelSession(sessionId, apiKey) {
606
- const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
607
- method: 'DELETE',
608
- headers: {
609
- Authorization: `Bearer ${apiKey}`,
610
- },
611
- });
612
- if (!response.ok) {
613
- throw new Error(`Failed to close Kernel session: ${response.statusText}`);
614
- }
615
- }
616
- /**
617
- * Connect to Browserbase remote browser via CDP.
618
- * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
619
- */
620
- async connectToBrowserbase() {
621
- const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
622
- const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
623
- if (!browserbaseApiKey || !browserbaseProjectId) {
624
- throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
625
- }
626
- const response = await fetch('https://api.browserbase.com/v1/sessions', {
627
- method: 'POST',
628
- headers: {
629
- 'Content-Type': 'application/json',
630
- 'X-BB-API-Key': browserbaseApiKey,
631
- },
632
- body: JSON.stringify({
633
- projectId: browserbaseProjectId,
634
- }),
635
- });
636
- if (!response.ok) {
637
- throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
638
- }
639
- const session = (await response.json());
640
- const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
641
- throw new Error('Failed to connect to Browserbase session via CDP');
642
- });
643
- try {
644
- const contexts = browser.contexts();
645
- if (contexts.length === 0) {
646
- throw new Error('No browser context found in Browserbase session');
647
- }
648
- const context = contexts[0];
649
- const pages = context.pages();
650
- const page = pages[0] ?? (await context.newPage());
651
- this.browserbaseSessionId = session.id;
652
- this.browserbaseApiKey = browserbaseApiKey;
653
- this.browser = browser;
654
- context.setDefaultTimeout(10000);
655
- this.contexts.push(context);
656
- this.setupContextTracking(context);
657
- this.pages.push(page);
658
- this.activePageIndex = 0;
659
- this.setupPageTracking(page);
660
- }
661
- catch (error) {
662
- await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
663
- console.error('Failed to close Browserbase session during cleanup:', sessionError);
664
- });
665
- throw error;
666
- }
667
- }
668
- /**
669
- * Find or create a Kernel profile by name.
670
- * Returns the profile object if successful.
671
- */
672
- async findOrCreateKernelProfile(profileName, apiKey) {
673
- // First, try to get the existing profile
674
- const getResponse = await fetch(`https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, {
675
- method: 'GET',
676
- headers: {
677
- Authorization: `Bearer ${apiKey}`,
678
- },
679
- });
680
- if (getResponse.ok) {
681
- // Profile exists, return it
682
- return { name: profileName };
683
- }
684
- if (getResponse.status !== 404) {
685
- throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`);
686
- }
687
- // Profile doesn't exist, create it
688
- const createResponse = await fetch('https://api.onkernel.com/profiles', {
689
- method: 'POST',
690
- headers: {
691
- 'Content-Type': 'application/json',
692
- Authorization: `Bearer ${apiKey}`,
693
- },
694
- body: JSON.stringify({ name: profileName }),
695
- });
696
- if (!createResponse.ok) {
697
- throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`);
698
- }
699
- return { name: profileName };
700
- }
701
- /**
702
- * Connect to Kernel remote browser via CDP.
703
- * Requires KERNEL_API_KEY environment variable.
704
- */
705
- async connectToKernel() {
706
- const kernelApiKey = process.env.KERNEL_API_KEY;
707
- if (!kernelApiKey) {
708
- throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
709
- }
710
- // Find or create profile if KERNEL_PROFILE_NAME is set
711
- const profileName = process.env.KERNEL_PROFILE_NAME;
712
- let profileConfig;
713
- if (profileName) {
714
- await this.findOrCreateKernelProfile(profileName, kernelApiKey);
715
- profileConfig = {
716
- profile: {
717
- name: profileName,
718
- save_changes: true, // Save cookies/state back to the profile when session ends
719
- },
720
- };
721
- }
722
- const response = await fetch('https://api.onkernel.com/browsers', {
723
- method: 'POST',
724
- headers: {
725
- 'Content-Type': 'application/json',
726
- Authorization: `Bearer ${kernelApiKey}`,
727
- },
728
- body: JSON.stringify({
729
- // Kernel browsers are headful by default with stealth mode available
730
- // The user can configure these via environment variables if needed
731
- headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true',
732
- stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode
733
- timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10),
734
- // Load and save to a profile if specified
735
- ...profileConfig,
736
- }),
737
- });
738
- if (!response.ok) {
739
- throw new Error(`Failed to create Kernel session: ${response.statusText}`);
740
- }
741
- let session;
742
- try {
743
- session = (await response.json());
744
- }
745
- catch (error) {
746
- throw new Error(`Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}`);
747
- }
748
- if (!session.session_id || !session.cdp_ws_url) {
749
- throw new Error(`Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}`);
750
- }
751
- const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => {
752
- throw new Error('Failed to connect to Kernel session via CDP');
753
- });
754
- try {
755
- const contexts = browser.contexts();
756
- let context;
757
- let page;
758
- // Kernel browsers launch with a default context and page
759
- if (contexts.length === 0) {
760
- context = await browser.newContext();
761
- page = await context.newPage();
762
- }
763
- else {
764
- context = contexts[0];
765
- const pages = context.pages();
766
- page = pages[0] ?? (await context.newPage());
767
- }
768
- this.kernelSessionId = session.session_id;
769
- this.kernelApiKey = kernelApiKey;
770
- this.browser = browser;
771
- context.setDefaultTimeout(60000);
772
- this.contexts.push(context);
773
- this.pages.push(page);
774
- this.activePageIndex = 0;
775
- this.setupPageTracking(page);
776
- this.setupContextTracking(context);
777
- }
778
- catch (error) {
779
- await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
780
- console.error('Failed to close Kernel session during cleanup:', sessionError);
781
- });
782
- throw error;
783
- }
784
- }
785
- /**
786
- * Connect to Browser Use remote browser via CDP.
787
- * Requires BROWSER_USE_API_KEY environment variable.
788
- */
789
- async connectToBrowserUse() {
790
- const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
791
- if (!browserUseApiKey) {
792
- throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
793
- }
794
- const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
795
- method: 'POST',
796
- headers: {
797
- 'Content-Type': 'application/json',
798
- 'X-Browser-Use-API-Key': browserUseApiKey,
799
- },
800
- body: JSON.stringify({}),
801
- });
802
- if (!response.ok) {
803
- throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
804
- }
805
- let session;
806
- try {
807
- session = (await response.json());
808
- }
809
- catch (error) {
810
- throw new Error(`Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`);
811
- }
812
- if (!session.id || !session.cdpUrl) {
813
- throw new Error(`Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`);
814
- }
815
- const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
816
- throw new Error('Failed to connect to Browser Use session via CDP');
817
- });
818
- try {
819
- const contexts = browser.contexts();
820
- let context;
821
- let page;
822
- if (contexts.length === 0) {
823
- context = await browser.newContext();
824
- page = await context.newPage();
825
- }
826
- else {
827
- context = contexts[0];
828
- const pages = context.pages();
829
- page = pages[0] ?? (await context.newPage());
830
- }
831
- this.browserUseSessionId = session.id;
832
- this.browserUseApiKey = browserUseApiKey;
833
- this.browser = browser;
834
- context.setDefaultTimeout(60000);
835
- this.contexts.push(context);
836
- this.pages.push(page);
837
- this.activePageIndex = 0;
838
- this.setupPageTracking(page);
839
- this.setupContextTracking(context);
840
- }
841
- catch (error) {
842
- await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
843
- console.error('Failed to close Browser Use session during cleanup:', sessionError);
844
- });
845
- throw error;
846
- }
847
- }
848
- /**
849
- * Launch the browser with the specified options
850
- * If already launched, this is a no-op (browser stays open)
851
- */
852
- async launch(options) {
853
- // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
854
- const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
855
- const hasExtensions = !!options.extensions?.length;
856
- const hasProfile = !!options.profile;
857
- const hasStorageState = !!options.storageState;
858
- if (hasExtensions && cdpEndpoint) {
859
- throw new Error('Extensions cannot be used with CDP connection');
860
- }
861
- if (hasProfile && cdpEndpoint) {
862
- throw new Error('Profile cannot be used with CDP connection');
863
- }
864
- if (hasStorageState && hasProfile) {
865
- throw new Error('Storage state cannot be used with profile (profile is already persistent storage)');
866
- }
867
- if (hasStorageState && hasExtensions) {
868
- throw new Error('Storage state cannot be used with extensions (extensions require persistent context)');
869
- }
870
- if (this.isLaunched()) {
871
- const needsRelaunch = (!cdpEndpoint && this.cdpEndpoint !== null) ||
872
- (!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
873
- if (needsRelaunch) {
874
- await this.close();
875
- }
876
- else {
877
- return;
878
- }
879
- }
880
- if (cdpEndpoint) {
881
- await this.connectViaCDP(cdpEndpoint);
882
- return;
883
- }
884
- // Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
885
- // -p flag takes precedence over env var
886
- const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
887
- if (provider === 'browserbase') {
888
- await this.connectToBrowserbase();
889
- return;
890
- }
891
- if (provider === 'browseruse') {
892
- await this.connectToBrowserUse();
893
- return;
894
- }
895
- // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel
896
- if (provider === 'kernel') {
897
- await this.connectToKernel();
898
- return;
899
- }
900
- const browserType = options.browser ?? 'chromium';
901
- if (hasExtensions && browserType !== 'chromium') {
902
- throw new Error('Extensions are only supported in Chromium');
903
- }
904
- // allowFileAccess is only supported in Chromium
905
- if (options.allowFileAccess && browserType !== 'chromium') {
906
- throw new Error('allowFileAccess is only supported in Chromium');
907
- }
908
- const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
909
- const viewport = options.viewport ?? { width: 1280, height: 720 };
910
- // Build base args array with file access flags if enabled
911
- // --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
912
- // --allow-file-access: allows the browser to access local files in general
913
- const fileAccessArgs = options.allowFileAccess
914
- ? ['--allow-file-access-from-files', '--allow-file-access']
915
- : [];
916
- const baseArgs = options.args
917
- ? [...fileAccessArgs, ...options.args]
918
- : fileAccessArgs.length > 0
919
- ? fileAccessArgs
920
- : undefined;
921
- let context;
922
- if (hasExtensions) {
923
- // Extensions require persistent context in a temp directory
924
- const extPaths = options.extensions.join(',');
925
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
926
- // Combine extension args with custom args and file access args
927
- const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
928
- const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
929
- context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `agent-browser-ext-${session}`), {
930
- headless: false,
931
- executablePath: options.executablePath,
932
- args: allArgs,
933
- viewport,
934
- extraHTTPHeaders: options.headers,
935
- userAgent: options.userAgent,
936
- ...(options.proxy && { proxy: options.proxy }),
937
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
938
- });
939
- this.isPersistentContext = true;
940
- }
941
- else if (hasProfile) {
942
- // Profile uses persistent context for durable cookies/storage
943
- // Expand ~ to home directory since it won't be shell-expanded
944
- const profilePath = options.profile.replace(/^~\//, os.homedir() + '/');
945
- context = await launcher.launchPersistentContext(profilePath, {
946
- headless: options.headless ?? true,
947
- executablePath: options.executablePath,
948
- args: baseArgs,
949
- viewport,
950
- extraHTTPHeaders: options.headers,
951
- userAgent: options.userAgent,
952
- ...(options.proxy && { proxy: options.proxy }),
953
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
954
- });
955
- this.isPersistentContext = true;
956
- }
957
- else {
958
- // Regular ephemeral browser
959
- this.browser = await launcher.launch({
960
- headless: options.headless ?? true,
961
- executablePath: options.executablePath,
962
- args: baseArgs,
963
- });
964
- this.cdpEndpoint = null;
965
- context = await this.browser.newContext({
966
- viewport,
967
- extraHTTPHeaders: options.headers,
968
- userAgent: options.userAgent,
969
- ...(options.proxy && { proxy: options.proxy }),
970
- ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
971
- ...(options.storageState && { storageState: options.storageState }),
972
- });
973
- }
974
- context.setDefaultTimeout(60000);
975
- this.contexts.push(context);
976
- this.setupContextTracking(context);
977
- const page = context.pages()[0] ?? (await context.newPage());
978
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
979
- if (!this.pages.includes(page)) {
980
- this.pages.push(page);
981
- this.setupPageTracking(page);
982
- }
983
- this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
984
- }
985
- /**
986
- * Connect to a running browser via CDP (Chrome DevTools Protocol)
987
- * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
988
- */
989
- async connectViaCDP(cdpEndpoint) {
990
- if (!cdpEndpoint) {
991
- throw new Error('CDP endpoint is required for CDP connection');
992
- }
993
- // Determine the connection URL:
994
- // - If it starts with ws://, wss://, http://, or https://, use it directly
995
- // - If it's a numeric string (e.g., "9222"), treat as port for localhost
996
- // - Otherwise, treat it as a port number for localhost
997
- let cdpUrl;
998
- if (cdpEndpoint.startsWith('ws://') ||
999
- cdpEndpoint.startsWith('wss://') ||
1000
- cdpEndpoint.startsWith('http://') ||
1001
- cdpEndpoint.startsWith('https://')) {
1002
- cdpUrl = cdpEndpoint;
1003
- }
1004
- else if (/^\d+$/.test(cdpEndpoint)) {
1005
- // Numeric string - treat as port number (handles JSON serialization quirks)
1006
- cdpUrl = `http://localhost:${cdpEndpoint}`;
1007
- }
1008
- else {
1009
- // Unknown format - still try as port for backward compatibility
1010
- cdpUrl = `http://localhost:${cdpEndpoint}`;
1011
- }
1012
- const browser = await chromium.connectOverCDP(cdpUrl).catch(() => {
1013
- throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
1014
- (cdpUrl.includes('localhost')
1015
- ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
1016
- : 'Make sure the remote browser is accessible and the URL is correct.'));
1017
- });
1018
- // Validate and set up state, cleaning up browser connection if anything fails
1019
- try {
1020
- const contexts = browser.contexts();
1021
- if (contexts.length === 0) {
1022
- throw new Error('No browser context found. Make sure the app has an open window.');
1023
- }
1024
- // Filter out pages with empty URLs, which can cause Playwright to hang
1025
- const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
1026
- if (allPages.length === 0) {
1027
- throw new Error('No page found. Make sure the app has loaded content.');
1028
- }
1029
- // All validation passed - commit state
1030
- this.browser = browser;
1031
- this.cdpEndpoint = cdpEndpoint;
1032
- for (const context of contexts) {
1033
- context.setDefaultTimeout(10000);
1034
- this.contexts.push(context);
1035
- this.setupContextTracking(context);
1036
- }
1037
- for (const page of allPages) {
1038
- this.pages.push(page);
1039
- this.setupPageTracking(page);
1040
- }
1041
- this.activePageIndex = 0;
1042
- }
1043
- catch (error) {
1044
- // Clean up browser connection if validation or setup failed
1045
- await browser.close().catch(() => { });
1046
- throw error;
1047
- }
1048
- }
1049
- /**
1050
- * Set up console, error, and close tracking for a page
1051
- */
1052
- setupPageTracking(page) {
1053
- page.on('console', (msg) => {
1054
- this.consoleMessages.push({
1055
- type: msg.type(),
1056
- text: msg.text(),
1057
- timestamp: Date.now(),
1058
- });
1059
- });
1060
- page.on('pageerror', (error) => {
1061
- this.pageErrors.push({
1062
- message: error.message,
1063
- timestamp: Date.now(),
1064
- });
1065
- });
1066
- page.on('close', () => {
1067
- const index = this.pages.indexOf(page);
1068
- if (index !== -1) {
1069
- this.pages.splice(index, 1);
1070
- if (this.activePageIndex >= this.pages.length) {
1071
- this.activePageIndex = Math.max(0, this.pages.length - 1);
1072
- }
1073
- }
1074
- });
1075
- }
1076
- /**
1077
- * Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
1078
- * This handles pages created externally (e.g., via target="_blank" links, window.open)
1079
- */
1080
- setupContextTracking(context) {
1081
- context.on('page', (page) => {
1082
- // Only add if not already tracked (avoids duplicates when newTab() creates pages)
1083
- if (!this.pages.includes(page)) {
1084
- this.pages.push(page);
1085
- this.setupPageTracking(page);
1086
- }
1087
- // Auto-switch to the newly opened tab so subsequent commands target it.
1088
- // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
1089
- // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
1090
- // stays in sync with the browser.
1091
- const newIndex = this.pages.indexOf(page);
1092
- if (newIndex !== -1 && newIndex !== this.activePageIndex) {
1093
- this.activePageIndex = newIndex;
1094
- // Invalidate CDP session since the active page changed
1095
- this.invalidateCDPSession().catch(() => { });
1096
- }
1097
- });
1098
- }
1099
- /**
1100
- * Create a new tab in the current context
1101
- */
1102
- async newTab() {
1103
- if (!this.browser || this.contexts.length === 0) {
1104
- throw new Error('Browser not launched');
1105
- }
1106
- // Invalidate CDP session since we're switching to a new page
1107
- await this.invalidateCDPSession();
1108
- const context = this.contexts[0]; // Use first context for tabs
1109
- const page = await context.newPage();
1110
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1111
- if (!this.pages.includes(page)) {
1112
- this.pages.push(page);
1113
- this.setupPageTracking(page);
1114
- }
1115
- this.activePageIndex = this.pages.length - 1;
1116
- return { index: this.activePageIndex, total: this.pages.length };
1117
- }
1118
- /**
1119
- * Create a new window (new context)
1120
- */
1121
- async newWindow(viewport) {
1122
- if (!this.browser) {
1123
- throw new Error('Browser not launched');
1124
- }
1125
- const context = await this.browser.newContext({
1126
- viewport: viewport ?? { width: 1280, height: 720 },
1127
- });
1128
- context.setDefaultTimeout(60000);
1129
- this.contexts.push(context);
1130
- this.setupContextTracking(context);
1131
- const page = await context.newPage();
1132
- // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1133
- if (!this.pages.includes(page)) {
1134
- this.pages.push(page);
1135
- this.setupPageTracking(page);
1136
- }
1137
- this.activePageIndex = this.pages.length - 1;
1138
- return { index: this.activePageIndex, total: this.pages.length };
1139
- }
1140
- /**
1141
- * Invalidate the current CDP session (must be called before switching pages)
1142
- * This ensures screencast and input injection work correctly after tab switch
1143
- */
1144
- async invalidateCDPSession() {
1145
- // Stop screencast if active (it's tied to the current page's CDP session)
1146
- if (this.screencastActive) {
1147
- await this.stopScreencast();
1148
- }
1149
- // Detach and clear the CDP session
1150
- if (this.cdpSession) {
1151
- await this.cdpSession.detach().catch(() => { });
1152
- this.cdpSession = null;
1153
- }
1154
- }
1155
- /**
1156
- * Switch to a specific tab/page by index
1157
- */
1158
- async switchTo(index) {
1159
- if (index < 0 || index >= this.pages.length) {
1160
- throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
1161
- }
1162
- // Invalidate CDP session before switching (it's page-specific)
1163
- if (index !== this.activePageIndex) {
1164
- await this.invalidateCDPSession();
1165
- }
1166
- this.activePageIndex = index;
1167
- const page = this.pages[index];
1168
- return {
1169
- index: this.activePageIndex,
1170
- url: page.url(),
1171
- title: '', // Title requires async, will be fetched separately
1172
- };
1173
- }
1174
- /**
1175
- * Close a specific tab/page
1176
- */
1177
- async closeTab(index) {
1178
- const targetIndex = index ?? this.activePageIndex;
1179
- if (targetIndex < 0 || targetIndex >= this.pages.length) {
1180
- throw new Error(`Invalid tab index: ${targetIndex}`);
1181
- }
1182
- if (this.pages.length === 1) {
1183
- throw new Error('Cannot close the last tab. Use "close" to close the browser.');
1184
- }
1185
- // If closing the active tab, invalidate CDP session first
1186
- if (targetIndex === this.activePageIndex) {
1187
- await this.invalidateCDPSession();
1188
- }
1189
- const page = this.pages[targetIndex];
1190
- await page.close();
1191
- this.pages.splice(targetIndex, 1);
1192
- // Adjust active index if needed
1193
- if (this.activePageIndex >= this.pages.length) {
1194
- this.activePageIndex = this.pages.length - 1;
1195
- }
1196
- else if (this.activePageIndex > targetIndex) {
1197
- this.activePageIndex--;
1198
- }
1199
- return { closed: targetIndex, remaining: this.pages.length };
1200
- }
1201
- /**
1202
- * List all tabs with their info
1203
- */
1204
- async listTabs() {
1205
- const tabs = await Promise.all(this.pages.map(async (page, index) => ({
1206
- index,
1207
- url: page.url(),
1208
- title: await page.title().catch(() => ''),
1209
- active: index === this.activePageIndex,
1210
- })));
1211
- return tabs;
1212
- }
1213
- /**
1214
- * Get or create a CDP session for the current page
1215
- * Only works with Chromium-based browsers
1216
- */
1217
- async getCDPSession() {
1218
- if (this.cdpSession) {
1219
- return this.cdpSession;
1220
- }
1221
- const page = this.getPage();
1222
- const context = page.context();
1223
- // Create a new CDP session attached to the page
1224
- this.cdpSession = await context.newCDPSession(page);
1225
- return this.cdpSession;
1226
- }
1227
- /**
1228
- * Check if screencast is currently active
1229
- */
1230
- isScreencasting() {
1231
- return this.screencastActive;
1232
- }
1233
- /**
1234
- * Start screencast - streams viewport frames via CDP
1235
- * @param callback Function called for each frame
1236
- * @param options Screencast options
1237
- */
1238
- async startScreencast(callback, options) {
1239
- if (this.screencastActive) {
1240
- throw new Error('Screencast already active');
1241
- }
1242
- const cdp = await this.getCDPSession();
1243
- this.frameCallback = callback;
1244
- this.screencastActive = true;
1245
- // Create and store the frame handler so we can remove it later
1246
- this.screencastFrameHandler = async (params) => {
1247
- const frame = {
1248
- data: params.data,
1249
- metadata: params.metadata,
1250
- sessionId: params.sessionId,
1251
- };
1252
- // Acknowledge the frame to receive the next one
1253
- await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
1254
- // Call the callback with the frame
1255
- if (this.frameCallback) {
1256
- this.frameCallback(frame);
1257
- }
1258
- };
1259
- // Listen for screencast frames
1260
- cdp.on('Page.screencastFrame', this.screencastFrameHandler);
1261
- // Start the screencast
1262
- await cdp.send('Page.startScreencast', {
1263
- format: options?.format ?? 'jpeg',
1264
- quality: options?.quality ?? 80,
1265
- maxWidth: options?.maxWidth ?? 1280,
1266
- maxHeight: options?.maxHeight ?? 720,
1267
- everyNthFrame: options?.everyNthFrame ?? 1,
1268
- });
1269
- }
1270
- /**
1271
- * Stop screencast
1272
- */
1273
- async stopScreencast() {
1274
- if (!this.screencastActive) {
1275
- return;
1276
- }
1277
- try {
1278
- const cdp = await this.getCDPSession();
1279
- await cdp.send('Page.stopScreencast');
1280
- // Remove the event listener to prevent accumulation
1281
- if (this.screencastFrameHandler) {
1282
- cdp.off('Page.screencastFrame', this.screencastFrameHandler);
1283
- }
1284
- }
1285
- catch {
1286
- // Ignore errors when stopping
1287
- }
1288
- this.screencastActive = false;
1289
- this.frameCallback = null;
1290
- this.screencastFrameHandler = null;
1291
- }
1292
- /**
1293
- * Inject a mouse event via CDP
1294
- */
1295
- async injectMouseEvent(params) {
1296
- const cdp = await this.getCDPSession();
1297
- const cdpButton = params.button === 'left'
1298
- ? 'left'
1299
- : params.button === 'right'
1300
- ? 'right'
1301
- : params.button === 'middle'
1302
- ? 'middle'
1303
- : 'none';
1304
- await cdp.send('Input.dispatchMouseEvent', {
1305
- type: params.type,
1306
- x: params.x,
1307
- y: params.y,
1308
- button: cdpButton,
1309
- clickCount: params.clickCount ?? 1,
1310
- deltaX: params.deltaX ?? 0,
1311
- deltaY: params.deltaY ?? 0,
1312
- modifiers: params.modifiers ?? 0,
1313
- });
1314
- }
1315
- /**
1316
- * Inject a keyboard event via CDP
1317
- */
1318
- async injectKeyboardEvent(params) {
1319
- const cdp = await this.getCDPSession();
1320
- await cdp.send('Input.dispatchKeyEvent', {
1321
- type: params.type,
1322
- key: params.key,
1323
- code: params.code,
1324
- text: params.text,
1325
- modifiers: params.modifiers ?? 0,
1326
- });
1327
- }
1328
- /**
1329
- * Inject touch event via CDP (for mobile emulation)
1330
- */
1331
- async injectTouchEvent(params) {
1332
- const cdp = await this.getCDPSession();
1333
- await cdp.send('Input.dispatchTouchEvent', {
1334
- type: params.type,
1335
- touchPoints: params.touchPoints.map((tp, i) => ({
1336
- x: tp.x,
1337
- y: tp.y,
1338
- id: tp.id ?? i,
1339
- })),
1340
- modifiers: params.modifiers ?? 0,
1341
- });
1342
- }
1343
- /**
1344
- * Check if video recording is currently active
1345
- */
1346
- isRecording() {
1347
- return this.recordingContext !== null;
1348
- }
1349
- /**
1350
- * Start recording to a video file using Playwright's native video recording.
1351
- * Creates a fresh browser context with video recording enabled.
1352
- * Automatically captures current URL and transfers cookies/storage if no URL provided.
1353
- *
1354
- * @param outputPath - Path to the output video file (will be .webm)
1355
- * @param url - Optional URL to navigate to (defaults to current page URL)
1356
- */
1357
- async startRecording(outputPath, url) {
1358
- if (this.recordingContext) {
1359
- throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
1360
- }
1361
- if (!this.browser) {
1362
- throw new Error('Browser not launched. Call launch first.');
1363
- }
1364
- // Check if output file already exists
1365
- if (existsSync(outputPath)) {
1366
- throw new Error(`Output file already exists: ${outputPath}`);
1367
- }
1368
- // Validate output path is .webm (Playwright native format)
1369
- if (!outputPath.endsWith('.webm')) {
1370
- throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
1371
- }
1372
- // Auto-capture current URL if none provided
1373
- const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
1374
- const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
1375
- if (!url && currentPage) {
1376
- const currentUrl = currentPage.url();
1377
- if (currentUrl && currentUrl !== 'about:blank') {
1378
- url = currentUrl;
1379
- }
1380
- }
1381
- // Capture state from current context (cookies + storage)
1382
- let storageState;
1383
- if (currentContext) {
1384
- try {
1385
- storageState = await currentContext.storageState();
1386
- }
1387
- catch {
1388
- // Ignore errors - context might be closed or invalid
1389
- }
1390
- }
1391
- // Create a temp directory for video recording
1392
- const session = process.env.AGENT_BROWSER_SESSION || 'default';
1393
- this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
1394
- mkdirSync(this.recordingTempDir, { recursive: true });
1395
- this.recordingOutputPath = outputPath;
1396
- // Create a new context with video recording enabled and restored state
1397
- const viewport = { width: 1280, height: 720 };
1398
- this.recordingContext = await this.browser.newContext({
1399
- viewport,
1400
- recordVideo: {
1401
- dir: this.recordingTempDir,
1402
- size: viewport,
1403
- },
1404
- storageState,
1405
- });
1406
- this.recordingContext.setDefaultTimeout(10000);
1407
- // Create a page in the recording context
1408
- this.recordingPage = await this.recordingContext.newPage();
1409
- // Add the recording context and page to our managed lists
1410
- this.contexts.push(this.recordingContext);
1411
- this.pages.push(this.recordingPage);
1412
- this.activePageIndex = this.pages.length - 1;
1413
- // Set up page tracking
1414
- this.setupPageTracking(this.recordingPage);
1415
- // Invalidate CDP session since we switched pages
1416
- await this.invalidateCDPSession();
1417
- // Navigate to URL if provided or captured
1418
- if (url) {
1419
- await this.recordingPage.goto(url, { waitUntil: 'load' });
1420
- }
1421
- }
1422
- /**
1423
- * Stop recording and save the video file
1424
- * @returns Recording result with path
1425
- */
1426
- async stopRecording() {
1427
- if (!this.recordingContext || !this.recordingPage) {
1428
- return { path: '', frames: 0, error: 'No recording in progress' };
1429
- }
1430
- const outputPath = this.recordingOutputPath;
1431
- try {
1432
- // Get the video object before closing the page
1433
- const video = this.recordingPage.video();
1434
- // Remove recording page/context from our managed lists before closing
1435
- const pageIndex = this.pages.indexOf(this.recordingPage);
1436
- if (pageIndex !== -1) {
1437
- this.pages.splice(pageIndex, 1);
1438
- }
1439
- const contextIndex = this.contexts.indexOf(this.recordingContext);
1440
- if (contextIndex !== -1) {
1441
- this.contexts.splice(contextIndex, 1);
1442
- }
1443
- // Close the page to finalize the video
1444
- await this.recordingPage.close();
1445
- // Save the video to the desired output path
1446
- if (video) {
1447
- await video.saveAs(outputPath);
1448
- }
1449
- // Clean up temp directory
1450
- if (this.recordingTempDir) {
1451
- rmSync(this.recordingTempDir, { recursive: true, force: true });
1452
- }
1453
- // Close the recording context
1454
- await this.recordingContext.close();
1455
- // Reset recording state
1456
- this.recordingContext = null;
1457
- this.recordingPage = null;
1458
- this.recordingOutputPath = '';
1459
- this.recordingTempDir = '';
1460
- // Adjust active page index
1461
- if (this.pages.length > 0) {
1462
- this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
1463
- }
1464
- else {
1465
- this.activePageIndex = 0;
1466
- }
1467
- // Invalidate CDP session since we may have switched pages
1468
- await this.invalidateCDPSession();
1469
- return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
1470
- }
1471
- catch (error) {
1472
- // Clean up temp directory on error
1473
- if (this.recordingTempDir) {
1474
- rmSync(this.recordingTempDir, { recursive: true, force: true });
1475
- }
1476
- // Reset state on error
1477
- this.recordingContext = null;
1478
- this.recordingPage = null;
1479
- this.recordingOutputPath = '';
1480
- this.recordingTempDir = '';
1481
- const message = error instanceof Error ? error.message : String(error);
1482
- return { path: outputPath, frames: 0, error: message };
1483
- }
1484
- }
1485
- /**
1486
- * Restart recording - stops current recording (if any) and starts a new one.
1487
- * Convenience method that combines stopRecording and startRecording.
1488
- *
1489
- * @param outputPath - Path to the output video file (must be .webm)
1490
- * @param url - Optional URL to navigate to (defaults to current page URL)
1491
- * @returns Result from stopping the previous recording (if any)
1492
- */
1493
- async restartRecording(outputPath, url) {
1494
- let previousPath;
1495
- let stopped = false;
1496
- // Stop current recording if active
1497
- if (this.recordingContext) {
1498
- const result = await this.stopRecording();
1499
- previousPath = result.path;
1500
- stopped = true;
1501
- }
1502
- // Start new recording
1503
- await this.startRecording(outputPath, url);
1504
- return { previousPath, stopped };
1505
- }
1506
- /**
1507
- * Close the browser and clean up
1508
- */
1509
- async close() {
1510
- // Stop recording if active (saves video)
1511
- if (this.recordingContext) {
1512
- await this.stopRecording();
1513
- }
1514
- // Stop screencast if active
1515
- if (this.screencastActive) {
1516
- await this.stopScreencast();
1517
- }
1518
- // Clean up CDP session
1519
- if (this.cdpSession) {
1520
- await this.cdpSession.detach().catch(() => { });
1521
- this.cdpSession = null;
1522
- }
1523
- if (this.browserbaseSessionId && this.browserbaseApiKey) {
1524
- await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch((error) => {
1525
- console.error('Failed to close Browserbase session:', error);
1526
- });
1527
- this.browser = null;
1528
- }
1529
- else if (this.browserUseSessionId && this.browserUseApiKey) {
1530
- await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch((error) => {
1531
- console.error('Failed to close Browser Use session:', error);
1532
- });
1533
- this.browser = null;
1534
- }
1535
- else if (this.kernelSessionId && this.kernelApiKey) {
1536
- await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => {
1537
- console.error('Failed to close Kernel session:', error);
1538
- });
1539
- this.browser = null;
1540
- }
1541
- else if (this.cdpEndpoint !== null) {
1542
- // CDP: only disconnect, don't close external app's pages
1543
- if (this.browser) {
1544
- await this.browser.close().catch(() => { });
1545
- this.browser = null;
1546
- }
1547
- }
1548
- else {
1549
- // Regular browser: close everything
1550
- for (const page of this.pages) {
1551
- await page.close().catch(() => { });
1552
- }
1553
- for (const context of this.contexts) {
1554
- await context.close().catch(() => { });
1555
- }
1556
- if (this.browser) {
1557
- await this.browser.close().catch(() => { });
1558
- this.browser = null;
1559
- }
1560
- }
1561
- this.pages = [];
1562
- this.contexts = [];
1563
- this.cdpEndpoint = null;
1564
- this.browserbaseSessionId = null;
1565
- this.browserbaseApiKey = null;
1566
- this.browserUseSessionId = null;
1567
- this.browserUseApiKey = null;
1568
- this.kernelSessionId = null;
1569
- this.kernelApiKey = null;
1570
- this.isPersistentContext = false;
1571
- this.activePageIndex = 0;
1572
- this.refMap = {};
1573
- this.lastSnapshot = '';
1574
- this.frameCallback = null;
1575
- }
1576
- }
1577
- //# sourceMappingURL=browser.js.map