neoagent 2.3.1-beta.84 → 2.3.1-beta.86

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.
@@ -5,6 +5,9 @@ const { DATA_DIR } = require('../../../runtime/paths');
5
5
 
6
6
  const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
7
7
  if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
8
+ const BROWSER_PROFILE_ROOT = path.join(DATA_DIR, 'browser-profiles');
9
+ if (!fs.existsSync(BROWSER_PROFILE_ROOT)) fs.mkdirSync(BROWSER_PROFILE_ROOT, { recursive: true });
10
+ const BROWSER_READY_MARKER = '/var/lib/neoagent/browser-runtime-ready';
8
11
 
9
12
  const USER_AGENTS = [
10
13
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
@@ -23,6 +26,7 @@ const VIEWPORTS = [
23
26
 
24
27
  function resolveBrowserExecutablePath() {
25
28
  const explicitPath =
29
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
26
30
  process.env.PUPPETEER_EXECUTABLE_PATH ||
27
31
  process.env.CHROME_BIN ||
28
32
  process.env.CHROMIUM_BIN;
@@ -31,12 +35,17 @@ function resolveBrowserExecutablePath() {
31
35
 
32
36
  const bundledCandidates = [
33
37
  () => require('playwright-chromium').chromium.executablePath(),
34
- () => require('playwright').chromium.executablePath(),
35
38
  ];
36
39
  for (const resolveBundled of bundledCandidates) {
37
40
  try {
38
41
  const bundledPath = resolveBundled();
39
42
  if (bundledPath && fs.existsSync(bundledPath)) {
43
+ if (process.platform === 'linux') {
44
+ const wrappedPath = path.join(path.dirname(bundledPath), 'chrome-wrapper');
45
+ if (fs.existsSync(wrappedPath)) {
46
+ return wrappedPath;
47
+ }
48
+ }
40
49
  return bundledPath;
41
50
  }
42
51
  } catch {}
@@ -67,11 +76,12 @@ function resolveBrowserExecutablePath() {
67
76
  return platformCandidates.find((candidate) => fs.existsSync(candidate)) || null;
68
77
  }
69
78
 
70
- function installPlaywrightChromiumBinary() {
79
+ function installPlaywrightBrowserBinary(browserName) {
71
80
  const packageRoot = path.dirname(require.resolve('playwright-chromium/package.json'));
72
81
  const cliPath = path.join(packageRoot, 'cli.js');
73
82
  return new Promise((resolve, reject) => {
74
- const child = spawn(process.execPath, [cliPath, 'install', 'chromium'], {
83
+ const args = [cliPath, 'install', '--no-shell', browserName];
84
+ const child = spawn(process.execPath, args, {
75
85
  stdio: ['ignore', 'pipe', 'pipe'],
76
86
  });
77
87
  let stdout = '';
@@ -84,7 +94,7 @@ function installPlaywrightChromiumBinary() {
84
94
  stderr += data.toString();
85
95
  });
86
96
  child.on('error', (error) => {
87
- const detail = String(error?.message || 'playwright install chromium failed').trim();
97
+ const detail = String(error?.message || `playwright install ${browserName} failed`).trim();
88
98
  reject(new Error(detail));
89
99
  });
90
100
  child.on('close', (code) => {
@@ -92,7 +102,7 @@ function installPlaywrightChromiumBinary() {
92
102
  resolve();
93
103
  return;
94
104
  }
95
- const detail = String(stderr || stdout || `playwright install chromium exited with code ${code ?? 'unknown'}`).trim();
105
+ const detail = String(stderr || stdout || `playwright install ${browserName} exited with code ${code ?? 'unknown'}`).trim();
96
106
  reject(new Error(detail));
97
107
  });
98
108
  });
@@ -106,6 +116,22 @@ function sleep(ms) {
106
116
  return new Promise(r => setTimeout(r, ms));
107
117
  }
108
118
 
119
+ async function waitForFile(filePath, options = {}) {
120
+ const timeoutMs = Math.max(0, Number(options.timeoutMs || 0));
121
+ const intervalMs = Math.max(100, Number(options.intervalMs || 500));
122
+ if (!filePath || timeoutMs <= 0 || fs.existsSync(filePath)) {
123
+ return fs.existsSync(filePath);
124
+ }
125
+ const startedAt = Date.now();
126
+ while (Date.now() - startedAt < timeoutMs) {
127
+ await sleep(intervalMs);
128
+ if (fs.existsSync(filePath)) {
129
+ return true;
130
+ }
131
+ }
132
+ return fs.existsSync(filePath);
133
+ }
134
+
109
135
  function buildIsolatedEvaluationExpression(script) {
110
136
  const source = String(script || 'undefined');
111
137
  // Evaluate each snippet inside a fresh function scope so repeated calls do not
@@ -113,24 +139,59 @@ function buildIsolatedEvaluationExpression(script) {
113
139
  return `(() => eval(${JSON.stringify(source)}))()`;
114
140
  }
115
141
 
142
+ function normalizeWaitUntil(waitUntil) {
143
+ const value = String(waitUntil || '').trim().toLowerCase();
144
+ if (value === 'networkidle0' || value === 'networkidle2') {
145
+ return 'networkidle';
146
+ }
147
+ if (value === 'load' || value === 'domcontentloaded' || value === 'networkidle' || value === 'commit') {
148
+ return value;
149
+ }
150
+ return 'domcontentloaded';
151
+ }
152
+
153
+ function clearChromiumSingletonLocks(profileDir) {
154
+ const lockEntries = [
155
+ 'SingletonLock',
156
+ 'SingletonSocket',
157
+ 'SingletonCookie',
158
+ 'SingletonStartupLock',
159
+ 'DevToolsActivePort',
160
+ ];
161
+ for (const entry of lockEntries) {
162
+ const targetPath = path.join(profileDir, entry);
163
+ try {
164
+ fs.rmSync(targetPath, { force: true, recursive: true });
165
+ } catch {}
166
+ }
167
+ }
168
+
116
169
  class BrowserController {
117
170
  constructor(options = {}) {
118
171
  this.io = options.io || null;
119
172
  this.userId = options.userId != null ? String(options.userId) : null;
120
173
  this.artifactStore = options.artifactStore || null;
121
174
  this.runtimeBackend = options.runtimeBackend || 'host';
175
+ this.engine = 'chromium';
122
176
  this.browser = null;
177
+ this.context = null;
123
178
  this.page = null;
179
+ this.displayProcess = null;
180
+ this.displayValue = process.env.DISPLAY || null;
124
181
  this.launching = false;
182
+ this.launchPromise = null;
125
183
  this.browserBinaryInstallPromise = null;
126
- this.headless = true;
184
+ this.headless = false;
127
185
  this._viewport = VIEWPORTS[0];
128
186
  this._userAgent = USER_AGENTS[0];
187
+ this.profileDir = path.join(BROWSER_PROFILE_ROOT, this.userId || 'default');
188
+ if (!fs.existsSync(this.profileDir)) fs.mkdirSync(this.profileDir, { recursive: true });
129
189
  }
130
190
 
131
191
  async setHeadless(val) {
132
- // Headless is always true in VM-based runtime.
133
- this.headless = true;
192
+ void val;
193
+ // Browser sessions inside the VM always run headed.
194
+ this.headless = false;
134
195
  }
135
196
 
136
197
  async closeBrowser() {
@@ -141,14 +202,20 @@ class BrowserController {
141
202
  const ua = this._userAgent;
142
203
  const vp = this._viewport;
143
204
 
144
- await page.setUserAgent(ua);
145
- await page.setViewport(vp);
146
- await page.setExtraHTTPHeaders({
147
- 'Accept-Language': 'en-US,en;q=0.9',
148
- });
205
+ if (typeof page.setUserAgent === 'function') {
206
+ await page.setUserAgent(ua);
207
+ }
208
+ if (typeof page.setViewport === 'function') {
209
+ await page.setViewport(vp);
210
+ }
211
+ if (typeof page.setExtraHTTPHeaders === 'function') {
212
+ await page.setExtraHTTPHeaders({
213
+ 'Accept-Language': 'en-US,en;q=0.9',
214
+ });
215
+ }
149
216
 
150
217
  // Inject fingerprint overrides before any page script runs
151
- await page.evaluateOnNewDocument(`
218
+ const script = `
152
219
  (() => {
153
220
  // Remove webdriver flag
154
221
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
@@ -236,19 +303,31 @@ class BrowserController {
236
303
  };
237
304
  }
238
305
  })();
239
- `);
306
+ `;
307
+ if (typeof page.evaluateOnNewDocument === 'function') {
308
+ await page.evaluateOnNewDocument(script);
309
+ } else if (typeof page.addInitScript === 'function') {
310
+ await page.addInitScript(script);
311
+ }
240
312
  }
241
313
 
242
314
  async ensureBrowser() {
243
315
  if (this.browser && this.browser.isConnected()) return;
244
- if (this.launching) {
245
- await sleep(2000);
316
+ if (this.launchPromise) {
317
+ await this.launchPromise;
246
318
  return;
247
319
  }
248
320
 
249
321
  this.launching = true;
250
- try {
251
- const puppeteer = require('puppeteer-core');
322
+ this.launchPromise = (async () => {
323
+ const runtimeReady = await waitForFile(BROWSER_READY_MARKER, {
324
+ timeoutMs: 10 * 60 * 1000,
325
+ intervalMs: 1000,
326
+ });
327
+ if (!runtimeReady) {
328
+ throw new Error('Browser runtime provisioning is still in progress inside the VM. Retry shortly.');
329
+ }
330
+ await this.ensureVirtualDisplay();
252
331
 
253
332
  this._userAgent = USER_AGENTS[rand(0, USER_AGENTS.length - 1)];
254
333
  this._viewport = VIEWPORTS[rand(0, VIEWPORTS.length - 1)];
@@ -256,7 +335,7 @@ class BrowserController {
256
335
  let executablePath = resolveBrowserExecutablePath();
257
336
  if (!executablePath) {
258
337
  if (!this.browserBinaryInstallPromise) {
259
- this.browserBinaryInstallPromise = installPlaywrightChromiumBinary();
338
+ this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine);
260
339
  }
261
340
  try {
262
341
  await this.browserBinaryInstallPromise;
@@ -267,30 +346,50 @@ class BrowserController {
267
346
  }
268
347
 
269
348
  if (!executablePath) {
270
- throw new Error('No browser executable found for puppeteer-core; set PUPPETEER_EXECUTABLE_PATH or install a browser.');
349
+ throw new Error(`No ${this.engine} executable found for the VM browser runtime.`);
271
350
  }
272
351
 
273
- this.browser = await puppeteer.launch({
274
- headless: this.headless ? 'new' : false,
352
+ const launchEnv = {
353
+ ...process.env,
354
+ ...(this.displayValue ? { DISPLAY: this.displayValue } : {}),
355
+ };
356
+
357
+ const launchArgs = [
358
+ '--no-sandbox',
359
+ '--disable-setuid-sandbox',
360
+ '--disable-dev-shm-usage',
361
+ '--disable-crash-reporter',
362
+ '--disable-background-networking',
363
+ '--disable-component-update',
364
+ '--disable-blink-features=AutomationControlled',
365
+ '--disable-infobars',
366
+ '--no-first-run',
367
+ '--no-default-browser-check',
368
+ '--disable-gpu',
369
+ '--lang=en-US,en',
370
+ `--window-size=${this._viewport.width},${this._viewport.height}`,
371
+ ];
372
+
373
+ const playwright = require('playwright-chromium');
374
+ clearChromiumSingletonLocks(this.profileDir);
375
+ this.context = await playwright.chromium.launchPersistentContext(this.profileDir, {
376
+ headless: false,
275
377
  executablePath,
276
- args: [
277
- '--no-sandbox',
278
- '--disable-setuid-sandbox',
279
- '--disable-dev-shm-usage',
280
- '--disable-blink-features=AutomationControlled',
281
- '--disable-infobars',
282
- '--no-first-run',
283
- '--no-default-browser-check',
284
- '--lang=en-US,en',
285
- `--window-size=${this._viewport.width},${this._viewport.height}`,
286
- ],
287
- defaultViewport: this._viewport,
288
- ignoreDefaultArgs: ['--enable-automation'],
378
+ env: launchEnv,
379
+ args: launchArgs,
380
+ viewport: this._viewport,
381
+ ignoreHTTPSErrors: false,
382
+ timeout: 120000,
289
383
  });
290
-
291
- this.page = await this.browser.newPage();
384
+ this.browser = typeof this.context.browser === 'function' ? this.context.browser() : null;
385
+ this.page = this.context.pages()[0] || await this.context.newPage();
292
386
  await this._applyStealthToPage(this.page);
387
+ })();
388
+
389
+ try {
390
+ await this.launchPromise;
293
391
  } finally {
392
+ this.launchPromise = null;
294
393
  this.launching = false;
295
394
  }
296
395
  }
@@ -298,7 +397,11 @@ class BrowserController {
298
397
  async ensurePage() {
299
398
  await this.ensureBrowser();
300
399
  if (!this.page || this.page.isClosed()) {
301
- this.page = await this.browser.newPage();
400
+ if (this.context && typeof this.context.newPage === 'function') {
401
+ this.page = await this.context.newPage();
402
+ } else {
403
+ this.page = await this.browser.newPage();
404
+ }
302
405
  await this._applyStealthToPage(this.page);
303
406
  }
304
407
  return this.page;
@@ -355,7 +458,7 @@ class BrowserController {
355
458
 
356
459
  try {
357
460
  const response = await page.goto(url, {
358
- waitUntil: 'networkidle2',
461
+ waitUntil: normalizeWaitUntil(options.waitUntil),
359
462
  timeout: 30000
360
463
  });
361
464
 
@@ -622,15 +725,20 @@ class BrowserController {
622
725
  }
623
726
 
624
727
  async launch(options = {}) {
728
+ void options;
625
729
  await this.ensureBrowser();
626
730
  return { success: true };
627
731
  }
628
732
 
629
733
  isLaunched() {
630
- return !!(this.browser && this.browser.isConnected());
734
+ if (this.context) return true;
735
+ return !!(this.browser && typeof this.browser.isConnected === 'function' && this.browser.isConnected());
631
736
  }
632
737
 
633
738
  getPageCount() {
739
+ if (this.context && typeof this.context.pages === 'function') {
740
+ try { return this.context.pages().length; } catch { return 0; }
741
+ }
634
742
  if (!this.browser) return 0;
635
743
  try { return this.browser.pages ? 1 : 0; } catch { return 0; }
636
744
  }
@@ -659,12 +767,49 @@ class BrowserController {
659
767
  if (this.page && !this.page.isClosed()) {
660
768
  await this.page.close().catch(() => { });
661
769
  }
770
+ if (this.context) {
771
+ await this.context.close().catch(() => { });
772
+ this.context = null;
773
+ this.browser = null;
774
+ this.page = null;
775
+ return;
776
+ }
662
777
  if (this.browser) {
663
778
  await this.browser.close().catch(() => { });
664
779
  this.browser = null;
665
780
  this.page = null;
666
781
  }
667
782
  }
783
+
784
+ async ensureVirtualDisplay() {
785
+ if (process.platform !== 'linux') {
786
+ return;
787
+ }
788
+ if (this.displayProcess && !this.displayProcess.killed) {
789
+ return;
790
+ }
791
+ if (this.displayValue && String(this.displayValue).trim()) {
792
+ return;
793
+ }
794
+
795
+ const display = ':99';
796
+ const child = spawn('Xvfb', [display, '-screen', '0', '1440x900x24', '-ac', '-nolisten', 'tcp'], {
797
+ stdio: ['ignore', 'ignore', 'pipe'],
798
+ });
799
+
800
+ let launchError = '';
801
+ child.stderr.on('data', (chunk) => {
802
+ launchError += chunk.toString();
803
+ });
804
+
805
+ await sleep(1000);
806
+ if (child.exitCode != null) {
807
+ throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
808
+ }
809
+
810
+ this.displayProcess = child;
811
+ this.displayValue = display;
812
+ }
668
813
  }
669
814
 
670
- module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression };
815
+ module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression, normalizeWaitUntil };
@@ -23,6 +23,18 @@ function assertPathInside(baseDir, candidatePath, label) {
23
23
  return resolvedCandidate;
24
24
  }
25
25
 
26
+ function isPidAlive(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0) {
28
+ return false;
29
+ }
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
26
38
  class RuntimeHttpClient {
27
39
  constructor(baseUrl, token = '', options = {}) {
28
40
  this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
@@ -31,7 +43,7 @@ class RuntimeHttpClient {
31
43
  }
32
44
 
33
45
  async waitForHealth(options = {}) {
34
- const timeoutMs = Number(options.timeoutMs || 120000);
46
+ const timeoutMs = Number(options.timeoutMs || 600000); // Increased from 120s to 10m for bootstrap
35
47
  const intervalMs = Number(options.intervalMs || 1000);
36
48
  const checkLiveness = options.checkLiveness || (() => true);
37
49
  const startedAt = Date.now();
@@ -65,37 +77,53 @@ class RuntimeHttpClient {
65
77
  }
66
78
 
67
79
  async request(method, pathname, body, options = {}) {
68
- const controller = options.timeoutMs ? new AbortController() : null;
69
- const timer = controller ? setTimeout(() => controller.abort(new Error(`Request timed out after ${options.timeoutMs} ms.`)), options.timeoutMs) : null;
70
- try {
71
- const response = await fetch(`${this.baseUrl}${pathname}`, {
72
- method,
73
- headers: {
74
- 'content-type': 'application/json',
75
- ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
76
- },
77
- body: body === undefined ? undefined : JSON.stringify(body),
78
- signal: controller?.signal,
79
- });
80
-
81
- const contentType = response.headers.get('content-type') || '';
82
- const payload = contentType.includes('application/json')
83
- ? await response.json().catch(() => ({}))
84
- : { text: await response.text().catch(() => '') };
80
+ const retryCount = Math.max(0, Number(options.retryCount ?? 6));
81
+ const retryDelayMs = Math.max(100, Number(options.retryDelayMs ?? 1000));
82
+ let lastError = null;
85
83
 
86
- if (!response.ok) {
87
- const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
88
- throw new Error(errorMessage);
89
- }
90
- if (response.ok && typeof this.onActivity === 'function') {
91
- this.onActivity();
92
- }
93
- return payload;
94
- } finally {
95
- if (timer) {
96
- clearTimeout(timer);
84
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
85
+ const controller = options.timeoutMs ? new AbortController() : null;
86
+ const timer = controller ? setTimeout(() => controller.abort(new Error(`Request timed out after ${options.timeoutMs} ms.`)), options.timeoutMs) : null;
87
+ try {
88
+ const response = await fetch(`${this.baseUrl}${pathname}`, {
89
+ method,
90
+ headers: {
91
+ 'content-type': 'application/json',
92
+ ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
93
+ },
94
+ body: body === undefined ? undefined : JSON.stringify(body),
95
+ signal: controller?.signal,
96
+ });
97
+
98
+ const contentType = response.headers.get('content-type') || '';
99
+ const payload = contentType.includes('application/json')
100
+ ? await response.json().catch(() => ({}))
101
+ : { text: await response.text().catch(() => '') };
102
+
103
+ if (!response.ok) {
104
+ const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
105
+ throw new Error(errorMessage);
106
+ }
107
+ if (typeof this.onActivity === 'function') {
108
+ this.onActivity();
109
+ }
110
+ return payload;
111
+ } catch (error) {
112
+ lastError = error;
113
+ const message = String(error?.message || error);
114
+ const retryable = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|timed out/i.test(message);
115
+ if (!retryable || attempt === retryCount) {
116
+ throw error;
117
+ }
118
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
119
+ } finally {
120
+ if (timer) {
121
+ clearTimeout(timer);
122
+ }
97
123
  }
98
124
  }
125
+
126
+ throw lastError || new Error('Runtime request failed.');
99
127
  }
100
128
 
101
129
  async requestStream(method, pathname, stream, options = {}) {
@@ -325,6 +353,7 @@ class VmAndroidProvider {
325
353
  class LocalVmExecutionBackend {
326
354
  constructor(options = {}) {
327
355
  this.vmManager = options.vmManager;
356
+ this.runtimeProfile = options.runtimeProfile === 'android' ? 'android' : 'browser_cli';
328
357
  this.token = options.token || process.env.NEOAGENT_VM_GUEST_TOKEN || '';
329
358
  this.artifactStore = options.artifactStore || null;
330
359
  this.lastActivity = new Map();
@@ -348,12 +377,12 @@ class LocalVmExecutionBackend {
348
377
  const now = Date.now();
349
378
  for (const [userId, lastUsed] of this.lastActivity.entries()) {
350
379
  if (now - lastUsed > IDLE_TIMEOUT_MS) {
351
- console.log(`[Runtime] User ${userId} runtime idle for ${Math.round((now - lastUsed) / 1000)}s, shutting down VM.`);
380
+ console.log(`[Runtime:${this.runtimeProfile}] User ${userId} runtime idle for ${Math.round((now - lastUsed) / 1000)}s, shutting down VM.`);
352
381
  this.lastActivity.delete(userId);
353
382
  try {
354
383
  await this.vmManager?.killVm?.(userId);
355
384
  } catch (err) {
356
- console.error(`[Runtime] Failed to shut down idle VM for user ${userId}:`, err.message);
385
+ console.error(`[Runtime:${this.runtimeProfile}] Failed to shut down idle VM for user ${userId}:`, err.message);
357
386
  }
358
387
  }
359
388
  }
@@ -366,7 +395,7 @@ class LocalVmExecutionBackend {
366
395
  }
367
396
  const session = await this.vmManager.ensureVm(userId);
368
397
  this.#touch(userId);
369
- const client = new RuntimeHttpClient(session.baseUrl, this.token, {
398
+ const client = new RuntimeHttpClient(session.baseUrl, session.guestToken || this.token, {
370
399
  onActivity: () => this.#touch(userId),
371
400
  });
372
401
  try {
@@ -375,7 +404,7 @@ class LocalVmExecutionBackend {
375
404
  checkLiveness: () => {
376
405
  const key = String(userId || '').trim();
377
406
  const session = this.vmManager.instances.get(key);
378
- return session && session.process && !session.process.killed && session.process.exitCode === null;
407
+ return Boolean(session && session.process && isPidAlive(session.process.pid));
379
408
  },
380
409
  });
381
410
  } catch (error) {