nstbrowser-ai-agent 0.0.1

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 (119) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +1321 -0
  3. package/bin/nstbrowser-ai-agent-darwin-arm64 +0 -0
  4. package/bin/nstbrowser-ai-agent-darwin-x64 +0 -0
  5. package/bin/nstbrowser-ai-agent-linux-arm64 +0 -0
  6. package/bin/nstbrowser-ai-agent-linux-x64 +0 -0
  7. package/bin/nstbrowser-ai-agent-win32-x64.exe +0 -0
  8. package/bin/nstbrowser-ai-agent.js +109 -0
  9. package/dist/action-policy.d.ts +14 -0
  10. package/dist/action-policy.d.ts.map +1 -0
  11. package/dist/action-policy.js +253 -0
  12. package/dist/action-policy.js.map +1 -0
  13. package/dist/actions.d.ts +18 -0
  14. package/dist/actions.d.ts.map +1 -0
  15. package/dist/actions.js +2037 -0
  16. package/dist/actions.js.map +1 -0
  17. package/dist/auth-cli.d.ts +2 -0
  18. package/dist/auth-cli.d.ts.map +1 -0
  19. package/dist/auth-cli.js +97 -0
  20. package/dist/auth-cli.js.map +1 -0
  21. package/dist/auth-vault.d.ts +36 -0
  22. package/dist/auth-vault.d.ts.map +1 -0
  23. package/dist/auth-vault.js +125 -0
  24. package/dist/auth-vault.js.map +1 -0
  25. package/dist/browser.d.ts +573 -0
  26. package/dist/browser.d.ts.map +1 -0
  27. package/dist/browser.js +2036 -0
  28. package/dist/browser.js.map +1 -0
  29. package/dist/confirmation.d.ts +8 -0
  30. package/dist/confirmation.d.ts.map +1 -0
  31. package/dist/confirmation.js +30 -0
  32. package/dist/confirmation.js.map +1 -0
  33. package/dist/daemon.d.ts +65 -0
  34. package/dist/daemon.d.ts.map +1 -0
  35. package/dist/daemon.js +589 -0
  36. package/dist/daemon.js.map +1 -0
  37. package/dist/diff.d.ts +18 -0
  38. package/dist/diff.d.ts.map +1 -0
  39. package/dist/diff.js +271 -0
  40. package/dist/diff.js.map +1 -0
  41. package/dist/domain-filter.d.ts +28 -0
  42. package/dist/domain-filter.d.ts.map +1 -0
  43. package/dist/domain-filter.js +149 -0
  44. package/dist/domain-filter.js.map +1 -0
  45. package/dist/encryption.d.ts +73 -0
  46. package/dist/encryption.d.ts.map +1 -0
  47. package/dist/encryption.js +171 -0
  48. package/dist/encryption.js.map +1 -0
  49. package/dist/ios-actions.d.ts +11 -0
  50. package/dist/ios-actions.d.ts.map +1 -0
  51. package/dist/ios-actions.js +228 -0
  52. package/dist/ios-actions.js.map +1 -0
  53. package/dist/ios-manager.d.ts +266 -0
  54. package/dist/ios-manager.d.ts.map +1 -0
  55. package/dist/ios-manager.js +1073 -0
  56. package/dist/ios-manager.js.map +1 -0
  57. package/dist/nstbrowser-actions.d.ts +10 -0
  58. package/dist/nstbrowser-actions.d.ts.map +1 -0
  59. package/dist/nstbrowser-actions.js +277 -0
  60. package/dist/nstbrowser-actions.js.map +1 -0
  61. package/dist/nstbrowser-client.d.ts +197 -0
  62. package/dist/nstbrowser-client.d.ts.map +1 -0
  63. package/dist/nstbrowser-client.js +454 -0
  64. package/dist/nstbrowser-client.js.map +1 -0
  65. package/dist/nstbrowser-errors.d.ts +28 -0
  66. package/dist/nstbrowser-errors.d.ts.map +1 -0
  67. package/dist/nstbrowser-errors.js +59 -0
  68. package/dist/nstbrowser-errors.js.map +1 -0
  69. package/dist/nstbrowser-profile-resolver.d.ts +89 -0
  70. package/dist/nstbrowser-profile-resolver.d.ts.map +1 -0
  71. package/dist/nstbrowser-profile-resolver.js +227 -0
  72. package/dist/nstbrowser-profile-resolver.js.map +1 -0
  73. package/dist/nstbrowser-types.d.ts +151 -0
  74. package/dist/nstbrowser-types.d.ts.map +1 -0
  75. package/dist/nstbrowser-types.js +5 -0
  76. package/dist/nstbrowser-types.js.map +1 -0
  77. package/dist/nstbrowser-utils.d.ts +71 -0
  78. package/dist/nstbrowser-utils.d.ts.map +1 -0
  79. package/dist/nstbrowser-utils.js +174 -0
  80. package/dist/nstbrowser-utils.js.map +1 -0
  81. package/dist/protocol.d.ts +26 -0
  82. package/dist/protocol.d.ts.map +1 -0
  83. package/dist/protocol.js +1245 -0
  84. package/dist/protocol.js.map +1 -0
  85. package/dist/snapshot.d.ts +67 -0
  86. package/dist/snapshot.d.ts.map +1 -0
  87. package/dist/snapshot.js +514 -0
  88. package/dist/snapshot.js.map +1 -0
  89. package/dist/state-utils.d.ts +77 -0
  90. package/dist/state-utils.d.ts.map +1 -0
  91. package/dist/state-utils.js +178 -0
  92. package/dist/state-utils.js.map +1 -0
  93. package/dist/stream-server.d.ts +117 -0
  94. package/dist/stream-server.d.ts.map +1 -0
  95. package/dist/stream-server.js +309 -0
  96. package/dist/stream-server.js.map +1 -0
  97. package/dist/types.d.ts +1121 -0
  98. package/dist/types.d.ts.map +1 -0
  99. package/dist/types.js +2 -0
  100. package/dist/types.js.map +1 -0
  101. package/package.json +83 -0
  102. package/scripts/analyze-api-coverage.js +205 -0
  103. package/scripts/analyze-cli-coverage.js +239 -0
  104. package/scripts/build-all-platforms.sh +68 -0
  105. package/scripts/check-version-sync.js +39 -0
  106. package/scripts/copy-native.js +36 -0
  107. package/scripts/download-nstbrowser-docs.js +152 -0
  108. package/scripts/generate-skills.sh +218 -0
  109. package/scripts/postinstall.js +231 -0
  110. package/scripts/sync-version.js +69 -0
  111. package/skills/nstbrowser-ai-agent/SKILL.md +759 -0
  112. package/skills/nstbrowser-ai-agent/references/batch-operations.md +414 -0
  113. package/skills/nstbrowser-ai-agent/references/nst-api-reference.md +960 -0
  114. package/skills/nstbrowser-ai-agent/references/profile-management.md +672 -0
  115. package/skills/nstbrowser-ai-agent/references/proxy-configuration.md +460 -0
  116. package/skills/nstbrowser-ai-agent/references/troubleshooting.md +773 -0
  117. package/skills/nstbrowser-ai-agent/templates/automated-workflow.sh +248 -0
  118. package/skills/nstbrowser-ai-agent/templates/batch-proxy-update.sh +257 -0
  119. package/skills/nstbrowser-ai-agent/templates/profile-setup.sh +248 -0
