neoagent 3.2.1-beta.1 → 3.2.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +358 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +20 -45
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +21 -33
  52. package/server/services/ai/providers/githubCopilot.js +41 -20
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +19 -27
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +111 -84
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. package/server/utils/retry.js +107 -0
@@ -1,7 +1,11 @@
1
+ 'use strict';
2
+
1
3
  const path = require('path');
2
4
  const fs = require('fs');
3
5
  const { spawn } = require('child_process');
4
6
  const { DATA_DIR } = require('../../../runtime/paths');
7
+ const { validateCloudUrlWithDns } = require('../../utils/cloud-security');
8
+ const { runProcess } = require('../android/process');
5
9
  const {
6
10
  chooseBrowserIdentity,
7
11
  detectBotChallenge,
@@ -127,40 +131,66 @@ function resolveBrowserExecutablePath() {
127
131
  return platformCandidates.find((candidate) => fs.existsSync(candidate)) || null;
128
132
  }
129
133
 
130
- function installPlaywrightBrowserBinary(browserName) {
134
+ async function installPlaywrightBrowserBinary(browserName, options = {}) {
131
135
  const packageRoot = path.dirname(require.resolve('playwright-chromium/package.json'));
132
136
  const cliPath = path.join(packageRoot, 'cli.js');
133
- return new Promise((resolve, reject) => {
134
- const args = [cliPath, 'install', '--no-shell', browserName];
135
- const child = spawn(process.execPath, args, {
136
- stdio: ['ignore', 'pipe', 'pipe'],
137
- });
138
- let stdout = '';
139
- let stderr = '';
137
+ await runProcess(process.execPath, [cliPath, 'install', '--no-shell', browserName], {
138
+ signal: options.signal,
139
+ timeoutMs: 10 * 60 * 1000,
140
+ maxOutputBytes: 32 * 1024 * 1024,
141
+ });
142
+ }
140
143
 
141
- child.stdout.on('data', (data) => {
142
- stdout += data.toString();
143
- });
144
- child.stderr.on('data', (data) => {
145
- stderr += data.toString();
146
- });
147
- child.on('error', (error) => {
148
- const detail = String(error?.message || `playwright install ${browserName} failed`).trim();
149
- reject(new Error(detail));
150
- });
151
- child.on('close', (code) => {
152
- if (code === 0) {
153
- resolve();
154
- return;
155
- }
156
- const detail = String(stderr || stdout || `playwright install ${browserName} exited with code ${code ?? 'unknown'}`).trim();
157
- reject(new Error(detail));
158
- });
144
+ function createAbortError(signal, fallback = 'Browser operation aborted.') {
145
+ if (signal?.reason instanceof Error) return signal.reason;
146
+ const error = new Error(String(signal?.reason || fallback));
147
+ error.name = 'AbortError';
148
+ error.code = 'ABORT_ERR';
149
+ return error;
150
+ }
151
+
152
+ function throwIfAborted(signal) {
153
+ if (signal?.aborted) throw createAbortError(signal);
154
+ }
155
+
156
+ function sleep(ms, signal = null) {
157
+ throwIfAborted(signal);
158
+ return new Promise((resolve, reject) => {
159
+ const timer = setTimeout(() => {
160
+ signal?.removeEventListener('abort', onAbort);
161
+ resolve();
162
+ }, ms);
163
+ const onAbort = () => {
164
+ clearTimeout(timer);
165
+ reject(createAbortError(signal));
166
+ };
167
+ signal?.addEventListener('abort', onAbort, { once: true });
168
+ if (signal?.aborted) onAbort();
159
169
  });
160
170
  }
161
171
 
162
- function sleep(ms) {
163
- return new Promise(r => setTimeout(r, ms));
172
+ function isAbortError(error, signal = null) {
173
+ return Boolean(
174
+ signal?.aborted
175
+ || error?.name === 'AbortError'
176
+ || error?.code === 'ABORT_ERR',
177
+ );
178
+ }
179
+
180
+ async function raceWithSignal(promise, signal) {
181
+ throwIfAborted(signal);
182
+ if (!signal) return promise;
183
+ let onAbort;
184
+ const aborted = new Promise((_, reject) => {
185
+ onAbort = () => reject(createAbortError(signal));
186
+ signal.addEventListener('abort', onAbort, { once: true });
187
+ if (signal.aborted) onAbort();
188
+ });
189
+ try {
190
+ return await Promise.race([promise, aborted]);
191
+ } finally {
192
+ signal.removeEventListener('abort', onAbort);
193
+ }
164
194
  }
165
195
 
166
196
  async function waitForFile(filePath, options = {}) {
@@ -171,7 +201,7 @@ async function waitForFile(filePath, options = {}) {
171
201
  }
172
202
  const startedAt = Date.now();
173
203
  while (Date.now() - startedAt < timeoutMs) {
174
- await sleep(intervalMs);
204
+ await sleep(intervalMs, options.signal);
175
205
  if (fs.existsSync(filePath)) {
176
206
  return true;
177
207
  }
@@ -197,6 +227,17 @@ function normalizeWaitUntil(waitUntil) {
197
227
  return 'domcontentloaded';
198
228
  }
199
229
 
230
+ function normalizePointCoordinate(value, label) {
231
+ if (value == null || value === '') {
232
+ throw new Error(`${label} coordinate is required.`);
233
+ }
234
+ const normalized = Number(value);
235
+ if (!Number.isFinite(normalized)) {
236
+ throw new Error(`${label} coordinate must be a finite number.`);
237
+ }
238
+ return Math.round(normalized);
239
+ }
240
+
200
241
  function clearChromiumSingletonLocks(profileDir) {
201
242
  const lockEntries = [
202
243
  'SingletonLock',
@@ -213,6 +254,18 @@ function clearChromiumSingletonLocks(profileDir) {
213
254
  }
214
255
  }
215
256
 
257
+ function chooseVirtualDisplay() {
258
+ for (let number = 90; number < 200; number += 1) {
259
+ if (
260
+ !fs.existsSync(`/tmp/.X11-unix/X${number}`)
261
+ && !fs.existsSync(`/tmp/.X${number}-lock`)
262
+ ) {
263
+ return `:${number}`;
264
+ }
265
+ }
266
+ throw new Error('No free X11 display is available for the browser.');
267
+ }
268
+
216
269
  class BrowserController {
217
270
  constructor(options = {}) {
218
271
  this.io = options.io || null;
@@ -226,9 +279,15 @@ class BrowserController {
226
279
  this.page = null;
227
280
  this.displayProcess = null;
228
281
  this.displayValue = process.env.DISPLAY || null;
282
+ this._managedDisplayValue = null;
229
283
  this.launching = false;
230
284
  this.launchPromise = null;
231
285
  this.browserBinaryInstallPromise = null;
286
+ this._launchAbortController = null;
287
+ this._urlValidator = options.urlValidator || validateCloudUrlWithDns;
288
+ this._boundPages = new WeakSet();
289
+ this._closing = false;
290
+ this._closePromise = null;
232
291
  this.headless = false;
233
292
  this.profileDir = path.join(BROWSER_PROFILE_ROOT, this.userId || 'default');
234
293
  if (!fs.existsSync(this.profileDir)) fs.mkdirSync(this.profileDir, { recursive: true });
@@ -251,6 +310,122 @@ class BrowserController {
251
310
  return this.close();
252
311
  }
253
312
 
313
+ _contextIsOpen() {
314
+ if (!this.context) return false;
315
+ if (typeof this.context.isClosed === 'function' && this.context.isClosed()) return false;
316
+ return !this.browser
317
+ || typeof this.browser.isConnected !== 'function'
318
+ || this.browser.isConnected();
319
+ }
320
+
321
+ _clearBrowserReferences(context, browser) {
322
+ if (context && this.context !== context) return;
323
+ if (!context && browser && this.browser !== browser) return;
324
+ this.context = null;
325
+ this.browser = null;
326
+ this.page = null;
327
+ }
328
+
329
+ _bindPage(page, options = {}) {
330
+ if (!page) return;
331
+ if (!this._boundPages.has(page)) {
332
+ this._boundPages.add(page);
333
+ const clearPage = () => {
334
+ if (this.page === page) this.page = null;
335
+ };
336
+ page.on?.('close', clearPage);
337
+ page.on?.('crash', clearPage);
338
+ }
339
+ if (options.makeActive !== false) this.page = page;
340
+ }
341
+
342
+ _bindContextLifecycle(context, browser) {
343
+ context.on?.('close', () => this._clearBrowserReferences(context, browser));
344
+ context.on?.('page', (page) => {
345
+ this._bindPage(page);
346
+ this._applyStealthToPage(page).catch(() => {});
347
+ });
348
+ browser?.on?.('disconnected', () => this._clearBrowserReferences(context, browser));
349
+ for (const page of context.pages?.() || []) this._bindPage(page, { makeActive: false });
350
+ }
351
+
352
+ async _networkUrlAllowed(url) {
353
+ let parsed;
354
+ try {
355
+ parsed = new URL(String(url || ''));
356
+ } catch {
357
+ return false;
358
+ }
359
+ const protocol = parsed.protocol.toLowerCase();
360
+ if (protocol === 'about:' && parsed.href === 'about:blank') return true;
361
+ if (protocol === 'blob:' || protocol === 'data:') return true;
362
+ if (protocol === 'ws:' || protocol === 'wss:') {
363
+ parsed.protocol = protocol === 'ws:' ? 'http:' : 'https:';
364
+ } else if (protocol !== 'http:' && protocol !== 'https:') {
365
+ return false;
366
+ }
367
+ const result = await this._urlValidator(parsed.href);
368
+ return result?.allowed === true;
369
+ }
370
+
371
+ async _assertNavigationAllowed(url, options = {}) {
372
+ let result = null;
373
+ try {
374
+ result = await this._urlValidator(String(url || ''), { signal: options.signal });
375
+ } catch (error) {
376
+ if (isAbortError(error, options.signal)) throw error;
377
+ }
378
+ if (result?.allowed !== true) {
379
+ const error = new Error('This URL is not permitted.');
380
+ error.code = 'URL_BLOCKED';
381
+ throw error;
382
+ }
383
+ }
384
+
385
+ async _installNetworkGuard(context) {
386
+ await context.route('**/*', async (route) => {
387
+ let allowed = false;
388
+ try {
389
+ allowed = await this._networkUrlAllowed(route.request().url());
390
+ } catch {}
391
+ if (!allowed) {
392
+ await route.abort('blockedbyclient').catch(() => {});
393
+ return;
394
+ }
395
+ await route.continue().catch(() => {});
396
+ });
397
+
398
+ if (typeof context.routeWebSocket === 'function') {
399
+ await context.routeWebSocket('**', async (webSocket) => {
400
+ let allowed = false;
401
+ try {
402
+ allowed = await this._networkUrlAllowed(webSocket.url());
403
+ } catch {}
404
+ if (!allowed) {
405
+ await webSocket.close({ code: 1008, reason: 'Blocked by network policy' }).catch(() => {});
406
+ return;
407
+ }
408
+ webSocket.connectToServer();
409
+ });
410
+ }
411
+ }
412
+
413
+ async _withPageCancellation(page, signal, operation) {
414
+ throwIfAborted(signal);
415
+ if (!signal) return operation();
416
+ const closeOnAbort = () => {
417
+ if (!page?.isClosed?.()) {
418
+ page.close({ runBeforeUnload: false, reason: 'Browser operation cancelled' }).catch(() => {});
419
+ }
420
+ };
421
+ signal.addEventListener('abort', closeOnAbort, { once: true });
422
+ try {
423
+ return await raceWithSignal(Promise.resolve().then(operation), signal);
424
+ } finally {
425
+ signal.removeEventListener('abort', closeOnAbort);
426
+ }
427
+ }
428
+
254
429
  async _applyStealthToPage(page) {
255
430
  const ua = this._userAgent;
256
431
  const vp = this._viewport;
@@ -308,7 +483,6 @@ class BrowserController {
308
483
  arr.item = i => arr[i];
309
484
  arr.namedItem = n => arr.find(p => p.name === n) || null;
310
485
  arr.refresh = () => {};
311
- Object.defineProperty(arr, 'length', { get: () => arr.length });
312
486
  return arr;
313
487
  }
314
488
  });
@@ -345,17 +519,6 @@ class BrowserController {
345
519
  return originalFillText.apply(this, args);
346
520
  };
347
521
 
348
- // Media Devices Spoofing
349
- if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
350
- const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
351
- navigator.mediaDevices.enumerateDevices = async () => {
352
- return [
353
- { kind: 'audioinput', deviceId: 'default', groupId: 'a', label: 'MacBook Pro Microphone' },
354
- { kind: 'audiooutput', deviceId: 'default', groupId: 'b', label: 'MacBook Pro Speakers' },
355
- { kind: 'videoinput', deviceId: 'default', groupId: 'c', label: 'FaceTime HD Camera' }
356
- ];
357
- };
358
- }
359
522
  })();
360
523
  `;
361
524
  if (typeof page.evaluateOnNewDocument === 'function') {
@@ -365,23 +528,42 @@ class BrowserController {
365
528
  }
366
529
  }
367
530
 
368
- async ensureBrowser() {
369
- if (this.browser && this.browser.isConnected()) return;
531
+ async ensureBrowser(options = {}) {
532
+ let signal = options.signal;
533
+ throwIfAborted(signal);
534
+ if (this._closePromise) await raceWithSignal(this._closePromise, signal);
535
+ if (this._contextIsOpen()) return;
370
536
  if (this.launchPromise) {
371
- await this.launchPromise;
537
+ await raceWithSignal(this.launchPromise, signal);
372
538
  return;
373
539
  }
374
540
 
541
+ const staleContext = this.context;
542
+ const staleBrowser = this.browser;
543
+ this._clearBrowserReferences(staleContext, staleBrowser);
544
+ if (staleContext) await staleContext.close().catch(() => {});
545
+ else if (staleBrowser) await staleBrowser.close().catch(() => {});
546
+
547
+ const launchAbortController = new AbortController();
548
+ this._launchAbortController = launchAbortController;
549
+ const externalSignal = signal;
550
+ const forwardAbort = () => launchAbortController.abort(externalSignal.reason);
551
+ externalSignal?.addEventListener('abort', forwardAbort, { once: true });
552
+ if (externalSignal?.aborted) forwardAbort();
553
+ signal = launchAbortController.signal;
554
+
375
555
  this.launching = true;
376
556
  this.launchPromise = (async () => {
377
557
  const runtimeReady = await waitForFile(BROWSER_READY_MARKER, {
378
558
  timeoutMs: 10 * 60 * 1000,
379
559
  intervalMs: 1000,
560
+ signal,
380
561
  });
381
562
  if (!runtimeReady) {
382
563
  throw new Error('Browser runtime provisioning is still in progress inside the VM. Retry shortly.');
383
564
  }
384
- await this.ensureVirtualDisplay();
565
+ await this.ensureVirtualDisplay({ signal });
566
+ throwIfAborted(signal);
385
567
 
386
568
  this._identity = chooseBrowserIdentity(this.userId || this.profileDir);
387
569
  this._userAgent = this._identity.userAgent;
@@ -394,10 +576,10 @@ class BrowserController {
394
576
  let executablePath = resolveBrowserExecutablePath();
395
577
  if (!executablePath) {
396
578
  if (!this.browserBinaryInstallPromise) {
397
- this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine);
579
+ this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine, { signal });
398
580
  }
399
581
  try {
400
- await this.browserBinaryInstallPromise;
582
+ await raceWithSignal(this.browserBinaryInstallPromise, signal);
401
583
  } finally {
402
584
  this.browserBinaryInstallPromise = null;
403
585
  }
@@ -444,7 +626,7 @@ class BrowserController {
444
626
 
445
627
  const playwright = require('playwright-chromium');
446
628
  clearChromiumSingletonLocks(this.profileDir);
447
- this.context = await playwright.chromium.launchPersistentContext(this.profileDir, {
629
+ const launchPromise = playwright.chromium.launchPersistentContext(this.profileDir, {
448
630
  headless: false,
449
631
  executablePath,
450
632
  env: launchEnv,
@@ -453,98 +635,137 @@ class BrowserController {
453
635
  userAgent: this._userAgent,
454
636
  locale: 'en-US',
455
637
  ignoreHTTPSErrors: false,
638
+ serviceWorkers: 'block',
456
639
  timeout: 120000,
457
640
  });
458
- this.browser = typeof this.context.browser === 'function' ? this.context.browser() : null;
641
+ let context;
642
+ try {
643
+ context = await raceWithSignal(launchPromise, signal);
644
+ } catch (error) {
645
+ launchPromise.then((lateContext) => lateContext.close().catch(() => {})).catch(() => {});
646
+ throw error;
647
+ }
648
+ const browser = typeof context.browser === 'function' ? context.browser() : null;
649
+ this.context = context;
650
+ this.browser = browser;
651
+ this._bindContextLifecycle(context, browser);
459
652
 
460
653
  // Cloud security: deny access to local devices on every page in this context.
461
- await this.context.addInitScript(DEVICE_DENY_SCRIPT);
654
+ await context.addInitScript(DEVICE_DENY_SCRIPT);
655
+ await this._installNetworkGuard(context);
462
656
 
463
- this.page = this.context.pages()[0] || await this.context.newPage();
657
+ this.page = context.pages()[0] || await context.newPage();
658
+ this._bindPage(this.page);
464
659
  await this._applyStealthToPage(this.page);
465
660
  })();
466
661
 
467
662
  try {
468
- await this.launchPromise;
663
+ await raceWithSignal(this.launchPromise, signal);
664
+ } catch (error) {
665
+ const failedContext = this.context;
666
+ const failedBrowser = this.browser;
667
+ this._clearBrowserReferences(failedContext, failedBrowser);
668
+ if (failedContext) await failedContext.close().catch(() => {});
669
+ else if (failedBrowser) await failedBrowser.close().catch(() => {});
670
+ await this._stopVirtualDisplay();
671
+ throw error;
469
672
  } finally {
673
+ externalSignal?.removeEventListener('abort', forwardAbort);
674
+ if (this._launchAbortController === launchAbortController) {
675
+ this._launchAbortController = null;
676
+ }
470
677
  this.launchPromise = null;
471
678
  this.launching = false;
472
679
  }
473
680
  }
474
681
 
475
- async ensurePage() {
476
- await this.ensureBrowser();
682
+ async ensurePage(options = {}) {
683
+ const signal = options.signal;
684
+ await this.ensureBrowser(options);
685
+ throwIfAborted(signal);
477
686
  if (!this.page || this.page.isClosed()) {
687
+ let pagePromise;
478
688
  if (this.context && typeof this.context.newPage === 'function') {
479
- this.page = await this.context.newPage();
689
+ pagePromise = this.context.newPage();
480
690
  } else {
481
- this.page = await this.browser.newPage();
691
+ pagePromise = this.browser.newPage();
692
+ }
693
+ try {
694
+ this.page = await raceWithSignal(pagePromise, signal);
695
+ } catch (error) {
696
+ pagePromise.then((latePage) => latePage.close().catch(() => {})).catch(() => {});
697
+ throw error;
482
698
  }
699
+ this._bindPage(this.page);
483
700
  await this._applyStealthToPage(this.page);
484
701
  }
485
702
  return this.page;
486
703
  }
487
704
 
488
705
  async takeScreenshot(options = {}) {
489
- const page = await this.ensurePage();
490
- let artifactRecord = null;
491
- let filename = `screenshot_${Date.now()}.png`;
492
- let filepath = path.join(SCREENSHOTS_DIR, filename);
493
- if (this.artifactStore && this.userId != null) {
494
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
495
- kind: 'browser-screenshot',
496
- backend: this.runtimeBackend,
497
- extension: 'png',
498
- contentType: 'image/png',
499
- filenameBase: 'browser-screenshot',
500
- metadata: {
501
- selector: options.selector || null,
502
- fullPage: options.fullPage === true,
503
- },
504
- });
505
- filepath = artifactRecord.storagePath;
506
- filename = path.basename(filepath);
507
- }
706
+ const page = await this.ensurePage(options);
707
+ return this._withPageCancellation(page, options.signal, async () => {
708
+ let artifactRecord = null;
709
+ let filename = `screenshot_${Date.now()}.png`;
710
+ let filepath = path.join(SCREENSHOTS_DIR, filename);
711
+ if (this.artifactStore && this.userId != null) {
712
+ artifactRecord = this.artifactStore.allocateFile(this.userId, {
713
+ kind: 'browser-screenshot',
714
+ backend: this.runtimeBackend,
715
+ extension: 'png',
716
+ contentType: 'image/png',
717
+ filenameBase: 'browser-screenshot',
718
+ metadata: {
719
+ selector: options.selector || null,
720
+ fullPage: options.fullPage === true,
721
+ },
722
+ });
723
+ filepath = artifactRecord.storagePath;
724
+ filename = path.basename(filepath);
725
+ }
508
726
 
509
- const screenshotOptions = { path: filepath, type: 'png' };
510
- if (options.fullPage) screenshotOptions.fullPage = true;
511
- if (options.selector) {
512
- const element = await page.$(options.selector);
513
- if (element) {
514
- await element.screenshot(screenshotOptions);
727
+ const screenshotOptions = { path: filepath, type: 'png' };
728
+ if (options.fullPage) screenshotOptions.fullPage = true;
729
+ if (options.selector) {
730
+ const element = await page.$(options.selector);
731
+ if (element) {
732
+ await element.screenshot(screenshotOptions);
733
+ } else {
734
+ await page.screenshot(screenshotOptions);
735
+ }
515
736
  } else {
516
737
  await page.screenshot(screenshotOptions);
517
738
  }
518
- } else {
519
- await page.screenshot(screenshotOptions);
520
- }
521
739
 
522
- if (artifactRecord) {
523
- this.artifactStore.finalizeFile(artifactRecord.artifactId, filepath);
524
- }
740
+ if (artifactRecord) {
741
+ this.artifactStore.finalizeFile(artifactRecord.artifactId, filepath);
742
+ }
525
743
 
526
- return {
527
- screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
528
- artifactId: artifactRecord?.artifactId || null,
529
- filename,
530
- fullPath: filepath,
531
- };
744
+ return {
745
+ screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
746
+ artifactId: artifactRecord?.artifactId || null,
747
+ filename,
748
+ fullPath: filepath,
749
+ };
750
+ });
532
751
  }
533
752
 
534
753
  async screenshotJpeg(quality = 80, options = {}) {
535
- const page = await this.ensurePage();
536
- const screenshotOptions = {
537
- type: 'jpeg',
538
- quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
539
- fullPage: options.fullPage === true,
540
- };
541
- if (options.selector) {
542
- const element = await page.$(options.selector);
543
- if (element) {
544
- return element.screenshot(screenshotOptions);
754
+ const page = await this.ensurePage(options);
755
+ return this._withPageCancellation(page, options.signal, async () => {
756
+ const screenshotOptions = {
757
+ type: 'jpeg',
758
+ quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
759
+ fullPage: options.fullPage === true,
760
+ };
761
+ if (options.selector) {
762
+ const element = await page.$(options.selector);
763
+ if (element) {
764
+ return element.screenshot(screenshotOptions);
765
+ }
545
766
  }
546
- }
547
- return page.screenshot(screenshotOptions);
767
+ return page.screenshot(screenshotOptions);
768
+ });
548
769
  }
549
770
 
550
771
  async _navigatePage(page, url, options = {}) {
@@ -552,9 +773,21 @@ class BrowserController {
552
773
  const waitUntil = normalizeWaitUntil(options.waitUntil);
553
774
  if (referrerMode === 'current' && page.url() && page.url() !== 'about:blank') {
554
775
  const previousUrl = page.url();
555
- await page.evaluate((nextUrl) => { window.location.href = nextUrl; }, url);
556
- await page.waitForFunction((oldUrl) => window.location.href !== oldUrl, previousUrl, { timeout: 10000 }).catch(() => {});
557
- await page.waitForLoadState(waitUntil, { timeout: 30000 }).catch(() => {});
776
+ const urlChanged = typeof page.waitForURL === 'function'
777
+ ? page.waitForURL(
778
+ (nextUrl) => String(nextUrl) !== previousUrl,
779
+ { waitUntil, timeout: 30000 },
780
+ )
781
+ : page.waitForFunction(
782
+ (oldUrl) => window.location.href !== oldUrl,
783
+ previousUrl,
784
+ { timeout: 30000 },
785
+ );
786
+ await Promise.all([
787
+ urlChanged,
788
+ page.evaluate((nextUrl) => { window.location.href = nextUrl; }, url),
789
+ ]);
790
+ await page.waitForLoadState(waitUntil, { timeout: 30000 });
558
791
  return null;
559
792
  }
560
793
 
@@ -581,80 +814,89 @@ class BrowserController {
581
814
  }
582
815
 
583
816
  async navigate(url, options = {}) {
584
- const page = await this.ensurePage();
585
-
817
+ let page = null;
586
818
  try {
587
- const requestedReferrerMode = normalizeReferrerMode(options.referrerMode);
588
- let activeReferrerMode = requestedReferrerMode;
589
- let response = await this._navigatePage(page, url, {
590
- ...options,
591
- referrerMode: activeReferrerMode,
592
- });
593
-
594
- if (options.waitFor) {
595
- await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
596
- }
597
-
598
- // Simulate human reading delay
599
- await sleep(rand(700, 1800));
600
-
601
- let rawHtml = await page.content();
602
- const { extractForLLM } = require('./contentExtractor');
603
- let extraction = extractForLLM(rawHtml, { url: page.url() });
604
- let botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
605
- let challengeRetried = false;
606
-
607
- if (
608
- botDetection.detected
609
- && normalizeChallengeRetry(options.challengeRetry)
610
- && requestedReferrerMode === 'direct'
611
- ) {
612
- challengeRetried = true;
613
- activeReferrerMode = 'google';
614
- await sleep(rand(1200, 2600));
615
- response = await this._navigatePage(page, url, {
819
+ await this._assertNavigationAllowed(url, options);
820
+ page = await this.ensurePage(options);
821
+ return await this._withPageCancellation(page, options.signal, async () => {
822
+ const requestedReferrerMode = normalizeReferrerMode(options.referrerMode);
823
+ let activeReferrerMode = requestedReferrerMode;
824
+ let response = await this._navigatePage(page, url, {
616
825
  ...options,
617
826
  referrerMode: activeReferrerMode,
618
827
  });
828
+
619
829
  if (options.waitFor) {
620
- await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
830
+ await page.waitForSelector(options.waitFor, { timeout: 10000 });
621
831
  }
622
- await sleep(rand(900, 2200));
623
- rawHtml = await page.content();
624
- extraction = extractForLLM(rawHtml, { url: page.url() });
625
- botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
626
- }
627
832
 
628
- const title = await page.title();
629
- const currentUrl = page.url();
833
+ // Simulate human reading delay.
834
+ await sleep(rand(700, 1800), options.signal);
835
+
836
+ let rawHtml = await page.content();
837
+ const { extractForLLM } = require('./contentExtractor');
838
+ let extraction = extractForLLM(rawHtml, { url: page.url() });
839
+ let botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
840
+ let challengeRetried = false;
841
+
842
+ if (
843
+ botDetection.detected
844
+ && normalizeChallengeRetry(options.challengeRetry)
845
+ && requestedReferrerMode === 'direct'
846
+ ) {
847
+ challengeRetried = true;
848
+ activeReferrerMode = 'google';
849
+ await sleep(rand(1200, 2600), options.signal);
850
+ response = await this._navigatePage(page, url, {
851
+ ...options,
852
+ referrerMode: activeReferrerMode,
853
+ });
854
+ if (options.waitFor) {
855
+ await page.waitForSelector(options.waitFor, { timeout: 10000 });
856
+ }
857
+ await sleep(rand(900, 2200), options.signal);
858
+ rawHtml = await page.content();
859
+ extraction = extractForLLM(rawHtml, { url: page.url() });
860
+ botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
861
+ }
630
862
 
631
- let screenshot = null;
632
- if (options.screenshot !== false) {
633
- screenshot = await this.takeScreenshot({ fullPage: options.fullPage });
634
- }
863
+ const title = await page.title();
864
+ const currentUrl = page.url();
635
865
 
636
- return {
637
- title,
638
- url: currentUrl,
639
- status: response?.status() || 0,
640
- pageContent: extraction.markdown,
641
- botDetection,
642
- referrerMode: activeReferrerMode,
643
- challengeRetried,
644
- screenshotPath: screenshot?.screenshotPath || null,
645
- artifactId: screenshot?.artifactId || null,
646
- fullPath: screenshot?.fullPath || null
647
- };
866
+ let screenshot = null;
867
+ if (options.screenshot !== false) {
868
+ screenshot = await this.takeScreenshot({
869
+ fullPage: options.fullPage,
870
+ signal: options.signal,
871
+ });
872
+ }
873
+
874
+ return {
875
+ title,
876
+ url: currentUrl,
877
+ status: response?.status() || 0,
878
+ pageContent: extraction.markdown,
879
+ botDetection,
880
+ referrerMode: activeReferrerMode,
881
+ challengeRetried,
882
+ screenshotPath: screenshot?.screenshotPath || null,
883
+ artifactId: screenshot?.artifactId || null,
884
+ fullPath: screenshot?.fullPath || null,
885
+ };
886
+ });
648
887
  } catch (err) {
888
+ if (isAbortError(err, options.signal)) throw err;
649
889
  let screenshot = null;
650
- try { screenshot = await this.takeScreenshot(); } catch { }
890
+ if (page && !page.isClosed()) {
891
+ try { screenshot = await this.takeScreenshot(); } catch {}
892
+ }
651
893
  return {
652
894
  error: err.message,
653
895
  url,
654
896
  botDetection: { detected: false, provider: null },
655
897
  screenshotPath: screenshot?.screenshotPath || null,
656
898
  artifactId: screenshot?.artifactId || null,
657
- fullPath: screenshot?.fullPath || null
899
+ fullPath: screenshot?.fullPath || null,
658
900
  };
659
901
  }
660
902
  }
@@ -668,7 +910,7 @@ class BrowserController {
668
910
  for (const point of path) {
669
911
  await page.mouse.move(point.x, point.y);
670
912
  if (!options.fast) {
671
- await sleep(rand(2, 10));
913
+ await sleep(rand(2, 10), options.signal);
672
914
  }
673
915
  }
674
916
  this._mousePosition = target;
@@ -689,271 +931,327 @@ class BrowserController {
689
931
  };
690
932
  }
691
933
 
692
- async click(selector, text, screenshot = true) {
693
- const page = await this.ensurePage();
934
+ async click(selector, text, screenshot = true, options = {}) {
935
+ const page = await this.ensurePage(options);
694
936
 
695
937
  try {
696
- let target = null;
697
-
698
- if (text && !selector) {
699
- const elements = await page.$$('a, button, [role="button"], input[type="submit"], [onclick]');
700
- for (const el of elements) {
701
- const elText = await page.evaluate(e => e.innerText || e.value || e.getAttribute('aria-label') || '', el);
702
- if (elText.toLowerCase().includes(text.toLowerCase())) {
703
- target = el;
704
- break;
938
+ return await this._withPageCancellation(page, options.signal, async () => {
939
+ let target = null;
940
+
941
+ if (text && !selector) {
942
+ const elements = await page.$$('a, button, [role="button"], input[type="submit"], [onclick]');
943
+ for (const el of elements) {
944
+ const elText = await page.evaluate(e => e.innerText || e.value || e.getAttribute('aria-label') || '', el);
945
+ if (elText.toLowerCase().includes(String(text).toLowerCase())) {
946
+ target = el;
947
+ break;
948
+ }
705
949
  }
950
+ if (!target) return { error: `No clickable element found with text: ${text}` };
951
+ } else if (selector) {
952
+ target = await page.$(selector);
953
+ if (!target) return { error: `Element not found: ${selector}` };
954
+ } else {
955
+ return { error: 'Either selector or text required' };
706
956
  }
707
- if (!target) return { error: `No clickable element found with text: ${text}` };
708
- } else if (selector) {
709
- target = await page.$(selector);
710
- if (!target) return { error: `Element not found: ${selector}` };
711
- } else {
712
- return { error: 'Either selector or text required' };
713
- }
714
957
 
715
- const point = await this._pointForElement(target);
716
- if (!point) return { error: 'Element has no visible clickable area' };
958
+ const point = await this._pointForElement(target);
959
+ if (!point) return { error: 'Element has no visible clickable area' };
717
960
 
718
- await this._moveMouseTo(page, point.x, point.y);
719
- await sleep(rand(170, 320));
720
- await page.mouse.down();
721
- await sleep(rand(80, 260));
722
- await page.mouse.up();
961
+ await this._moveMouseTo(page, point.x, point.y, { signal: options.signal });
962
+ await sleep(rand(170, 320), options.signal);
963
+ await page.mouse.down();
964
+ await sleep(rand(80, 260), options.signal);
965
+ await page.mouse.up();
723
966
 
724
- await sleep(rand(800, 1800));
967
+ await sleep(rand(800, 1800), options.signal);
725
968
 
726
- let screenshotResult = null;
727
- if (screenshot) screenshotResult = await this.takeScreenshot();
969
+ let screenshotResult = null;
970
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
728
971
 
729
- return {
730
- success: true,
731
- url: page.url(),
732
- title: await page.title(),
733
- screenshotPath: screenshotResult?.screenshotPath || null,
734
- artifactId: screenshotResult?.artifactId || null,
735
- fullPath: screenshotResult?.fullPath || null
736
- };
972
+ return {
973
+ success: true,
974
+ url: page.url(),
975
+ title: await page.title(),
976
+ screenshotPath: screenshotResult?.screenshotPath || null,
977
+ artifactId: screenshotResult?.artifactId || null,
978
+ fullPath: screenshotResult?.fullPath || null,
979
+ };
980
+ });
737
981
  } catch (err) {
982
+ if (isAbortError(err, options.signal)) throw err;
738
983
  return { error: err.message };
739
984
  }
740
985
  }
741
986
 
742
- async clickPoint(x, y, screenshot = true) {
743
- const page = await this.ensurePage();
987
+ async clickPoint(x, y, screenshot = true, options = {}) {
988
+ const page = await this.ensurePage(options);
744
989
 
745
990
  try {
746
- const px = Math.max(0, Math.round(Number(x) || 0));
747
- const py = Math.max(0, Math.round(Number(y) || 0));
748
- await this._moveMouseTo(page, px, py);
749
- await sleep(rand(90, 220));
750
- await page.mouse.down();
751
- await sleep(rand(70, 240));
752
- await page.mouse.up();
753
- await sleep(rand(500, 1200));
754
-
755
- let screenshotResult = null;
756
- if (screenshot) screenshotResult = await this.takeScreenshot();
757
-
758
- return {
759
- success: true,
760
- x: px,
761
- y: py,
762
- url: page.url(),
763
- title: await page.title(),
764
- screenshotPath: screenshotResult?.screenshotPath || null,
765
- artifactId: screenshotResult?.artifactId || null,
766
- fullPath: screenshotResult?.fullPath || null
767
- };
991
+ return await this._withPageCancellation(page, options.signal, async () => {
992
+ const px = Math.max(0, normalizePointCoordinate(x, 'x'));
993
+ const py = Math.max(0, normalizePointCoordinate(y, 'y'));
994
+ await this._moveMouseTo(page, px, py, { signal: options.signal });
995
+ await sleep(rand(90, 220), options.signal);
996
+ await page.mouse.down();
997
+ await sleep(rand(70, 240), options.signal);
998
+ await page.mouse.up();
999
+ await sleep(rand(500, 1200), options.signal);
1000
+
1001
+ let screenshotResult = null;
1002
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
1003
+
1004
+ return {
1005
+ success: true,
1006
+ x: px,
1007
+ y: py,
1008
+ url: page.url(),
1009
+ title: await page.title(),
1010
+ screenshotPath: screenshotResult?.screenshotPath || null,
1011
+ artifactId: screenshotResult?.artifactId || null,
1012
+ fullPath: screenshotResult?.fullPath || null,
1013
+ };
1014
+ });
768
1015
  } catch (err) {
1016
+ if (isAbortError(err, options.signal)) throw err;
769
1017
  return { error: err.message };
770
1018
  }
771
1019
  }
772
1020
 
773
1021
  async hoverPoint(x, y, options = {}) {
774
- const page = await this.ensurePage();
1022
+ const page = await this.ensurePage(options);
775
1023
  try {
776
- const px = Math.max(0, Math.round(Number(x) || 0));
777
- const py = Math.max(0, Math.round(Number(y) || 0));
778
- await this._moveMouseTo(page, px, py, { fast: Number(options.steps) <= 1 });
779
- return {
780
- success: true,
781
- x: px,
782
- y: py,
783
- url: page.url(),
784
- title: await page.title()
785
- };
1024
+ return await this._withPageCancellation(page, options.signal, async () => {
1025
+ const px = Math.max(0, normalizePointCoordinate(x, 'x'));
1026
+ const py = Math.max(0, normalizePointCoordinate(y, 'y'));
1027
+ await this._moveMouseTo(page, px, py, {
1028
+ fast: Number(options.steps) <= 1,
1029
+ signal: options.signal,
1030
+ });
1031
+ return {
1032
+ success: true,
1033
+ x: px,
1034
+ y: py,
1035
+ url: page.url(),
1036
+ title: await page.title(),
1037
+ };
1038
+ });
786
1039
  } catch (err) {
1040
+ if (isAbortError(err, options.signal)) throw err;
787
1041
  return { error: err.message };
788
1042
  }
789
1043
  }
790
1044
 
791
- async scroll(deltaX = 0, deltaY = 0, screenshot = true) {
792
- const page = await this.ensurePage();
1045
+ async scroll(deltaX = 0, deltaY = 0, screenshot = true, options = {}) {
1046
+ const page = await this.ensurePage(options);
793
1047
 
794
1048
  try {
795
- const x = Math.max(10, Math.min(this._viewport.width - 10, this._mousePosition.x + rand(-80, 80)));
796
- const y = Math.max(10, Math.min(this._viewport.height - 10, this._mousePosition.y + rand(-80, 80)));
797
- await this._moveMouseTo(page, x, y);
798
- const totalX = Math.round(Number(deltaX) || 0);
799
- const totalY = Math.round(Number(deltaY) || 0);
800
- const chunks = Math.max(1, Math.min(6, Math.ceil(Math.max(Math.abs(totalX), Math.abs(totalY)) / 450)));
801
- for (let i = 0; i < chunks; i += 1) {
802
- await page.mouse.wheel({
803
- deltaX: Math.round(totalX / chunks),
804
- deltaY: Math.round(totalY / chunks),
805
- });
806
- await sleep(rand(120, 360));
807
- }
1049
+ return await this._withPageCancellation(page, options.signal, async () => {
1050
+ const x = Math.max(10, Math.min(this._viewport.width - 10, this._mousePosition.x + rand(-80, 80)));
1051
+ const y = Math.max(10, Math.min(this._viewport.height - 10, this._mousePosition.y + rand(-80, 80)));
1052
+ await this._moveMouseTo(page, x, y, { signal: options.signal });
1053
+ const totalX = Math.round(Number(deltaX) || 0);
1054
+ const totalY = Math.round(Number(deltaY) || 0);
1055
+ const chunks = Math.max(1, Math.min(6, Math.ceil(Math.max(Math.abs(totalX), Math.abs(totalY)) / 450)));
1056
+ for (let i = 0; i < chunks; i += 1) {
1057
+ await page.mouse.wheel({
1058
+ deltaX: Math.round(totalX / chunks),
1059
+ deltaY: Math.round(totalY / chunks),
1060
+ });
1061
+ await sleep(rand(120, 360), options.signal);
1062
+ }
808
1063
 
809
- let screenshotResult = null;
810
- if (screenshot) screenshotResult = await this.takeScreenshot();
1064
+ let screenshotResult = null;
1065
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
811
1066
 
812
- return {
813
- success: true,
814
- url: page.url(),
815
- title: await page.title(),
816
- screenshotPath: screenshotResult?.screenshotPath || null,
817
- artifactId: screenshotResult?.artifactId || null,
818
- fullPath: screenshotResult?.fullPath || null
819
- };
1067
+ return {
1068
+ success: true,
1069
+ url: page.url(),
1070
+ title: await page.title(),
1071
+ screenshotPath: screenshotResult?.screenshotPath || null,
1072
+ artifactId: screenshotResult?.artifactId || null,
1073
+ fullPath: screenshotResult?.fullPath || null,
1074
+ };
1075
+ });
820
1076
  } catch (err) {
1077
+ if (isAbortError(err, options.signal)) throw err;
821
1078
  return { error: err.message };
822
1079
  }
823
1080
  }
824
1081
 
825
1082
  async type(selector, text, options = {}) {
826
- const page = await this.ensurePage();
1083
+ const page = await this.ensurePage(options);
827
1084
 
828
1085
  try {
829
- if (options.clear !== false) {
1086
+ return await this._withPageCancellation(page, options.signal, async () => {
1087
+ const value = String(text ?? '');
830
1088
  const element = await page.$(selector);
831
- if (element) {
832
- const point = await this._pointForElement(element);
833
- if (point) {
834
- await this._moveMouseTo(page, point.x, point.y);
835
- await sleep(rand(120, 260));
1089
+ if (!element) return { error: `Element not found: ${selector}` };
1090
+ const point = await this._pointForElement(element);
1091
+ if (point) {
1092
+ await this._moveMouseTo(page, point.x, point.y, { signal: options.signal });
1093
+ await sleep(rand(120, 260), options.signal);
1094
+ }
1095
+
1096
+ const locator = typeof page.locator === 'function' ? page.locator(selector) : null;
1097
+ if (options.clear !== false) {
1098
+ if (locator && typeof locator.fill === 'function') {
1099
+ await locator.fill('');
1100
+ } else {
1101
+ await page.click(selector, { clickCount: 3, delay: rand(40, 120) });
1102
+ await page.keyboard.press('Backspace');
836
1103
  }
837
1104
  }
838
- await page.click(selector, { clickCount: 3, delay: rand(40, 120) });
839
- await page.keyboard.press('Backspace');
840
- }
841
1105
 
842
- for (const char of text) {
843
- await page.type(selector, char, { delay: rand(45, 180) });
844
- }
1106
+ if (locator && typeof locator.pressSequentially === 'function') {
1107
+ await locator.pressSequentially(value, { delay: rand(45, 140) });
1108
+ } else {
1109
+ await page.type(selector, value, { delay: rand(45, 140) });
1110
+ }
845
1111
 
846
- if (options.pressEnter) {
847
- await page.keyboard.press('Enter');
848
- await sleep(1000);
849
- }
1112
+ if (options.pressEnter) {
1113
+ if (locator && typeof locator.press === 'function') await locator.press('Enter');
1114
+ else await page.keyboard.press('Enter');
1115
+ await sleep(1000, options.signal);
1116
+ }
850
1117
 
851
- let screenshotResult = null;
852
- if (options.screenshot !== false) screenshotResult = await this.takeScreenshot();
1118
+ let screenshotResult = null;
1119
+ if (options.screenshot !== false) {
1120
+ screenshotResult = await this.takeScreenshot({ signal: options.signal });
1121
+ }
853
1122
 
854
- return {
855
- success: true,
856
- typed: text,
857
- screenshotPath: screenshotResult?.screenshotPath || null,
858
- artifactId: screenshotResult?.artifactId || null,
859
- fullPath: screenshotResult?.fullPath || null
860
- };
1123
+ return {
1124
+ success: true,
1125
+ typed: value,
1126
+ screenshotPath: screenshotResult?.screenshotPath || null,
1127
+ artifactId: screenshotResult?.artifactId || null,
1128
+ fullPath: screenshotResult?.fullPath || null,
1129
+ };
1130
+ });
861
1131
  } catch (err) {
1132
+ if (isAbortError(err, options.signal)) throw err;
862
1133
  return { error: err.message };
863
1134
  }
864
1135
  }
865
1136
 
866
1137
  async typeText(text, options = {}) {
867
- const page = await this.ensurePage();
1138
+ const page = await this.ensurePage(options);
868
1139
 
869
1140
  try {
870
- for (const char of String(text || '')) {
871
- await page.keyboard.type(char, { delay: rand(45, 160) });
872
- }
1141
+ return await this._withPageCancellation(page, options.signal, async () => {
1142
+ const value = String(text ?? '');
1143
+ await page.keyboard.type(value, { delay: rand(45, 140) });
873
1144
 
874
- if (options.pressEnter) {
875
- await page.keyboard.press('Enter');
876
- await sleep(800);
877
- }
1145
+ if (options.pressEnter) {
1146
+ await page.keyboard.press('Enter');
1147
+ await sleep(800, options.signal);
1148
+ }
878
1149
 
879
- let screenshotResult = null;
880
- if (options.screenshot !== false) screenshotResult = await this.takeScreenshot();
1150
+ let screenshotResult = null;
1151
+ if (options.screenshot !== false) {
1152
+ screenshotResult = await this.takeScreenshot({ signal: options.signal });
1153
+ }
881
1154
 
882
- return {
883
- success: true,
884
- typed: String(text || ''),
885
- screenshotPath: screenshotResult?.screenshotPath || null,
886
- artifactId: screenshotResult?.artifactId || null,
887
- fullPath: screenshotResult?.fullPath || null
888
- };
1155
+ return {
1156
+ success: true,
1157
+ typed: value,
1158
+ screenshotPath: screenshotResult?.screenshotPath || null,
1159
+ artifactId: screenshotResult?.artifactId || null,
1160
+ fullPath: screenshotResult?.fullPath || null,
1161
+ };
1162
+ });
889
1163
  } catch (err) {
1164
+ if (isAbortError(err, options.signal)) throw err;
890
1165
  return { error: err.message };
891
1166
  }
892
1167
  }
893
1168
 
894
- async pressKey(key, screenshot = true) {
895
- const page = await this.ensurePage();
1169
+ async pressKey(key, screenshot = true, options = {}) {
1170
+ const page = await this.ensurePage(options);
896
1171
 
897
1172
  try {
898
- const normalized = String(key || '').trim();
899
- if (!normalized) {
900
- return { error: 'key required' };
901
- }
902
- await page.keyboard.press(normalized);
903
- await sleep(rand(250, 700));
904
-
905
- let screenshotResult = null;
906
- if (screenshot) screenshotResult = await this.takeScreenshot();
907
-
908
- return {
909
- success: true,
910
- key: normalized,
911
- screenshotPath: screenshotResult?.screenshotPath || null,
912
- artifactId: screenshotResult?.artifactId || null,
913
- fullPath: screenshotResult?.fullPath || null
914
- };
1173
+ return await this._withPageCancellation(page, options.signal, async () => {
1174
+ const normalized = String(key || '').trim();
1175
+ if (!normalized) {
1176
+ return { error: 'key required' };
1177
+ }
1178
+ await page.keyboard.press(normalized);
1179
+ await sleep(rand(250, 700), options.signal);
1180
+
1181
+ let screenshotResult = null;
1182
+ if (screenshot) screenshotResult = await this.takeScreenshot({ signal: options.signal });
1183
+
1184
+ return {
1185
+ success: true,
1186
+ key: normalized,
1187
+ screenshotPath: screenshotResult?.screenshotPath || null,
1188
+ artifactId: screenshotResult?.artifactId || null,
1189
+ fullPath: screenshotResult?.fullPath || null,
1190
+ };
1191
+ });
915
1192
  } catch (err) {
1193
+ if (isAbortError(err, options.signal)) throw err;
916
1194
  return { error: err.message };
917
1195
  }
918
1196
  }
919
1197
 
920
- async extract(selector, attribute, all = false) {
921
- const page = await this.ensurePage();
1198
+ async extract(selector, attribute, all = false, options = {}) {
1199
+ const page = await this.ensurePage(options);
922
1200
 
923
1201
  try {
924
- const rawHtml = await page.content().catch(() => '');
925
- const botDetection = await this._getBotDetection(page, rawHtml, '').catch(() => ({ detected: false, provider: null }));
926
- if (all) {
927
- const results = await page.$$eval(selector || 'body', (elements, attr) => {
928
- return elements.map(el => {
929
- if (attr === 'innerHTML') return el.innerHTML;
930
- if (attr === 'outerHTML') return el.outerHTML;
931
- if (attr) return el.getAttribute(attr) || '';
932
- return el.innerText || '';
933
- });
934
- }, attribute);
935
- return { results: results.slice(0, 100), botDetection };
936
- }
1202
+ return await this._withPageCancellation(page, options.signal, async () => {
1203
+ const rawHtml = await page.content().catch(() => '');
1204
+ const botDetection = await this._getBotDetection(page, rawHtml, '')
1205
+ .catch(() => ({ detected: false, provider: null }));
1206
+ if (all) {
1207
+ const results = await page.$$eval(selector || 'body', (elements, attr) => {
1208
+ return elements.slice(0, 100).map(el => {
1209
+ let value = '';
1210
+ if (attr === 'innerHTML') value = el.innerHTML;
1211
+ else if (attr === 'outerHTML') value = el.outerHTML;
1212
+ else if (attr) value = el.getAttribute(attr) || '';
1213
+ else value = el.innerText || '';
1214
+ return String(value).slice(0, 50000);
1215
+ });
1216
+ }, attribute);
1217
+ return { results, botDetection };
1218
+ }
937
1219
 
938
- const result = await page.$eval(selector || 'body', (el, attr) => {
939
- if (attr === 'innerHTML') return el.innerHTML;
940
- if (attr === 'outerHTML') return el.outerHTML;
941
- if (attr) return el.getAttribute(attr) || '';
942
- return el.innerText || '';
943
- }, attribute);
1220
+ const result = await page.$eval(selector || 'body', (el, attr) => {
1221
+ if (attr === 'innerHTML') return el.innerHTML;
1222
+ if (attr === 'outerHTML') return el.outerHTML;
1223
+ if (attr) return el.getAttribute(attr) || '';
1224
+ return el.innerText || '';
1225
+ }, attribute);
944
1226
 
945
- return { result: typeof result === 'string' ? result.slice(0, 50000) : result, botDetection };
1227
+ return { result: typeof result === 'string' ? result.slice(0, 50000) : result, botDetection };
1228
+ });
946
1229
  } catch (err) {
1230
+ if (isAbortError(err, options.signal)) throw err;
947
1231
  return { error: err.message };
948
1232
  }
949
1233
  }
950
1234
 
951
- async evaluate(script) {
952
- const page = await this.ensurePage();
1235
+ async evaluate(script, options = {}) {
1236
+ const source = String(script || '');
1237
+ if (!source) return { error: 'script required' };
1238
+ if (source.length > 10000) return { error: 'script exceeds maximum length (10000)' };
1239
+ const page = await this.ensurePage(options);
953
1240
  try {
954
- const result = await page.evaluate(buildIsolatedEvaluationExpression(script));
955
- return { result: typeof result === 'object' ? JSON.stringify(result) : String(result) };
1241
+ return await this._withPageCancellation(page, options.signal, async () => {
1242
+ const result = await page.evaluate(buildIsolatedEvaluationExpression(source));
1243
+ const serialized = typeof result === 'object' && result !== null
1244
+ ? JSON.stringify(result)
1245
+ : String(result);
1246
+ const output = serialized ?? 'undefined';
1247
+ const maxChars = 1024 * 1024;
1248
+ return {
1249
+ result: output.slice(0, maxChars),
1250
+ truncated: output.length > maxChars,
1251
+ };
1252
+ });
956
1253
  } catch (err) {
1254
+ if (isAbortError(err, options.signal)) throw err;
957
1255
  return { error: err.message };
958
1256
  }
959
1257
  }
@@ -963,14 +1261,12 @@ class BrowserController {
963
1261
  }
964
1262
 
965
1263
  async launch(options = {}) {
966
- void options;
967
- await this.ensureBrowser();
1264
+ await this.ensureBrowser(options);
968
1265
  return { success: true };
969
1266
  }
970
1267
 
971
1268
  isLaunched() {
972
- if (this.context) return true;
973
- return !!(this.browser && typeof this.browser.isConnected === 'function' && this.browser.isConnected());
1269
+ return this._contextIsOpen();
974
1270
  }
975
1271
 
976
1272
  getPageCount() {
@@ -981,84 +1277,139 @@ class BrowserController {
981
1277
  try { return this.browser.pages ? 1 : 0; } catch { return 0; }
982
1278
  }
983
1279
 
984
- async fill(selector, value) {
985
- return this.type(selector, String(value));
1280
+ async fill(selector, value, options = {}) {
1281
+ return this.type(selector, String(value), options);
986
1282
  }
987
1283
 
988
1284
  async extractContent(options = {}) {
989
- return this.extract(options.selector, options.attribute, options.all);
1285
+ return this.extract(options.selector, options.attribute, options.all, options);
990
1286
  }
991
1287
 
992
- async executeJS(code) {
993
- return this.evaluate(code);
1288
+ async executeJS(code, options = {}) {
1289
+ return this.evaluate(code, options);
994
1290
  }
995
1291
 
996
- async getPageInfo() {
1292
+ async getPageInfo(options = {}) {
1293
+ throwIfAborted(options.signal);
997
1294
  if (!this.page || this.page.isClosed()) return { url: null, title: null };
998
1295
  return {
999
1296
  url: this.page.url(),
1000
- title: await this.page.title()
1297
+ title: await raceWithSignal(this.page.title(), options.signal),
1001
1298
  };
1002
1299
  }
1003
1300
 
1004
- async getCookies() {
1005
- await this.ensureBrowser();
1301
+ async getCookies(options = {}) {
1302
+ await this.ensureBrowser(options);
1006
1303
  if (!this.context || typeof this.context.cookies !== 'function') {
1007
1304
  return { cookies: [] };
1008
1305
  }
1009
- const cookies = await this.context.cookies().catch(() => []);
1306
+ const cookies = await raceWithSignal(this.context.cookies(), options.signal);
1010
1307
  return {
1011
1308
  cookies: Array.isArray(cookies) ? cookies : [],
1012
1309
  };
1013
1310
  }
1014
1311
 
1015
1312
  async close() {
1016
- if (this.page && !this.page.isClosed()) {
1017
- await this.page.close().catch(() => { });
1018
- }
1019
- if (this.context) {
1020
- await this.context.close().catch(() => { });
1021
- this.context = null;
1022
- this.browser = null;
1313
+ if (this._closePromise) return this._closePromise;
1314
+ this._closing = true;
1315
+ this._launchAbortController?.abort(new Error('Browser controller is closing.'));
1316
+ this._closePromise = (async () => {
1317
+ const launchPromise = this.launchPromise;
1318
+ if (launchPromise) await launchPromise.catch(() => {});
1319
+
1320
+ const page = this.page;
1321
+ const context = this.context;
1322
+ const browser = this.browser;
1023
1323
  this.page = null;
1024
- return;
1025
- }
1026
- if (this.browser) {
1027
- await this.browser.close().catch(() => { });
1324
+ this.context = null;
1028
1325
  this.browser = null;
1029
- this.page = null;
1326
+
1327
+ if (context) {
1328
+ await context.close({ reason: 'Browser controller closed' }).catch(() => {});
1329
+ } else if (browser) {
1330
+ await browser.close().catch(() => {});
1331
+ } else if (page && !page.isClosed()) {
1332
+ await page.close({ runBeforeUnload: false, reason: 'Browser controller closed' }).catch(() => {});
1333
+ }
1334
+ await this._stopVirtualDisplay();
1335
+ })();
1336
+ try {
1337
+ await this._closePromise;
1338
+ } finally {
1339
+ this._closePromise = null;
1340
+ this._closing = false;
1030
1341
  }
1031
1342
  }
1032
1343
 
1033
- async ensureVirtualDisplay() {
1344
+ async ensureVirtualDisplay(options = {}) {
1034
1345
  if (process.platform !== 'linux') {
1035
1346
  return;
1036
1347
  }
1037
- if (this.displayProcess && !this.displayProcess.killed) {
1348
+ if (this.displayProcess && this.displayProcess.exitCode == null) {
1038
1349
  return;
1039
1350
  }
1040
1351
  if (this.displayValue && String(this.displayValue).trim()) {
1041
1352
  return;
1042
1353
  }
1043
1354
 
1044
- const display = ':99';
1355
+ const display = chooseVirtualDisplay();
1045
1356
  const child = spawn('Xvfb', [display, '-screen', '0', '1440x900x24', '-ac', '-nolisten', 'tcp'], {
1046
1357
  stdio: ['ignore', 'ignore', 'pipe'],
1047
1358
  });
1359
+ this.displayProcess = child;
1048
1360
 
1049
1361
  let launchError = '';
1050
1362
  child.stderr.on('data', (chunk) => {
1051
- launchError += chunk.toString();
1363
+ if (launchError.length < 64 * 1024) launchError += chunk.toString();
1364
+ });
1365
+ child.once('close', () => {
1366
+ if (this.displayProcess === child) {
1367
+ this.displayProcess = null;
1368
+ if (this.displayValue === display) this.displayValue = process.env.DISPLAY || null;
1369
+ if (this._managedDisplayValue === display) this._managedDisplayValue = null;
1370
+ }
1052
1371
  });
1053
1372
 
1054
- await sleep(1000);
1055
- if (child.exitCode != null) {
1056
- throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
1373
+ try {
1374
+ await Promise.race([
1375
+ sleep(1000, options.signal),
1376
+ new Promise((_, reject) => child.once('error', reject)),
1377
+ ]);
1378
+ if (child.exitCode != null) {
1379
+ throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
1380
+ }
1381
+ } catch (error) {
1382
+ await this._stopVirtualDisplay();
1383
+ throw error;
1057
1384
  }
1058
1385
 
1059
- this.displayProcess = child;
1060
1386
  this.displayValue = display;
1387
+ this._managedDisplayValue = display;
1388
+ }
1389
+
1390
+ async _stopVirtualDisplay() {
1391
+ const child = this.displayProcess;
1392
+ const managedDisplay = this._managedDisplayValue;
1393
+ this.displayProcess = null;
1394
+ this._managedDisplayValue = null;
1395
+ if (managedDisplay && this.displayValue === managedDisplay) {
1396
+ this.displayValue = process.env.DISPLAY || null;
1397
+ }
1398
+ if (!child || child.exitCode != null) return;
1399
+
1400
+ const exited = new Promise((resolve) => child.once('close', resolve));
1401
+ try { child.kill('SIGTERM'); } catch {}
1402
+ await Promise.race([exited, sleep(1000)]);
1403
+ if (child.exitCode == null) {
1404
+ try { child.kill('SIGKILL'); } catch {}
1405
+ await Promise.race([exited, sleep(1000)]);
1406
+ }
1061
1407
  }
1062
1408
  }
1063
1409
 
1064
- module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression, normalizeWaitUntil };
1410
+ module.exports = {
1411
+ BrowserController,
1412
+ buildIsolatedEvaluationExpression,
1413
+ normalizeWaitUntil,
1414
+ resolveBrowserExecutablePath,
1415
+ };