@@ -0,0 +1,2036 @@
1
+ import { chromium, firefox, webkit, devices, } from 'playwright-core';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
5
+ import { writeFile, mkdir } from 'node:fs/promises';
6
+ import { getEnhancedSnapshot, parseRef } from './snapshot.js';
7
+ import { safeHeaderMerge } from './state-utils.js';
8
+ import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
9
+ import { getEncryptionKey, isEncryptedPayload, decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js';
10
+ import { isNstbrowserInstalled, isNstbrowserRunning, startNstbrowserClient, getNstbrowserInstallInstructions, } from './nstbrowser-utils.js';
11
+ /**
12
+ * Returns the default Playwright timeout in milliseconds for standard operations.
13
+ * Can be overridden via the NSTBROWSER_AI_AGENT_DEFAULT_TIMEOUT environment variable.
14
+ * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure
15
+ * Playwright errors are returned before the CLI gives up with EAGAIN.
16
+ * CDP and recording contexts use a shorter fixed timeout (10s) and are not affected.
17
+ */
18
+ export function getDefaultTimeout() {
19
+ const envValue = process.env.NSTBROWSER_AI_AGENT_DEFAULT_TIMEOUT;
20
+ if (envValue) {
21
+ const parsed = parseInt(envValue, 10);
22
+ if (!isNaN(parsed) && parsed >= 1000) {
23
+ return parsed;
24
+ }
25
+ }
26
+ return 25000;
27
+ }
28
+ /**
29
+ * Manages the Playwright browser lifecycle with multiple tabs/windows
30
+ */
31
+ export class BrowserManager {
32
+ browser = null;
33
+ cdpEndpoint = null; // stores port number or full URL
34
+ isPersistentContext = false;
35
+ nstSessionId = null;
36
+ nstApiKey = null;
37
+ nstHost = null;
38
+ nstPort = null;
39
+ contexts = [];
40
+ pages = [];
41
+ activePageIndex = 0;
42
+ activeFrame = null;
43
+ dialogHandler = null;
44
+ trackedRequests = [];
45
+ routes = new Map();
46
+ consoleMessages = [];
47
+ pageErrors = [];
48
+ isRecordingHar = false;
49
+ refMap = {};
50
+ lastSnapshot = '';
51
+ scopedHeaderRoutes = new Map();
52
+ colorScheme = null;
53
+ downloadPath = null;
54
+ allowedDomains = [];
55
+ /**
56
+ * Set the persistent color scheme preference.
57
+ * Applied automatically to all new pages and contexts.
58
+ */
59
+ setColorScheme(scheme) {
60
+ this.colorScheme = scheme;
61
+ }
62
+ // CDP session for screencast and input injection
63
+ cdpSession = null;
64
+ screencastActive = false;
65
+ screencastSessionId = 0;
66
+ frameCallback = null;
67
+ screencastFrameHandler = null;
68
+ // Video recording (Playwright native)
69
+ recordingContext = null;
70
+ recordingPage = null;
71
+ recordingOutputPath = '';
72
+ recordingTempDir = '';
73
+ launchWarnings = [];
74
+ /**
75
+ * Get and clear launch warnings (e.g., decryption failures)
76
+ */
77
+ getAndClearWarnings() {
78
+ const warnings = this.launchWarnings;
79
+ this.launchWarnings = [];
80
+ return warnings;
81
+ }
82
+ // CDP profiling state
83
+ static MAX_PROFILE_EVENTS = 5_000_000;
84
+ profilingActive = false;
85
+ profileChunks = [];
86
+ profileEventsDropped = false;
87
+ profileCompleteResolver = null;
88
+ profileDataHandler = null;
89
+ profileCompleteHandler = null;
90
+ /**
91
+ * Check if browser is launched
92
+ */
93
+ isLaunched() {
94
+ return this.browser !== null || this.isPersistentContext;
95
+ }
96
+ /**
97
+ * Get enhanced snapshot with refs and cache the ref map
98
+ */
99
+ async getSnapshot(options) {
100
+ const page = this.getPage();
101
+ const snapshot = await getEnhancedSnapshot(page, options);
102
+ this.refMap = snapshot.refs;
103
+ this.lastSnapshot = snapshot.tree;
104
+ return snapshot;
105
+ }
106
+ /**
107
+ * Get the last snapshot tree text (empty string if no snapshot has been taken)
108
+ */
109
+ getLastSnapshot() {
110
+ return this.lastSnapshot;
111
+ }
112
+ /**
113
+ * Update the stored snapshot (used by diff to keep the baseline current)
114
+ */
115
+ setLastSnapshot(snapshot) {
116
+ this.lastSnapshot = snapshot;
117
+ }
118
+ /**
119
+ * Get the cached ref map from last snapshot
120
+ */
121
+ getRefMap() {
122
+ return this.refMap;
123
+ }
124
+ /**
125
+ * Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
126
+ * Returns null if ref doesn't exist or is invalid
127
+ */
128
+ getLocatorFromRef(refArg) {
129
+ const ref = parseRef(refArg);
130
+ if (!ref)
131
+ return null;
132
+ const refData = this.refMap[ref];
133
+ if (!refData)
134
+ return null;
135
+ const page = this.getPage();
136
+ // Check if this is a cursor-interactive element (uses CSS selector, not ARIA role)
137
+ // These have pseudo-roles 'clickable' or 'focusable' and a CSS selector
138
+ if (refData.role === 'clickable' || refData.role === 'focusable') {
139
+ // The selector is a CSS selector, use it directly
140
+ return page.locator(refData.selector);
141
+ }
142
+ // Build locator with exact: true to avoid substring matches
143
+ let locator = page.getByRole(refData.role, {
144
+ name: refData.name,
145
+ exact: true,
146
+ });
147
+ // If an nth index is stored (for disambiguation), use it
148
+ if (refData.nth !== undefined) {
149
+ locator = locator.nth(refData.nth);
150
+ }
151
+ return locator;
152
+ }
153
+ /**
154
+ * Check if a selector looks like a ref
155
+ */
156
+ isRef(selector) {
157
+ return parseRef(selector) !== null;
158
+ }
159
+ /**
160
+ * Install the domain filter on a context if an allowlist is configured.
161
+ * Should be called before any pages navigate on the context.
162
+ */
163
+ async ensureDomainFilter(context) {
164
+ if (this.allowedDomains.length > 0) {
165
+ await installDomainFilter(context, this.allowedDomains);
166
+ }
167
+ }
168
+ /**
169
+ * After installing the domain filter, verify existing pages are on allowed
170
+ * domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
171
+ * already navigated to disallowed domains. Navigate them to about:blank.
172
+ */
173
+ async sanitizeExistingPages(pages) {
174
+ if (this.allowedDomains.length === 0)
175
+ return;
176
+ for (const page of pages) {
177
+ const url = page.url();
178
+ if (!url || url === 'about:blank')
179
+ continue;
180
+ try {
181
+ const hostname = new URL(url).hostname.toLowerCase();
182
+ if (!isDomainAllowed(hostname, this.allowedDomains)) {
183
+ await page.goto('about:blank');
184
+ }
185
+ }
186
+ catch {
187
+ await page.goto('about:blank').catch(() => { });
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * Check if a URL is allowed by the domain allowlist.
193
+ * Throws if the URL's domain is blocked. No-op if no allowlist is set.
194
+ * Blocks non-http(s) schemes and unparseable URLs by default.
195
+ */
196
+ checkDomainAllowed(url) {
197
+ if (this.allowedDomains.length === 0)
198
+ return;
199
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
200
+ throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
201
+ }
202
+ let hostname;
203
+ try {
204
+ hostname = new URL(url).hostname.toLowerCase();
205
+ }
206
+ catch {
207
+ throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
208
+ }
209
+ if (!isDomainAllowed(hostname, this.allowedDomains)) {
210
+ throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
211
+ }
212
+ }
213
+ /**
214
+ * Get locator - supports both refs and regular selectors
215
+ */
216
+ getLocator(selectorOrRef) {
217
+ // Check if it's a ref first
218
+ const locator = this.getLocatorFromRef(selectorOrRef);
219
+ if (locator)
220
+ return locator;
221
+ // Otherwise treat as regular selector
222
+ const page = this.getPage();
223
+ return page.locator(selectorOrRef);
224
+ }
225
+ /**
226
+ * Check if the browser has any usable pages
227
+ */
228
+ hasPages() {
229
+ return this.pages.length > 0;
230
+ }
231
+ /**
232
+ * Ensure at least one page exists. If the browser is launched but all pages
233
+ * were closed (stale session), creates a new page on the existing context.
234
+ * No-op if pages already exist.
235
+ */
236
+ async ensurePage() {
237
+ if (this.pages.length > 0)
238
+ return;
239
+ if (!this.browser && !this.isPersistentContext)
240
+ return;
241
+ // Use the last existing context, or create a new one
242
+ let context;
243
+ if (this.contexts.length > 0) {
244
+ context = this.contexts[this.contexts.length - 1];
245
+ }
246
+ else if (this.browser) {
247
+ context = await this.browser.newContext({
248
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
249
+ });
250
+ context.setDefaultTimeout(getDefaultTimeout());
251
+ this.contexts.push(context);
252
+ this.setupContextTracking(context);
253
+ await this.ensureDomainFilter(context);
254
+ }
255
+ else {
256
+ return;
257
+ }
258
+ const page = await context.newPage();
259
+ if (!this.pages.includes(page)) {
260
+ this.pages.push(page);
261
+ this.setupPageTracking(page);
262
+ }
263
+ this.activePageIndex = this.pages.length - 1;
264
+ }
265
+ /**
266
+ * Get the current active page, throws if not launched
267
+ */
268
+ getPage() {
269
+ if (this.pages.length === 0) {
270
+ throw new Error('Browser not launched. Call launch first.');
271
+ }
272
+ return this.pages[this.activePageIndex];
273
+ }
274
+ /**
275
+ * Get the current frame (or page's main frame if no frame is selected)
276
+ */
277
+ getFrame() {
278
+ if (this.activeFrame) {
279
+ return this.activeFrame;
280
+ }
281
+ return this.getPage().mainFrame();
282
+ }
283
+ /**
284
+ * Switch to a frame by selector, name, or URL
285
+ */
286
+ async switchToFrame(options) {
287
+ const page = this.getPage();
288
+ if (options.selector) {
289
+ const frameElement = await page.$(options.selector);
290
+ if (!frameElement) {
291
+ throw new Error(`Frame not found: ${options.selector}`);
292
+ }
293
+ const frame = await frameElement.contentFrame();
294
+ if (!frame) {
295
+ throw new Error(`Element is not a frame: ${options.selector}`);
296
+ }
297
+ this.activeFrame = frame;
298
+ }
299
+ else if (options.name) {
300
+ const frame = page.frame({ name: options.name });
301
+ if (!frame) {
302
+ throw new Error(`Frame not found with name: ${options.name}`);
303
+ }
304
+ this.activeFrame = frame;
305
+ }
306
+ else if (options.url) {
307
+ const frame = page.frame({ url: options.url });
308
+ if (!frame) {
309
+ throw new Error(`Frame not found with URL: ${options.url}`);
310
+ }
311
+ this.activeFrame = frame;
312
+ }
313
+ }
314
+ /**
315
+ * Switch back to main frame
316
+ */
317
+ switchToMainFrame() {
318
+ this.activeFrame = null;
319
+ }
320
+ /**
321
+ * Set up dialog handler
322
+ */
323
+ setDialogHandler(response, promptText) {
324
+ const page = this.getPage();
325
+ // Remove existing handler if any
326
+ if (this.dialogHandler) {
327
+ page.removeListener('dialog', this.dialogHandler);
328
+ }
329
+ this.dialogHandler = async (dialog) => {
330
+ if (response === 'accept') {
331
+ await dialog.accept(promptText);
332
+ }
333
+ else {
334
+ await dialog.dismiss();
335
+ }
336
+ };
337
+ page.on('dialog', this.dialogHandler);
338
+ }
339
+ /**
340
+ * Clear dialog handler
341
+ */
342
+ clearDialogHandler() {
343
+ if (this.dialogHandler) {
344
+ const page = this.getPage();
345
+ page.removeListener('dialog', this.dialogHandler);
346
+ this.dialogHandler = null;
347
+ }
348
+ }
349
+ /**
350
+ * Start tracking requests
351
+ */
352
+ startRequestTracking() {
353
+ const page = this.getPage();
354
+ page.on('request', (request) => {
355
+ this.trackedRequests.push({
356
+ url: request.url(),
357
+ method: request.method(),
358
+ headers: request.headers(),
359
+ timestamp: Date.now(),
360
+ resourceType: request.resourceType(),
361
+ });
362
+ });
363
+ }
364
+ /**
365
+ * Get tracked requests
366
+ */
367
+ getRequests(filter) {
368
+ if (filter) {
369
+ return this.trackedRequests.filter((r) => r.url.includes(filter));
370
+ }
371
+ return this.trackedRequests;
372
+ }
373
+ /**
374
+ * Clear tracked requests
375
+ */
376
+ clearRequests() {
377
+ this.trackedRequests = [];
378
+ }
379
+ /**
380
+ * Add a route to intercept requests
381
+ */
382
+ async addRoute(url, options) {
383
+ const page = this.getPage();
384
+ const handler = async (route) => {
385
+ if (options.abort) {
386
+ await route.abort();
387
+ }
388
+ else if (options.response) {
389
+ await route.fulfill({
390
+ status: options.response.status ?? 200,
391
+ body: options.response.body ?? '',
392
+ contentType: options.response.contentType ?? 'text/plain',
393
+ headers: options.response.headers,
394
+ });
395
+ }
396
+ else {
397
+ await route.continue();
398
+ }
399
+ };
400
+ this.routes.set(url, handler);
401
+ await page.route(url, handler);
402
+ }
403
+ /**
404
+ * Remove a route
405
+ */
406
+ async removeRoute(url) {
407
+ const page = this.getPage();
408
+ if (url) {
409
+ const handler = this.routes.get(url);
410
+ if (handler) {
411
+ await page.unroute(url, handler);
412
+ this.routes.delete(url);
413
+ }
414
+ }
415
+ else {
416
+ // Remove all routes
417
+ for (const [routeUrl, handler] of this.routes) {
418
+ await page.unroute(routeUrl, handler);
419
+ }
420
+ this.routes.clear();
421
+ }
422
+ }
423
+ /**
424
+ * Set geolocation
425
+ */
426
+ async setGeolocation(latitude, longitude, accuracy) {
427
+ const context = this.contexts[0];
428
+ if (context) {
429
+ await context.setGeolocation({ latitude, longitude, accuracy });
430
+ }
431
+ }
432
+ /**
433
+ * Set permissions
434
+ */
435
+ async setPermissions(permissions, grant) {
436
+ const context = this.contexts[0];
437
+ if (context) {
438
+ if (grant) {
439
+ await context.grantPermissions(permissions);
440
+ }
441
+ else {
442
+ await context.clearPermissions();
443
+ }
444
+ }
445
+ }
446
+ /**
447
+ * Set viewport
448
+ */
449
+ async setViewport(width, height) {
450
+ const page = this.getPage();
451
+ await page.setViewportSize({ width, height });
452
+ }
453
+ /**
454
+ * Set device scale factor (devicePixelRatio) via CDP
455
+ * This sets window.devicePixelRatio which affects how the page renders and responds to media queries
456
+ *
457
+ * Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
458
+ * (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
459
+ * when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
460
+ * deviceScaleFactor must be set at context creation time.
461
+ *
462
+ * Must be called after setViewport to work correctly
463
+ */
464
+ async setDeviceScaleFactor(deviceScaleFactor, width, height, mobile = false) {
465
+ const cdp = await this.getCDPSession();
466
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
467
+ width,
468
+ height,
469
+ deviceScaleFactor,
470
+ mobile,
471
+ });
472
+ }
473
+ /**
474
+ * Clear device metrics override to restore default devicePixelRatio
475
+ */
476
+ async clearDeviceMetricsOverride() {
477
+ const cdp = await this.getCDPSession();
478
+ await cdp.send('Emulation.clearDeviceMetricsOverride');
479
+ }
480
+ /**
481
+ * Get device descriptor
482
+ */
483
+ getDevice(deviceName) {
484
+ return devices[deviceName];
485
+ }
486
+ /**
487
+ * List available devices
488
+ */
489
+ listDevices() {
490
+ return Object.keys(devices);
491
+ }
492
+ /**
493
+ * Start console message tracking
494
+ */
495
+ startConsoleTracking() {
496
+ const page = this.getPage();
497
+ page.on('console', (msg) => {
498
+ this.consoleMessages.push({
499
+ type: msg.type(),
500
+ text: msg.text(),
501
+ timestamp: Date.now(),
502
+ });
503
+ });
504
+ }
505
+ /**
506
+ * Get console messages
507
+ */
508
+ getConsoleMessages() {
509
+ return this.consoleMessages;
510
+ }
511
+ /**
512
+ * Clear console messages
513
+ */
514
+ clearConsoleMessages() {
515
+ this.consoleMessages = [];
516
+ }
517
+ /**
518
+ * Start error tracking
519
+ */
520
+ startErrorTracking() {
521
+ const page = this.getPage();
522
+ page.on('pageerror', (error) => {
523
+ this.pageErrors.push({
524
+ message: error.message,
525
+ timestamp: Date.now(),
526
+ });
527
+ });
528
+ }
529
+ /**
530
+ * Get page errors
531
+ */
532
+ getPageErrors() {
533
+ return this.pageErrors;
534
+ }
535
+ /**
536
+ * Clear page errors
537
+ */
538
+ clearPageErrors() {
539
+ this.pageErrors = [];
540
+ }
541
+ /**
542
+ * Start HAR recording
543
+ */
544
+ async startHarRecording() {
545
+ // HAR is started at context level, flag for tracking
546
+ this.isRecordingHar = true;
547
+ }
548
+ /**
549
+ * Check if HAR recording
550
+ */
551
+ isHarRecording() {
552
+ return this.isRecordingHar;
553
+ }
554
+ /**
555
+ * Set offline mode
556
+ */
557
+ async setOffline(offline) {
558
+ const context = this.contexts[0];
559
+ if (context) {
560
+ await context.setOffline(offline);
561
+ }
562
+ }
563
+ /**
564
+ * Set extra HTTP headers (global - all requests)
565
+ */
566
+ async setExtraHeaders(headers) {
567
+ const context = this.contexts[0];
568
+ if (context) {
569
+ await context.setExtraHTTPHeaders(headers);
570
+ }
571
+ }
572
+ /**
573
+ * Set scoped HTTP headers (only for requests matching the origin)
574
+ * Uses route interception to add headers only to matching requests
575
+ */
576
+ async setScopedHeaders(origin, headers) {
577
+ const page = this.getPage();
578
+ // Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
579
+ // Handle both full URLs and just hostnames
580
+ let urlPattern;
581
+ try {
582
+ const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
583
+ // Match any protocol, the host, and any path
584
+ urlPattern = `**://${url.host}/**`;
585
+ }
586
+ catch {
587
+ // If parsing fails, treat as hostname pattern
588
+ urlPattern = `**://${origin}/**`;
589
+ }
590
+ // Remove existing route for this origin if any
591
+ const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
592
+ if (existingHandler) {
593
+ await page.unroute(urlPattern, existingHandler);
594
+ }
595
+ // Create handler that adds headers to matching requests
596
+ const handler = async (route) => {
597
+ const requestHeaders = route.request().headers();
598
+ await route.continue({
599
+ headers: safeHeaderMerge(requestHeaders, headers),
600
+ });
601
+ };
602
+ // Store and register the route
603
+ this.scopedHeaderRoutes.set(urlPattern, handler);
604
+ await page.route(urlPattern, handler);
605
+ }
606
+ /**
607
+ * Clear scoped headers for an origin (or all if no origin specified)
608
+ */
609
+ async clearScopedHeaders(origin) {
610
+ const page = this.getPage();
611
+ if (origin) {
612
+ let urlPattern;
613
+ try {
614
+ const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
615
+ urlPattern = `**://${url.host}/**`;
616
+ }
617
+ catch {
618
+ urlPattern = `**://${origin}/**`;
619
+ }
620
+ const handler = this.scopedHeaderRoutes.get(urlPattern);
621
+ if (handler) {
622
+ await page.unroute(urlPattern, handler);
623
+ this.scopedHeaderRoutes.delete(urlPattern);
624
+ }
625
+ }
626
+ else {
627
+ // Clear all scoped header routes
628
+ for (const [pattern, handler] of this.scopedHeaderRoutes) {
629
+ await page.unroute(pattern, handler);
630
+ }
631
+ this.scopedHeaderRoutes.clear();
632
+ }
633
+ }
634
+ /**
635
+ * Start tracing
636
+ */
637
+ async startTracing(options) {
638
+ const context = this.contexts[0];
639
+ if (context) {
640
+ await context.tracing.start({
641
+ screenshots: options.screenshots ?? true,
642
+ snapshots: options.snapshots ?? true,
643
+ });
644
+ }
645
+ }
646
+ /**
647
+ * Stop tracing and save
648
+ */
649
+ async stopTracing(path) {
650
+ const context = this.contexts[0];
651
+ if (context) {
652
+ await context.tracing.stop(path ? { path } : undefined);
653
+ }
654
+ }
655
+ /**
656
+ * Get the current browser context (first context)
657
+ */
658
+ getContext() {
659
+ return this.contexts[0] ?? null;
660
+ }
661
+ /**
662
+ * Save storage state (cookies, localStorage, etc.)
663
+ */
664
+ async saveStorageState(path) {
665
+ const context = this.contexts[0];
666
+ if (context) {
667
+ await context.storageState({ path });
668
+ }
669
+ }
670
+ /**
671
+ * Get all pages
672
+ */
673
+ getPages() {
674
+ return this.pages;
675
+ }
676
+ /**
677
+ * Get current page index
678
+ */
679
+ getActiveIndex() {
680
+ return this.activePageIndex;
681
+ }
682
+ /**
683
+ * Get the current browser instance
684
+ */
685
+ getBrowser() {
686
+ return this.browser;
687
+ }
688
+ /**
689
+ * Check if an existing CDP connection is still alive
690
+ * by verifying we can access browser contexts and that at least one has pages
691
+ */
692
+ isCdpConnectionAlive() {
693
+ if (!this.browser)
694
+ return false;
695
+ try {
696
+ const contexts = this.browser.contexts();
697
+ if (contexts.length === 0)
698
+ return false;
699
+ return contexts.some((context) => context.pages().length > 0);
700
+ }
701
+ catch {
702
+ return false;
703
+ }
704
+ }
705
+ /**
706
+ * Check if CDP connection needs to be re-established
707
+ */
708
+ needsCdpReconnect(cdpEndpoint) {
709
+ if (!this.browser?.isConnected())
710
+ return true;
711
+ if (this.cdpEndpoint !== cdpEndpoint)
712
+ return true;
713
+ if (!this.isCdpConnectionAlive())
714
+ return true;
715
+ return false;
716
+ }
717
+ /**
718
+ * Close a Nstbrowser session via API
719
+ * Only stops the browser if it's a temporary "once" browser
720
+ * Profile browsers are left running for reuse
721
+ */
722
+ async closeNstbrowserSession(profileId) {
723
+ if (!this.nstApiKey || !this.nstHost || !this.nstPort)
724
+ return;
725
+ // Only stop "once" browsers (temporary browsers)
726
+ // Profile browsers should stay running for reuse
727
+ if (profileId === 'once') {
728
+ try {
729
+ // For once browsers, we stop all temporary browsers
730
+ await fetch(`http://${this.nstHost}:${this.nstPort}/api/v2/browsers/`, {
731
+ method: 'DELETE',
732
+ headers: {
733
+ 'Content-Type': 'application/json',
734
+ 'x-api-key': this.nstApiKey,
735
+ },
736
+ body: JSON.stringify([]), // Empty array stops all once browsers
737
+ });
738
+ }
739
+ catch (error) {
740
+ console.error('Failed to stop Nstbrowser once session:', error);
741
+ }
742
+ }
743
+ // For profile browsers, we don't stop them - just disconnect
744
+ // This allows the browser to stay running for subsequent commands
745
+ }
746
+ /**
747
+ * Connect to Nstbrowser remote browser via CDP.
748
+ * Uses WebSocket CDP endpoints: /api/v2/connect or /api/v2/connect/{profileId}
749
+ * Requires NST_API_KEY, NST_HOST (default: 127.0.0.1), NST_PORT (default: 8848)
750
+ *
751
+ * Profile selection priority:
752
+ * 1. nstProfileId from launch options (highest priority)
753
+ * 2. nstProfileName from launch options
754
+ * 3. NST_PROFILE_ID environment variable
755
+ * 4. NST_PROFILE environment variable (backward compatibility)
756
+ * 5. Once profile (temporary browser, default)
757
+ *
758
+ * When using profile name:
759
+ * - First checks if a browser with matching profile name is already running (uses earliest started)
760
+ * - If not running, queries profile API to get profileId, then starts browser
761
+ * - If profile not found, throws error
762
+ */
763
+ async connectToNstbrowser(options) {
764
+ // Write debug log to file
765
+ const fs = await import('fs');
766
+ const debugLog = `/tmp/nst-debug-${Date.now()}.log`;
767
+ fs.writeFileSync(debugLog, `connectToNstbrowser called\noptions: ${JSON.stringify({
768
+ nstProfileId: options?.nstProfileId,
769
+ nstProfileName: options?.nstProfileName,
770
+ }, null, 2)}\n`);
771
+ console.error('[DEBUG] ===== connectToNstbrowser called =====');
772
+ console.error('[DEBUG] options:', JSON.stringify({
773
+ nstProfileId: options?.nstProfileId,
774
+ nstProfileName: options?.nstProfileName,
775
+ }, null, 2));
776
+ console.error(`[DEBUG] Debug log written to: ${debugLog}`);
777
+ const nstApiKey = process.env.NST_API_KEY;
778
+ const nstHost = process.env.NST_HOST || '127.0.0.1';
779
+ const nstPort = parseInt(process.env.NST_PORT || '8848', 10);
780
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
781
+ console.error('[DEBUG] Nstbrowser connection parameters:', {
782
+ host: nstHost,
783
+ port: nstPort,
784
+ apiKey: nstApiKey ? `${nstApiKey.substring(0, 8)}...` : 'undefined',
785
+ nstProfileId: options?.nstProfileId,
786
+ nstProfileName: options?.nstProfileName,
787
+ envProfileId: process.env.NST_PROFILE_ID,
788
+ envProfile: process.env.NST_PROFILE,
789
+ });
790
+ }
791
+ if (!nstApiKey) {
792
+ throw new Error('NST_API_KEY is required when using nst as a provider. ' +
793
+ 'Set it via environment variable or --nst-api-key flag.');
794
+ }
795
+ // Check if Nstbrowser client is installed
796
+ const isInstalled = await isNstbrowserInstalled();
797
+ if (!isInstalled) {
798
+ const instructions = getNstbrowserInstallInstructions();
799
+ throw new Error(`Nstbrowser client is not installed.\n${instructions}`);
800
+ }
801
+ // Check if Nstbrowser client is running
802
+ const isRunning = await isNstbrowserRunning(nstHost, nstPort);
803
+ if (!isRunning) {
804
+ console.error('Nstbrowser client is not running. Attempting to start...');
805
+ const started = await startNstbrowserClient();
806
+ if (!started) {
807
+ throw new Error('Failed to start Nstbrowser client automatically. ' +
808
+ 'Please start it manually and try again.');
809
+ }
810
+ }
811
+ // Import NstbrowserClient and profile resolver
812
+ const { NstbrowserClient } = await import('./nstbrowser-client.js');
813
+ const { resolveProfile, ensureBrowserRunning } = await import('./nstbrowser-profile-resolver.js');
814
+ const client = new NstbrowserClient(nstHost, nstPort, nstApiKey);
815
+ // Resolve profile using unified logic
816
+ const resolved = await resolveProfile(client, {
817
+ profileId: options?.nstProfileId,
818
+ profileName: options?.nstProfileName,
819
+ allowOnce: true,
820
+ autoStart: true,
821
+ });
822
+ // Ensure browser is running and get WebSocket URL
823
+ const profileWithWs = await ensureBrowserRunning(client, resolved, nstHost, nstPort, nstApiKey);
824
+ if (!profileWithWs.wsUrl) {
825
+ throw new Error('Failed to get WebSocket URL for browser');
826
+ }
827
+ // Store session info for cleanup
828
+ this.nstSessionId = profileWithWs.profileId || 'once';
829
+ this.nstApiKey = nstApiKey;
830
+ this.nstHost = nstHost;
831
+ this.nstPort = nstPort;
832
+ // Connect to the browser via CDP WebSocket
833
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
834
+ console.error(`[DEBUG] Connecting to Nstbrowser via CDP: ${profileWithWs.wsUrl.replace(nstApiKey, '***')}`);
835
+ }
836
+ try {
837
+ await this.connectViaCDP(profileWithWs.wsUrl);
838
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
839
+ console.error('[DEBUG] Successfully connected to Nstbrowser');
840
+ }
841
+ }
842
+ catch (error) {
843
+ throw new Error(`Failed to connect to Nstbrowser CDP: ${error}`);
844
+ }
845
+ }
846
+ /**
847
+ * Launch the browser with the specified options
848
+ * If already launched, this is a no-op (browser stays open)
849
+ */
850
+ async launch(options) {
851
+ // Debug: log launch options
852
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
853
+ console.error('[DEBUG] launch() called with options:', {
854
+ provider: options.provider,
855
+ nstProfileName: options.nstProfileName,
856
+ nstProfileId: options.nstProfileId,
857
+ cdpPort: options.cdpPort,
858
+ cdpUrl: options.cdpUrl,
859
+ autoConnect: options.autoConnect,
860
+ });
861
+ }
862
+ // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
863
+ const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
864
+ const hasExtensions = !!options.extensions?.length;
865
+ const hasProfile = !!options.profile;
866
+ const hasStorageState = !!options.storageState;
867
+ if (hasExtensions && cdpEndpoint) {
868
+ throw new Error('Extensions cannot be used with CDP connection');
869
+ }
870
+ if (hasProfile && cdpEndpoint) {
871
+ throw new Error('Profile cannot be used with CDP connection');
872
+ }
873
+ if (hasStorageState && hasProfile) {
874
+ throw new Error('Storage state cannot be used with profile (profile is already persistent storage)');
875
+ }
876
+ if (hasStorageState && hasExtensions) {
877
+ throw new Error('Storage state cannot be used with extensions (extensions require persistent context)');
878
+ }
879
+ if (this.isLaunched()) {
880
+ // Determine if we truly need to relaunch.
881
+ // We should NOT relaunch if we are already connected to a CDP/Nstbrowser instance
882
+ // and the new options don't explicitly request a DIFFERENT endpoint or a local provider.
883
+ const isSwitchingToLocal = options.provider === 'local' || options.headless === true || options.headless === false;
884
+ const hasNewCdpInfo = !!cdpEndpoint ||
885
+ !!options.autoConnect ||
886
+ !!options.nstProfileId ||
887
+ !!options.nstProfileName;
888
+ let needsRelaunch = false;
889
+ if (this.cdpEndpoint !== null) {
890
+ // We are currently in CDP/Remote mode
891
+ if (hasNewCdpInfo) {
892
+ // Only relaunch if the new CDP info is different from current
893
+ needsRelaunch = !!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint);
894
+ }
895
+ else if (options.provider === 'local') {
896
+ // Explicitly switching to local
897
+ needsRelaunch = true;
898
+ }
899
+ else {
900
+ // No new CDP info and not explicitly local -> Stay with current remote browser
901
+ needsRelaunch = false;
902
+ }
903
+ }
904
+ else {
905
+ // We are currently in Local mode
906
+ if (hasNewCdpInfo || (options.provider && options.provider !== 'local')) {
907
+ // Switching to Remote/Nstbrowser
908
+ needsRelaunch = true;
909
+ }
910
+ else {
911
+ // Stay in local, but check if basic options changed significantly (simplified)
912
+ needsRelaunch = false;
913
+ }
914
+ }
915
+ if (needsRelaunch) {
916
+ await this.close();
917
+ }
918
+ else {
919
+ // If we're already connected and don't need a relaunch,
920
+ // just update some minor settings and return.
921
+ if (options.colorScheme)
922
+ this.colorScheme = options.colorScheme;
923
+ return;
924
+ }
925
+ }
926
+ if (options.colorScheme) {
927
+ this.colorScheme = options.colorScheme;
928
+ }
929
+ if (options.downloadPath) {
930
+ this.downloadPath = options.downloadPath;
931
+ }
932
+ if (options.allowedDomains && options.allowedDomains.length > 0) {
933
+ this.allowedDomains = options.allowedDomains.map((d) => d.toLowerCase());
934
+ }
935
+ else {
936
+ const envDomains = process.env.NSTBROWSER_AI_AGENT_ALLOWED_DOMAINS;
937
+ if (envDomains) {
938
+ this.allowedDomains = parseDomainList(envDomains);
939
+ }
940
+ }
941
+ if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
942
+ const warning = "--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
943
+ this.launchWarnings.push(warning);
944
+ console.error(`[WARN] ${warning}`);
945
+ }
946
+ if (cdpEndpoint) {
947
+ await this.connectViaCDP(cdpEndpoint);
948
+ return;
949
+ }
950
+ if (options.autoConnect) {
951
+ await this.autoConnectViaCDP();
952
+ return;
953
+ }
954
+ // Cloud browser providers require explicit opt-in via -p flag or NSTBROWSER_AI_AGENT_PROVIDER env var
955
+ // -p flag takes precedence over env var
956
+ const provider = options.provider ?? process.env.NSTBROWSER_AI_AGENT_PROVIDER;
957
+ if (this.downloadPath && provider) {
958
+ const warning = "--download-path is ignored when using a cloud provider (downloads use the remote browser's configuration)";
959
+ this.launchWarnings.push(warning);
960
+ console.error(`[WARN] ${warning}`);
961
+ }
962
+ // Nstbrowser: requires explicit opt-in via -p nst flag or NSTBROWSER_AI_AGENT_PROVIDER=nst
963
+ if (provider === 'nst') {
964
+ // Write debug log to file
965
+ const fs = await import('fs');
966
+ const debugLog = `/tmp/nst-launch-debug-${Date.now()}.log`;
967
+ fs.writeFileSync(debugLog, `launch() calling connectToNstbrowser\noptions: ${JSON.stringify({
968
+ nstProfileId: options.nstProfileId,
969
+ nstProfileName: options.nstProfileName,
970
+ provider: options.provider,
971
+ }, null, 2)}\n`);
972
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
973
+ console.error('[DEBUG] Starting Nstbrowser connection...');
974
+ console.error('[DEBUG] Environment variables in launch:', {
975
+ NST_API_KEY: process.env.NST_API_KEY
976
+ ? `${process.env.NST_API_KEY.substring(0, 8)}...`
977
+ : 'undefined',
978
+ NST_HOST: process.env.NST_HOST,
979
+ NST_PORT: process.env.NST_PORT,
980
+ NST_PROFILE: process.env.NST_PROFILE,
981
+ });
982
+ console.error(`[DEBUG] Launch debug log written to: ${debugLog}`);
983
+ }
984
+ try {
985
+ await this.connectToNstbrowser(options);
986
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
987
+ console.error('[DEBUG] Nstbrowser connection successful');
988
+ }
989
+ }
990
+ catch (error) {
991
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
992
+ console.error('[DEBUG] Nstbrowser connection failed:', error);
993
+ }
994
+ throw error;
995
+ }
996
+ return;
997
+ }
998
+ if (this.downloadPath) {
999
+ const resolved = path.resolve(this.downloadPath);
1000
+ const stat = statSync(resolved, { throwIfNoEntry: false });
1001
+ if (stat && !stat.isDirectory()) {
1002
+ throw new Error(`Download path is not a directory: ${resolved}`);
1003
+ }
1004
+ if (!stat) {
1005
+ try {
1006
+ mkdirSync(resolved, { recursive: true });
1007
+ }
1008
+ catch (e) {
1009
+ const msg = e instanceof Error ? e.message : String(e);
1010
+ throw new Error(`Cannot create download directory '${resolved}': ${msg}`);
1011
+ }
1012
+ }
1013
+ this.downloadPath = resolved;
1014
+ }
1015
+ const browserType = options.browser ?? 'chromium';
1016
+ if (hasExtensions && browserType !== 'chromium') {
1017
+ throw new Error('Extensions are only supported in Chromium');
1018
+ }
1019
+ // allowFileAccess is only supported in Chromium
1020
+ if (options.allowFileAccess && browserType !== 'chromium') {
1021
+ throw new Error('allowFileAccess is only supported in Chromium');
1022
+ }
1023
+ const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
1024
+ // Build base args array with file access flags if enabled
1025
+ // --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
1026
+ // --allow-file-access: allows the browser to access local files in general
1027
+ const fileAccessArgs = options.allowFileAccess
1028
+ ? ['--allow-file-access-from-files', '--allow-file-access']
1029
+ : [];
1030
+ const baseArgs = options.args
1031
+ ? [...fileAccessArgs, ...options.args]
1032
+ : fileAccessArgs.length > 0
1033
+ ? fileAccessArgs
1034
+ : undefined;
1035
+ // Auto-detect args that control window size and disable viewport emulation
1036
+ // so Playwright doesn't override the browser's own sizing behavior
1037
+ const hasWindowSizeArgs = baseArgs?.some((arg) => arg === '--start-maximized' || arg.startsWith('--window-size='));
1038
+ const viewport = options.viewport !== undefined
1039
+ ? options.viewport
1040
+ : hasWindowSizeArgs
1041
+ ? null
1042
+ : { width: 1280, height: 720 };
1043
+ let context;
1044
+ if (hasExtensions) {
1045
+ // Extensions require persistent context in a temp directory
1046
+ const extPaths = options.extensions.join(',');
1047
+ const session = process.env.NSTBROWSER_AI_AGENT_SESSION || 'default';
1048
+ // Combine extension args with custom args and file access args
1049
+ const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
1050
+ const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
1051
+ context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `nstbrowser-ai-agent-ext-${session}`), {
1052
+ headless: false,
1053
+ executablePath: options.executablePath,
1054
+ args: allArgs,
1055
+ viewport,
1056
+ extraHTTPHeaders: options.headers,
1057
+ userAgent: options.userAgent,
1058
+ ...(options.proxy && { proxy: options.proxy }),
1059
+ ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1060
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1061
+ ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1062
+ });
1063
+ this.isPersistentContext = true;
1064
+ }
1065
+ else if (hasProfile) {
1066
+ // Profile uses persistent context for durable cookies/storage
1067
+ // Expand ~ to home directory since it won't be shell-expanded
1068
+ const profilePath = options.profile.replace(/^~\//, os.homedir() + '/');
1069
+ context = await launcher.launchPersistentContext(profilePath, {
1070
+ headless: options.headless ?? true,
1071
+ executablePath: options.executablePath,
1072
+ args: baseArgs,
1073
+ viewport,
1074
+ extraHTTPHeaders: options.headers,
1075
+ userAgent: options.userAgent,
1076
+ ...(options.proxy && { proxy: options.proxy }),
1077
+ ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1078
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1079
+ ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1080
+ });
1081
+ this.isPersistentContext = true;
1082
+ }
1083
+ else {
1084
+ // Regular ephemeral browser
1085
+ this.browser = await launcher.launch({
1086
+ headless: options.headless ?? true,
1087
+ executablePath: options.executablePath,
1088
+ args: baseArgs,
1089
+ ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1090
+ });
1091
+ this.cdpEndpoint = null;
1092
+ // Check for auto-load state file (supports encrypted files)
1093
+ let storageState = options.storageState ? options.storageState : undefined;
1094
+ if (!storageState && options.autoStateFilePath) {
1095
+ try {
1096
+ const fs = await import('fs');
1097
+ if (fs.existsSync(options.autoStateFilePath)) {
1098
+ const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
1099
+ const parsed = JSON.parse(content);
1100
+ if (isEncryptedPayload(parsed)) {
1101
+ const key = getEncryptionKey();
1102
+ if (key) {
1103
+ try {
1104
+ const decrypted = decryptData(parsed, key);
1105
+ storageState = JSON.parse(decrypted);
1106
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
1107
+ console.error(`[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`);
1108
+ }
1109
+ }
1110
+ catch (decryptErr) {
1111
+ const warning = 'Failed to decrypt state file - wrong encryption key? Starting fresh.';
1112
+ this.launchWarnings.push(warning);
1113
+ console.error(`[WARN] ${warning}`);
1114
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
1115
+ console.error(`[DEBUG] Decryption error:`, decryptErr);
1116
+ }
1117
+ }
1118
+ }
1119
+ else {
1120
+ const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
1121
+ this.launchWarnings.push(warning);
1122
+ console.error(`[WARN] ${warning}`);
1123
+ }
1124
+ }
1125
+ else {
1126
+ storageState = options.autoStateFilePath;
1127
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
1128
+ console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+ catch (err) {
1134
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
1135
+ console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
1136
+ }
1137
+ }
1138
+ }
1139
+ context = await this.browser.newContext({
1140
+ viewport,
1141
+ extraHTTPHeaders: options.headers,
1142
+ userAgent: options.userAgent,
1143
+ storageState,
1144
+ ...(options.proxy && { proxy: options.proxy }),
1145
+ ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
1146
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1147
+ });
1148
+ }
1149
+ context.setDefaultTimeout(getDefaultTimeout());
1150
+ this.contexts.push(context);
1151
+ this.setupContextTracking(context);
1152
+ await this.ensureDomainFilter(context);
1153
+ const page = context.pages()[0] ?? (await context.newPage());
1154
+ await this.sanitizeExistingPages([page]);
1155
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1156
+ if (!this.pages.includes(page)) {
1157
+ this.pages.push(page);
1158
+ this.setupPageTracking(page);
1159
+ }
1160
+ this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
1161
+ }
1162
+ /**
1163
+ * Connect to a running browser via CDP (Chrome DevTools Protocol)
1164
+ * @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
1165
+ */
1166
+ async connectViaCDP(cdpEndpoint, options) {
1167
+ if (!cdpEndpoint) {
1168
+ throw new Error('CDP endpoint is required for CDP connection');
1169
+ }
1170
+ // Determine the connection URL:
1171
+ // - If it starts with ws://, wss://, http://, or https://, use it directly
1172
+ // - If it's a numeric string (e.g., "9222"), treat as port for localhost
1173
+ // - Otherwise, treat it as a port number for localhost
1174
+ let cdpUrl;
1175
+ if (cdpEndpoint.startsWith('ws://') ||
1176
+ cdpEndpoint.startsWith('wss://') ||
1177
+ cdpEndpoint.startsWith('http://') ||
1178
+ cdpEndpoint.startsWith('https://')) {
1179
+ cdpUrl = cdpEndpoint;
1180
+ }
1181
+ else if (/^\d+$/.test(cdpEndpoint)) {
1182
+ // Numeric string - treat as port number (handles JSON serialization quirks)
1183
+ cdpUrl = `http://localhost:${cdpEndpoint}`;
1184
+ }
1185
+ else {
1186
+ // Unknown format - still try as port for backward compatibility
1187
+ cdpUrl = `http://localhost:${cdpEndpoint}`;
1188
+ }
1189
+ const browser = await chromium
1190
+ .connectOverCDP(cdpUrl, { timeout: options?.timeout })
1191
+ .catch(() => {
1192
+ throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
1193
+ (cdpUrl.includes('localhost')
1194
+ ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
1195
+ : 'Make sure the remote browser is accessible and the URL is correct.'));
1196
+ });
1197
+ // Validate and set up state, cleaning up browser connection if anything fails
1198
+ try {
1199
+ const contexts = browser.contexts();
1200
+ if (contexts.length === 0) {
1201
+ throw new Error('No browser context found. Make sure the app has an open window.');
1202
+ }
1203
+ // Filter out pages with empty URLs, which can cause Playwright to hang
1204
+ const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
1205
+ if (allPages.length === 0) {
1206
+ throw new Error('No page found. Make sure the app has loaded content.');
1207
+ }
1208
+ // All validation passed - commit state
1209
+ this.browser = browser;
1210
+ this.cdpEndpoint = cdpEndpoint;
1211
+ for (const context of contexts) {
1212
+ context.setDefaultTimeout(10000);
1213
+ this.contexts.push(context);
1214
+ this.setupContextTracking(context);
1215
+ await this.ensureDomainFilter(context);
1216
+ }
1217
+ await this.sanitizeExistingPages(allPages);
1218
+ for (const page of allPages) {
1219
+ this.pages.push(page);
1220
+ this.setupPageTracking(page);
1221
+ }
1222
+ this.activePageIndex = 0;
1223
+ }
1224
+ catch (error) {
1225
+ // Clean up browser connection if validation or setup failed
1226
+ await browser.close().catch(() => { });
1227
+ throw error;
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Get Chrome's default user data directory paths for the current platform.
1232
+ * Returns an array of candidate paths to check (stable, then beta/canary).
1233
+ */
1234
+ getChromeUserDataDirs() {
1235
+ const home = os.homedir();
1236
+ const platform = os.platform();
1237
+ if (platform === 'darwin') {
1238
+ return [
1239
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
1240
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
1241
+ path.join(home, 'Library', 'Application Support', 'Chromium'),
1242
+ ];
1243
+ }
1244
+ else if (platform === 'win32') {
1245
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
1246
+ return [
1247
+ path.join(localAppData, 'Google', 'Chrome', 'User Data'),
1248
+ path.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
1249
+ path.join(localAppData, 'Chromium', 'User Data'),
1250
+ ];
1251
+ }
1252
+ else {
1253
+ // Linux
1254
+ return [
1255
+ path.join(home, '.config', 'google-chrome'),
1256
+ path.join(home, '.config', 'google-chrome-unstable'),
1257
+ path.join(home, '.config', 'chromium'),
1258
+ ];
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Try to read the DevToolsActivePort file from a Chrome user data directory.
1263
+ * Returns { port, wsPath } if found, or null if not available.
1264
+ */
1265
+ readDevToolsActivePort(userDataDir) {
1266
+ const filePath = path.join(userDataDir, 'DevToolsActivePort');
1267
+ try {
1268
+ if (!existsSync(filePath))
1269
+ return null;
1270
+ const content = readFileSync(filePath, 'utf-8').trim();
1271
+ const lines = content.split('\n');
1272
+ if (lines.length < 2)
1273
+ return null;
1274
+ const port = parseInt(lines[0].trim(), 10);
1275
+ const wsPath = lines[1].trim();
1276
+ if (isNaN(port) || port <= 0 || port > 65535)
1277
+ return null;
1278
+ if (!wsPath)
1279
+ return null;
1280
+ return { port, wsPath };
1281
+ }
1282
+ catch {
1283
+ return null;
1284
+ }
1285
+ }
1286
+ /**
1287
+ * Try to discover a Chrome CDP endpoint by querying an HTTP debug port.
1288
+ * Returns the WebSocket debugger URL if available.
1289
+ */
1290
+ async probeDebugPort(port) {
1291
+ try {
1292
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
1293
+ signal: AbortSignal.timeout(2000),
1294
+ });
1295
+ if (!response.ok)
1296
+ return null;
1297
+ const data = (await response.json());
1298
+ return data.webSocketDebuggerUrl ?? null;
1299
+ }
1300
+ catch {
1301
+ return null;
1302
+ }
1303
+ }
1304
+ /**
1305
+ * Auto-discover and connect to a running Chrome/Chromium instance.
1306
+ *
1307
+ * Discovery strategy:
1308
+ * 1. Read DevToolsActivePort from Chrome's default user data directories
1309
+ * 2. If found, connect using the port and WebSocket path from that file
1310
+ * 3. If not found, probe common debugging ports (9222, 9229)
1311
+ * 4. If a port responds, connect via CDP
1312
+ */
1313
+ async autoConnectViaCDP() {
1314
+ // Strategy 1: Check DevToolsActivePort files
1315
+ const userDataDirs = this.getChromeUserDataDirs();
1316
+ for (const dir of userDataDirs) {
1317
+ const activePort = this.readDevToolsActivePort(dir);
1318
+ if (activePort) {
1319
+ // Try HTTP discovery first (works with --remote-debugging-port mode)
1320
+ const wsUrl = await this.probeDebugPort(activePort.port);
1321
+ if (wsUrl) {
1322
+ await this.connectViaCDP(wsUrl);
1323
+ return;
1324
+ }
1325
+ // HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
1326
+ // WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
1327
+ // path read directly from DevToolsActivePort.
1328
+ const directWsUrl = `ws://127.0.0.1:${activePort.port}${activePort.wsPath}`;
1329
+ try {
1330
+ if (process.env.NSTBROWSER_AI_AGENT_DEBUG === '1') {
1331
+ console.error(`[DEBUG] HTTP probe failed on port ${activePort.port}, ` +
1332
+ `attempting direct WebSocket connection to ${directWsUrl}`);
1333
+ }
1334
+ await this.connectViaCDP(directWsUrl, { timeout: 60_000 });
1335
+ return;
1336
+ }
1337
+ catch {
1338
+ // Direct WebSocket also failed, try next directory
1339
+ }
1340
+ }
1341
+ }
1342
+ // Strategy 2: Probe common debugging ports
1343
+ const commonPorts = [9222, 9229];
1344
+ for (const port of commonPorts) {
1345
+ const wsUrl = await this.probeDebugPort(port);
1346
+ if (wsUrl) {
1347
+ await this.connectViaCDP(wsUrl);
1348
+ return;
1349
+ }
1350
+ }
1351
+ // Nothing found
1352
+ const platform = os.platform();
1353
+ let hint;
1354
+ if (platform === 'darwin') {
1355
+ hint =
1356
+ 'Start Chrome with: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
1357
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1358
+ }
1359
+ else if (platform === 'win32') {
1360
+ hint =
1361
+ 'Start Chrome with: chrome.exe --remote-debugging-port=9222\n' +
1362
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1363
+ }
1364
+ else {
1365
+ hint =
1366
+ 'Start Chrome with: google-chrome --remote-debugging-port=9222\n' +
1367
+ 'Or enable remote debugging in Chrome 144+ at chrome://inspect/#remote-debugging';
1368
+ }
1369
+ throw new Error(`No running Chrome instance with remote debugging found.\n${hint}`);
1370
+ }
1371
+ /**
1372
+ * Set up console, error, and close tracking for a page
1373
+ */
1374
+ setupPageTracking(page) {
1375
+ if (this.colorScheme) {
1376
+ page.emulateMedia({ colorScheme: this.colorScheme }).catch(() => { });
1377
+ }
1378
+ page.on('console', (msg) => {
1379
+ this.consoleMessages.push({
1380
+ type: msg.type(),
1381
+ text: msg.text(),
1382
+ timestamp: Date.now(),
1383
+ });
1384
+ });
1385
+ page.on('pageerror', (error) => {
1386
+ this.pageErrors.push({
1387
+ message: error.message,
1388
+ timestamp: Date.now(),
1389
+ });
1390
+ });
1391
+ page.on('close', () => {
1392
+ const index = this.pages.indexOf(page);
1393
+ if (index !== -1) {
1394
+ this.pages.splice(index, 1);
1395
+ if (this.activePageIndex >= this.pages.length) {
1396
+ this.activePageIndex = Math.max(0, this.pages.length - 1);
1397
+ }
1398
+ }
1399
+ });
1400
+ }
1401
+ /**
1402
+ * Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
1403
+ * This handles pages created externally (e.g., via target="_blank" links, window.open)
1404
+ */
1405
+ setupContextTracking(context) {
1406
+ context.on('page', (page) => {
1407
+ // Only add if not already tracked (avoids duplicates when newTab() creates pages)
1408
+ if (!this.pages.includes(page)) {
1409
+ this.pages.push(page);
1410
+ this.setupPageTracking(page);
1411
+ }
1412
+ // Auto-switch to the newly opened tab so subsequent commands target it.
1413
+ // For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
1414
+ // but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
1415
+ // stays in sync with the browser.
1416
+ const newIndex = this.pages.indexOf(page);
1417
+ if (newIndex !== -1 && newIndex !== this.activePageIndex) {
1418
+ this.activePageIndex = newIndex;
1419
+ // Invalidate CDP session since the active page changed
1420
+ this.invalidateCDPSession().catch(() => { });
1421
+ }
1422
+ });
1423
+ }
1424
+ /**
1425
+ * Create a new tab in the current context
1426
+ */
1427
+ async newTab() {
1428
+ if (!this.browser || this.contexts.length === 0) {
1429
+ throw new Error('Browser not launched');
1430
+ }
1431
+ // Invalidate CDP session since we're switching to a new page
1432
+ await this.invalidateCDPSession();
1433
+ const context = this.contexts[0]; // Use first context for tabs
1434
+ const page = await context.newPage();
1435
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1436
+ if (!this.pages.includes(page)) {
1437
+ this.pages.push(page);
1438
+ this.setupPageTracking(page);
1439
+ }
1440
+ this.activePageIndex = this.pages.length - 1;
1441
+ return { index: this.activePageIndex, total: this.pages.length };
1442
+ }
1443
+ /**
1444
+ * Create a new window (new context)
1445
+ */
1446
+ async newWindow(viewport) {
1447
+ if (!this.browser) {
1448
+ throw new Error('Browser not launched');
1449
+ }
1450
+ const context = await this.browser.newContext({
1451
+ viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
1452
+ ...(this.colorScheme && { colorScheme: this.colorScheme }),
1453
+ });
1454
+ context.setDefaultTimeout(getDefaultTimeout());
1455
+ this.contexts.push(context);
1456
+ this.setupContextTracking(context);
1457
+ await this.ensureDomainFilter(context);
1458
+ const page = await context.newPage();
1459
+ // Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
1460
+ if (!this.pages.includes(page)) {
1461
+ this.pages.push(page);
1462
+ this.setupPageTracking(page);
1463
+ }
1464
+ this.activePageIndex = this.pages.length - 1;
1465
+ return { index: this.activePageIndex, total: this.pages.length };
1466
+ }
1467
+ /**
1468
+ * Invalidate the current CDP session (must be called before switching pages)
1469
+ * This ensures screencast and input injection work correctly after tab switch
1470
+ */
1471
+ async invalidateCDPSession() {
1472
+ // Stop screencast if active (it's tied to the current page's CDP session)
1473
+ if (this.screencastActive) {
1474
+ await this.stopScreencast();
1475
+ }
1476
+ // Detach and clear the CDP session
1477
+ if (this.cdpSession) {
1478
+ await this.cdpSession.detach().catch(() => { });
1479
+ this.cdpSession = null;
1480
+ }
1481
+ }
1482
+ /**
1483
+ * Switch to a specific tab/page by index
1484
+ */
1485
+ async switchTo(index) {
1486
+ if (index < 0 || index >= this.pages.length) {
1487
+ throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
1488
+ }
1489
+ // Invalidate CDP session before switching (it's page-specific)
1490
+ if (index !== this.activePageIndex) {
1491
+ await this.invalidateCDPSession();
1492
+ }
1493
+ this.activePageIndex = index;
1494
+ const page = this.pages[index];
1495
+ return {
1496
+ index: this.activePageIndex,
1497
+ url: page.url(),
1498
+ title: '', // Title requires async, will be fetched separately
1499
+ };
1500
+ }
1501
+ /**
1502
+ * Close a specific tab/page
1503
+ */
1504
+ async closeTab(index) {
1505
+ const targetIndex = index ?? this.activePageIndex;
1506
+ if (targetIndex < 0 || targetIndex >= this.pages.length) {
1507
+ throw new Error(`Invalid tab index: ${targetIndex}`);
1508
+ }
1509
+ if (this.pages.length === 1) {
1510
+ throw new Error('Cannot close the last tab. Use "close" to close the browser.');
1511
+ }
1512
+ // If closing the active tab, invalidate CDP session first
1513
+ if (targetIndex === this.activePageIndex) {
1514
+ await this.invalidateCDPSession();
1515
+ }
1516
+ const page = this.pages[targetIndex];
1517
+ await page.close();
1518
+ this.pages.splice(targetIndex, 1);
1519
+ // Adjust active index if needed
1520
+ if (this.activePageIndex >= this.pages.length) {
1521
+ this.activePageIndex = this.pages.length - 1;
1522
+ }
1523
+ else if (this.activePageIndex > targetIndex) {
1524
+ this.activePageIndex--;
1525
+ }
1526
+ return { closed: targetIndex, remaining: this.pages.length };
1527
+ }
1528
+ /**
1529
+ * List all tabs with their info
1530
+ */
1531
+ async listTabs() {
1532
+ const tabs = await Promise.all(this.pages.map(async (page, index) => ({
1533
+ index,
1534
+ url: page.url(),
1535
+ title: await page.title().catch(() => ''),
1536
+ active: index === this.activePageIndex,
1537
+ })));
1538
+ return tabs;
1539
+ }
1540
+ /**
1541
+ * Get or create a CDP session for the current page
1542
+ * Only works with Chromium-based browsers
1543
+ */
1544
+ async getCDPSession() {
1545
+ if (this.cdpSession) {
1546
+ return this.cdpSession;
1547
+ }
1548
+ const page = this.getPage();
1549
+ const context = page.context();
1550
+ // Create a new CDP session attached to the page
1551
+ this.cdpSession = await context.newCDPSession(page);
1552
+ return this.cdpSession;
1553
+ }
1554
+ /**
1555
+ * Check if screencast is currently active
1556
+ */
1557
+ isScreencasting() {
1558
+ return this.screencastActive;
1559
+ }
1560
+ /**
1561
+ * Start screencast - streams viewport frames via CDP
1562
+ * @param callback Function called for each frame
1563
+ * @param options Screencast options
1564
+ */
1565
+ async startScreencast(callback, options) {
1566
+ if (this.screencastActive) {
1567
+ throw new Error('Screencast already active');
1568
+ }
1569
+ const cdp = await this.getCDPSession();
1570
+ this.frameCallback = callback;
1571
+ this.screencastActive = true;
1572
+ // Create and store the frame handler so we can remove it later
1573
+ this.screencastFrameHandler = async (params) => {
1574
+ const frame = {
1575
+ data: params.data,
1576
+ metadata: params.metadata,
1577
+ sessionId: params.sessionId,
1578
+ };
1579
+ // Acknowledge the frame to receive the next one
1580
+ await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
1581
+ // Call the callback with the frame
1582
+ if (this.frameCallback) {
1583
+ this.frameCallback(frame);
1584
+ }
1585
+ };
1586
+ // Listen for screencast frames
1587
+ cdp.on('Page.screencastFrame', this.screencastFrameHandler);
1588
+ // Start the screencast
1589
+ await cdp.send('Page.startScreencast', {
1590
+ format: options?.format ?? 'jpeg',
1591
+ quality: options?.quality ?? 80,
1592
+ maxWidth: options?.maxWidth ?? 1280,
1593
+ maxHeight: options?.maxHeight ?? 720,
1594
+ everyNthFrame: options?.everyNthFrame ?? 1,
1595
+ });
1596
+ }
1597
+ /**
1598
+ * Stop screencast
1599
+ */
1600
+ async stopScreencast() {
1601
+ if (!this.screencastActive) {
1602
+ return;
1603
+ }
1604
+ try {
1605
+ const cdp = await this.getCDPSession();
1606
+ await cdp.send('Page.stopScreencast');
1607
+ // Remove the event listener to prevent accumulation
1608
+ if (this.screencastFrameHandler) {
1609
+ cdp.off('Page.screencastFrame', this.screencastFrameHandler);
1610
+ }
1611
+ }
1612
+ catch {
1613
+ // Ignore errors when stopping
1614
+ }
1615
+ this.screencastActive = false;
1616
+ this.frameCallback = null;
1617
+ this.screencastFrameHandler = null;
1618
+ }
1619
+ /**
1620
+ * Check if profiling is currently active
1621
+ */
1622
+ isProfilingActive() {
1623
+ return this.profilingActive;
1624
+ }
1625
+ /**
1626
+ * Start CDP profiling (Tracing)
1627
+ */
1628
+ async startProfiling(options) {
1629
+ if (this.profilingActive) {
1630
+ throw new Error('Profiling already active');
1631
+ }
1632
+ const cdp = await this.getCDPSession();
1633
+ const dataHandler = (params) => {
1634
+ if (params.value) {
1635
+ for (const evt of params.value) {
1636
+ if (this.profileChunks.length >= BrowserManager.MAX_PROFILE_EVENTS) {
1637
+ if (!this.profileEventsDropped) {
1638
+ this.profileEventsDropped = true;
1639
+ console.warn(`Profiling: exceeded ${BrowserManager.MAX_PROFILE_EVENTS} events, dropping further data`);
1640
+ }
1641
+ return;
1642
+ }
1643
+ this.profileChunks.push(evt);
1644
+ }
1645
+ }
1646
+ };
1647
+ const completeHandler = () => {
1648
+ if (this.profileCompleteResolver) {
1649
+ this.profileCompleteResolver();
1650
+ }
1651
+ };
1652
+ cdp.on('Tracing.dataCollected', dataHandler);
1653
+ cdp.on('Tracing.tracingComplete', completeHandler);
1654
+ const categories = options?.categories ?? [
1655
+ 'devtools.timeline',
1656
+ 'disabled-by-default-devtools.timeline',
1657
+ 'disabled-by-default-devtools.timeline.frame',
1658
+ 'disabled-by-default-devtools.timeline.stack',
1659
+ 'v8.execute',
1660
+ 'disabled-by-default-v8.cpu_profiler',
1661
+ 'disabled-by-default-v8.cpu_profiler.hires',
1662
+ 'v8',
1663
+ 'disabled-by-default-v8.runtime_stats',
1664
+ 'blink',
1665
+ 'blink.user_timing',
1666
+ 'latencyInfo',
1667
+ 'renderer.scheduler',
1668
+ 'sequence_manager',
1669
+ 'toplevel',
1670
+ ];
1671
+ try {
1672
+ await cdp.send('Tracing.start', {
1673
+ traceConfig: {
1674
+ includedCategories: categories,
1675
+ enableSampling: true,
1676
+ },
1677
+ transferMode: 'ReportEvents',
1678
+ });
1679
+ }
1680
+ catch (error) {
1681
+ cdp.off('Tracing.dataCollected', dataHandler);
1682
+ cdp.off('Tracing.tracingComplete', completeHandler);
1683
+ throw error;
1684
+ }
1685
+ // Only commit state after the CDP call succeeds
1686
+ this.profilingActive = true;
1687
+ this.profileChunks = [];
1688
+ this.profileEventsDropped = false;
1689
+ this.profileDataHandler = dataHandler;
1690
+ this.profileCompleteHandler = completeHandler;
1691
+ }
1692
+ /**
1693
+ * Stop CDP profiling and save to file
1694
+ */
1695
+ async stopProfiling(outputPath) {
1696
+ if (!this.profilingActive) {
1697
+ throw new Error('No profiling session active');
1698
+ }
1699
+ const cdp = await this.getCDPSession();
1700
+ const TRACE_TIMEOUT_MS = 30_000;
1701
+ const completePromise = new Promise((resolve, reject) => {
1702
+ const timer = setTimeout(() => reject(new Error('Profiling data collection timed out')), TRACE_TIMEOUT_MS);
1703
+ this.profileCompleteResolver = () => {
1704
+ clearTimeout(timer);
1705
+ resolve();
1706
+ };
1707
+ });
1708
+ await cdp.send('Tracing.end');
1709
+ let chunks;
1710
+ try {
1711
+ await completePromise;
1712
+ chunks = this.profileChunks;
1713
+ }
1714
+ finally {
1715
+ if (this.profileDataHandler) {
1716
+ cdp.off('Tracing.dataCollected', this.profileDataHandler);
1717
+ }
1718
+ if (this.profileCompleteHandler) {
1719
+ cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
1720
+ }
1721
+ this.profilingActive = false;
1722
+ this.profileChunks = [];
1723
+ this.profileEventsDropped = false;
1724
+ this.profileCompleteResolver = null;
1725
+ this.profileDataHandler = null;
1726
+ this.profileCompleteHandler = null;
1727
+ }
1728
+ const clockDomain = process.platform === 'linux'
1729
+ ? 'LINUX_CLOCK_MONOTONIC'
1730
+ : process.platform === 'darwin'
1731
+ ? 'MAC_MACH_ABSOLUTE_TIME'
1732
+ : undefined;
1733
+ const traceData = {
1734
+ traceEvents: chunks,
1735
+ };
1736
+ if (clockDomain) {
1737
+ traceData.metadata = { 'clock-domain': clockDomain };
1738
+ }
1739
+ const dir = path.dirname(outputPath);
1740
+ await mkdir(dir, { recursive: true });
1741
+ await writeFile(outputPath, JSON.stringify(traceData));
1742
+ const eventCount = chunks.length;
1743
+ return { path: outputPath, eventCount };
1744
+ }
1745
+ /**
1746
+ * Inject a mouse event via CDP
1747
+ */
1748
+ async injectMouseEvent(params) {
1749
+ const cdp = await this.getCDPSession();
1750
+ const cdpButton = params.button === 'left'
1751
+ ? 'left'
1752
+ : params.button === 'right'
1753
+ ? 'right'
1754
+ : params.button === 'middle'
1755
+ ? 'middle'
1756
+ : 'none';
1757
+ await cdp.send('Input.dispatchMouseEvent', {
1758
+ type: params.type,
1759
+ x: params.x,
1760
+ y: params.y,
1761
+ button: cdpButton,
1762
+ clickCount: params.clickCount ?? 1,
1763
+ deltaX: params.deltaX ?? 0,
1764
+ deltaY: params.deltaY ?? 0,
1765
+ modifiers: params.modifiers ?? 0,
1766
+ });
1767
+ }
1768
+ /**
1769
+ * Inject a keyboard event via CDP
1770
+ */
1771
+ async injectKeyboardEvent(params) {
1772
+ const cdp = await this.getCDPSession();
1773
+ await cdp.send('Input.dispatchKeyEvent', {
1774
+ type: params.type,
1775
+ key: params.key,
1776
+ code: params.code,
1777
+ text: params.text,
1778
+ modifiers: params.modifiers ?? 0,
1779
+ });
1780
+ }
1781
+ /**
1782
+ * Inject touch event via CDP (for mobile emulation)
1783
+ */
1784
+ async injectTouchEvent(params) {
1785
+ const cdp = await this.getCDPSession();
1786
+ await cdp.send('Input.dispatchTouchEvent', {
1787
+ type: params.type,
1788
+ touchPoints: params.touchPoints.map((tp, i) => ({
1789
+ x: tp.x,
1790
+ y: tp.y,
1791
+ id: tp.id ?? i,
1792
+ })),
1793
+ modifiers: params.modifiers ?? 0,
1794
+ });
1795
+ }
1796
+ /**
1797
+ * Check if video recording is currently active
1798
+ */
1799
+ isRecording() {
1800
+ return this.recordingContext !== null;
1801
+ }
1802
+ /**
1803
+ * Start recording to a video file using Playwright's native video recording.
1804
+ * Creates a fresh browser context with video recording enabled.
1805
+ * Automatically captures current URL and transfers cookies/storage if no URL provided.
1806
+ *
1807
+ * @param outputPath - Path to the output video file (will be .webm)
1808
+ * @param url - Optional URL to navigate to (defaults to current page URL)
1809
+ */
1810
+ async startRecording(outputPath, url) {
1811
+ if (this.recordingContext) {
1812
+ throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
1813
+ }
1814
+ if (!this.browser) {
1815
+ throw new Error('Browser not launched. Call launch first.');
1816
+ }
1817
+ // Check if output file already exists
1818
+ if (existsSync(outputPath)) {
1819
+ throw new Error(`Output file already exists: ${outputPath}`);
1820
+ }
1821
+ // Validate output path is .webm (Playwright native format)
1822
+ if (!outputPath.endsWith('.webm')) {
1823
+ throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
1824
+ }
1825
+ // Auto-capture current URL if none provided
1826
+ const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
1827
+ const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
1828
+ if (!url && currentPage) {
1829
+ const currentUrl = currentPage.url();
1830
+ if (currentUrl && currentUrl !== 'about:blank') {
1831
+ url = currentUrl;
1832
+ }
1833
+ }
1834
+ // Capture state from current context (cookies + storage)
1835
+ let storageState;
1836
+ if (currentContext) {
1837
+ try {
1838
+ storageState = await currentContext.storageState();
1839
+ }
1840
+ catch {
1841
+ // Ignore errors - context might be closed or invalid
1842
+ }
1843
+ }
1844
+ // Create a temp directory for video recording
1845
+ const session = process.env.NSTBROWSER_AI_AGENT_SESSION || 'default';
1846
+ this.recordingTempDir = path.join(os.tmpdir(), `nstbrowser-ai-agent-recording-${session}-${Date.now()}`);
1847
+ mkdirSync(this.recordingTempDir, { recursive: true });
1848
+ this.recordingOutputPath = outputPath;
1849
+ // Create a new context with video recording enabled and restored state
1850
+ const viewport = { width: 1280, height: 720 };
1851
+ this.recordingContext = await this.browser.newContext({
1852
+ viewport,
1853
+ recordVideo: {
1854
+ dir: this.recordingTempDir,
1855
+ size: viewport,
1856
+ },
1857
+ storageState,
1858
+ });
1859
+ this.recordingContext.setDefaultTimeout(10000);
1860
+ // Create a page in the recording context
1861
+ this.recordingPage = await this.recordingContext.newPage();
1862
+ // Add the recording context and page to our managed lists
1863
+ this.contexts.push(this.recordingContext);
1864
+ this.pages.push(this.recordingPage);
1865
+ this.activePageIndex = this.pages.length - 1;
1866
+ // Set up page tracking
1867
+ this.setupPageTracking(this.recordingPage);
1868
+ // Invalidate CDP session since we switched pages
1869
+ await this.invalidateCDPSession();
1870
+ // Navigate to URL if provided or captured
1871
+ if (url) {
1872
+ await this.recordingPage.goto(url, { waitUntil: 'load' });
1873
+ }
1874
+ }
1875
+ /**
1876
+ * Stop recording and save the video file
1877
+ * @returns Recording result with path
1878
+ */
1879
+ async stopRecording() {
1880
+ if (!this.recordingContext || !this.recordingPage) {
1881
+ return { path: '', frames: 0, error: 'No recording in progress' };
1882
+ }
1883
+ const outputPath = this.recordingOutputPath;
1884
+ try {
1885
+ // Get the video object before closing the page
1886
+ const video = this.recordingPage.video();
1887
+ // Remove recording page/context from our managed lists before closing
1888
+ const pageIndex = this.pages.indexOf(this.recordingPage);
1889
+ if (pageIndex !== -1) {
1890
+ this.pages.splice(pageIndex, 1);
1891
+ }
1892
+ const contextIndex = this.contexts.indexOf(this.recordingContext);
1893
+ if (contextIndex !== -1) {
1894
+ this.contexts.splice(contextIndex, 1);
1895
+ }
1896
+ // Close the page to finalize the video
1897
+ await this.recordingPage.close();
1898
+ // Save the video to the desired output path
1899
+ if (video) {
1900
+ await video.saveAs(outputPath);
1901
+ }
1902
+ // Clean up temp directory
1903
+ if (this.recordingTempDir) {
1904
+ rmSync(this.recordingTempDir, { recursive: true, force: true });
1905
+ }
1906
+ // Close the recording context
1907
+ await this.recordingContext.close();
1908
+ // Reset recording state
1909
+ this.recordingContext = null;
1910
+ this.recordingPage = null;
1911
+ this.recordingOutputPath = '';
1912
+ this.recordingTempDir = '';
1913
+ // Adjust active page index
1914
+ if (this.pages.length > 0) {
1915
+ this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
1916
+ }
1917
+ else {
1918
+ this.activePageIndex = 0;
1919
+ }
1920
+ // Invalidate CDP session since we may have switched pages
1921
+ await this.invalidateCDPSession();
1922
+ return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
1923
+ }
1924
+ catch (error) {
1925
+ // Clean up temp directory on error
1926
+ if (this.recordingTempDir) {
1927
+ rmSync(this.recordingTempDir, { recursive: true, force: true });
1928
+ }
1929
+ // Reset state on error
1930
+ this.recordingContext = null;
1931
+ this.recordingPage = null;
1932
+ this.recordingOutputPath = '';
1933
+ this.recordingTempDir = '';
1934
+ const message = error instanceof Error ? error.message : String(error);
1935
+ return { path: outputPath, frames: 0, error: message };
1936
+ }
1937
+ }
1938
+ /**
1939
+ * Restart recording - stops current recording (if any) and starts a new one.
1940
+ * Convenience method that combines stopRecording and startRecording.
1941
+ *
1942
+ * @param outputPath - Path to the output video file (must be .webm)
1943
+ * @param url - Optional URL to navigate to (defaults to current page URL)
1944
+ * @returns Result from stopping the previous recording (if any)
1945
+ */
1946
+ async restartRecording(outputPath, url) {
1947
+ let previousPath;
1948
+ let stopped = false;
1949
+ // Stop current recording if active
1950
+ if (this.recordingContext) {
1951
+ const result = await this.stopRecording();
1952
+ previousPath = result.path;
1953
+ stopped = true;
1954
+ }
1955
+ // Start new recording
1956
+ await this.startRecording(outputPath, url);
1957
+ return { previousPath, stopped };
1958
+ }
1959
+ /**
1960
+ * Close the browser and clean up
1961
+ */
1962
+ async close() {
1963
+ // Stop recording if active (saves video)
1964
+ if (this.recordingContext) {
1965
+ await this.stopRecording();
1966
+ }
1967
+ // Stop screencast if active
1968
+ if (this.screencastActive) {
1969
+ await this.stopScreencast();
1970
+ }
1971
+ // Clean up profiling state if active (without saving)
1972
+ if (this.profilingActive) {
1973
+ const cdp = this.cdpSession;
1974
+ if (cdp) {
1975
+ if (this.profileDataHandler) {
1976
+ cdp.off('Tracing.dataCollected', this.profileDataHandler);
1977
+ }
1978
+ if (this.profileCompleteHandler) {
1979
+ cdp.off('Tracing.tracingComplete', this.profileCompleteHandler);
1980
+ }
1981
+ await cdp.send('Tracing.end').catch(() => { });
1982
+ }
1983
+ this.profilingActive = false;
1984
+ this.profileChunks = [];
1985
+ this.profileEventsDropped = false;
1986
+ this.profileCompleteResolver = null;
1987
+ this.profileDataHandler = null;
1988
+ this.profileCompleteHandler = null;
1989
+ }
1990
+ // Clean up CDP session
1991
+ if (this.cdpSession) {
1992
+ await this.cdpSession.detach().catch(() => { });
1993
+ this.cdpSession = null;
1994
+ }
1995
+ if (this.nstSessionId) {
1996
+ await this.closeNstbrowserSession(this.nstSessionId).catch((error) => {
1997
+ console.error('Failed to close Nstbrowser session:', error);
1998
+ });
1999
+ this.browser = null;
2000
+ }
2001
+ else if (this.cdpEndpoint !== null) {
2002
+ // CDP: only disconnect, don't close external app's pages
2003
+ if (this.browser) {
2004
+ await this.browser.close().catch(() => { });
2005
+ this.browser = null;
2006
+ }
2007
+ }
2008
+ else {
2009
+ // Regular browser: close everything
2010
+ for (const page of this.pages) {
2011
+ await page.close().catch(() => { });
2012
+ }
2013
+ for (const context of this.contexts) {
2014
+ await context.close().catch(() => { });
2015
+ }
2016
+ if (this.browser) {
2017
+ await this.browser.close().catch(() => { });
2018
+ this.browser = null;
2019
+ }
2020
+ }
2021
+ this.pages = [];
2022
+ this.contexts = [];
2023
+ this.cdpEndpoint = null;
2024
+ this.nstSessionId = null;
2025
+ this.nstApiKey = null;
2026
+ this.nstHost = null;
2027
+ this.nstPort = null;
2028
+ this.isPersistentContext = false;
2029
+ this.activePageIndex = 0;
2030
+ this.colorScheme = null;
2031
+ this.refMap = {};
2032
+ this.lastSnapshot = '';
2033
+ this.frameCallback = null;
2034
+ }
2035
+ }
2036
+ //# sourceMappingURL=browser.js.map