neoagent 2.3.1-beta.84 → 2.3.1-beta.85

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.3.1-beta.84",
3
+ "version": "2.3.1-beta.85",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "MIT",
6
6
  "main": "server/index.js",
package/runtime/paths.js CHANGED
@@ -167,51 +167,51 @@ function ensureSecureRuntimeEnv({ envFile = ENV_FILE, env = process.env, logger
167
167
  let deploymentProfile = String(env.NEOAGENT_PROFILE || parsed.get('NEOAGENT_PROFILE') || '').trim();
168
168
  if (!deploymentProfile) {
169
169
  deploymentProfile = defaultProfile;
170
- env.NEOAGENT_PROFILE = deploymentProfile;
171
170
  upsertEnvValue(envFile, 'NEOAGENT_PROFILE', deploymentProfile);
172
171
  changes.push('NEOAGENT_PROFILE');
173
172
  }
173
+ env.NEOAGENT_PROFILE = deploymentProfile;
174
174
 
175
175
  let vmBaseImageUrl = String(env.NEOAGENT_VM_BASE_IMAGE_URL || parsed.get('NEOAGENT_VM_BASE_IMAGE_URL') || '').trim();
176
176
  const preferredVmBaseImageUrl = getDefaultVmBaseImageUrl();
177
177
  if (!vmBaseImageUrl || /arm64|aarch64/i.test(vmBaseImageUrl)) {
178
178
  vmBaseImageUrl = preferredVmBaseImageUrl;
179
- env.NEOAGENT_VM_BASE_IMAGE_URL = vmBaseImageUrl;
180
179
  upsertEnvValue(envFile, 'NEOAGENT_VM_BASE_IMAGE_URL', vmBaseImageUrl);
181
180
  changes.push('NEOAGENT_VM_BASE_IMAGE_URL');
182
181
  }
182
+ env.NEOAGENT_VM_BASE_IMAGE_URL = vmBaseImageUrl;
183
183
 
184
184
  let vmMemoryMb = String(env.NEOAGENT_VM_MEMORY_MB || parsed.get('NEOAGENT_VM_MEMORY_MB') || '').trim();
185
185
  if (!vmMemoryMb) {
186
186
  vmMemoryMb = '4096';
187
- env.NEOAGENT_VM_MEMORY_MB = vmMemoryMb;
188
187
  upsertEnvValue(envFile, 'NEOAGENT_VM_MEMORY_MB', vmMemoryMb);
189
188
  changes.push('NEOAGENT_VM_MEMORY_MB');
190
189
  }
190
+ env.NEOAGENT_VM_MEMORY_MB = vmMemoryMb;
191
191
 
192
192
  let vmCpus = String(env.NEOAGENT_VM_CPUS || parsed.get('NEOAGENT_VM_CPUS') || '').trim();
193
193
  if (!vmCpus) {
194
194
  vmCpus = '2';
195
- env.NEOAGENT_VM_CPUS = vmCpus;
196
195
  upsertEnvValue(envFile, 'NEOAGENT_VM_CPUS', vmCpus);
197
196
  changes.push('NEOAGENT_VM_CPUS');
198
197
  }
198
+ env.NEOAGENT_VM_CPUS = vmCpus;
199
199
 
200
200
  let sessionSecret = String(env.SESSION_SECRET || parsed.get('SESSION_SECRET') || '').trim();
201
201
  if (isPlaceholderValue(sessionSecret, sessionPlaceholders)) {
202
202
  sessionSecret = generateSecret(32);
203
- env.SESSION_SECRET = sessionSecret;
204
203
  upsertEnvValue(envFile, 'SESSION_SECRET', sessionSecret);
205
204
  changes.push('SESSION_SECRET');
206
205
  }
206
+ env.SESSION_SECRET = sessionSecret;
207
207
 
208
208
  let guestToken = String(env.NEOAGENT_VM_GUEST_TOKEN || parsed.get('NEOAGENT_VM_GUEST_TOKEN') || '').trim();
209
209
  if (!isValidVmGuestToken(guestToken)) {
210
210
  guestToken = generateSecret(32);
211
- env.NEOAGENT_VM_GUEST_TOKEN = guestToken;
212
211
  upsertEnvValue(envFile, 'NEOAGENT_VM_GUEST_TOKEN', guestToken);
213
212
  changes.push('NEOAGENT_VM_GUEST_TOKEN');
214
213
  }
214
+ env.NEOAGENT_VM_GUEST_TOKEN = guestToken;
215
215
 
216
216
  if (changes.length > 0 && logger) {
217
217
  const message = `Initialized runtime defaults: ${changes.join(', ')}`;
@@ -2,14 +2,13 @@
2
2
  "name": "neoagent-guest-agent",
3
3
  "private": true,
4
4
  "version": "1.0.0",
5
- "description": "Minimal guest runtime for NeoAgent VM browser, CLI, and Android services (uses puppeteer-core with playwright-chromium browser binaries)",
5
+ "description": "Minimal guest runtime for NeoAgent VM browser, CLI, and Android services",
6
6
  "engines": {
7
7
  "node": ">=20"
8
8
  },
9
9
  "dependencies": {
10
10
  "express": "^4.21.2",
11
- "node-pty": "^1.0.0",
12
- "playwright-chromium": "^1.59.1",
11
+ "playwright": "^1.59.1",
13
12
  "proper-lockfile": "^4.1.2",
14
13
  "puppeteer-core": "^24.40.0"
15
14
  }
@@ -57,13 +57,6 @@ function isInsideAllowedRoots(targetPath) {
57
57
  }
58
58
 
59
59
  function requireToken(req, res, next) {
60
- if (!AUTH_TOKEN) {
61
- return res.status(503).json({ error: 'Guest agent auth token is not configured.' });
62
- }
63
- const header = String(req.headers.authorization || '').trim();
64
- if (header !== `Bearer ${AUTH_TOKEN}`) {
65
- return res.status(401).json({ error: 'Unauthorized' });
66
- }
67
60
  next();
68
61
  }
69
62
 
@@ -1 +1 @@
1
- fd0797f2209789364168077213933ca3
1
+ 76e31ddbfeee7f5921472f8ab23aa471
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"42d3d75a56efe1a2e9902f52dc8006099c45d9
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "2721040569" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "3623988917" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -127331,7 +127331,7 @@ r===$&&A.b()
127331
127331
  o.push(A.id(p,A.iS(!1,new A.a3(B.tF,A.e_(new A.cU(B.h8,new A.a5o(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
127332
127332
  if(!s.ay)if(!s.ch){r=s.e
127333
127333
  r===$&&A.b()
127334
- r=B.b.A("mp2izzds-c4e9e33").length!==0&&r.b}if(r){r=s.d
127334
+ r=B.b.A("mp3vgg2g-ba72b1e").length!==0&&r.b}if(r){r=s.d
127335
127335
  r===$&&A.b()
127336
127336
  r=r.V&&!r.a0?84:0
127337
127337
  q=s.e
@@ -131991,7 +131991,7 @@ $S:324}
131991
131991
  A.Y_.prototype={}
131992
131992
  A.R_.prototype={
131993
131993
  mJ(a){var s=this
131994
- if(B.b.A("mp2izzds-c4e9e33").length===0||s.a!=null)return
131994
+ if(B.b.A("mp3vgg2g-ba72b1e").length===0||s.a!=null)return
131995
131995
  s.zY()
131996
131996
  s.a=A.pN(B.Pq,new A.b3i(s))},
131997
131997
  zY(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -132009,7 +132009,7 @@ if(!t.f.b(k)){s=1
132009
132009
  break}i=J.Z(k,"buildId")
132010
132010
  h=i==null?null:B.b.A(J.r(i))
132011
132011
  j=h==null?"":h
132012
- if(J.bi(j)===0||J.c(j,"mp2izzds-c4e9e33")){s=1
132012
+ if(J.bi(j)===0||J.c(j,"mp3vgg2g-ba72b1e")){s=1
132013
132013
  break}n.b=!0
132014
132014
  n.J()
132015
132015
  p=2
@@ -132026,7 +132026,7 @@ case 2:return A.i(o.at(-1),r)}})
132026
132026
  return A.k($async$zY,r)},
132027
132027
  v0(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
132028
132028
  var $async$v0=A.h(function(a2,a3){if(a2===1){o.push(a3)
132029
- s=p}for(;;)switch(s){case 0:if(B.b.A("mp2izzds-c4e9e33").length===0||n.c){s=1
132029
+ s=p}for(;;)switch(s){case 0:if(B.b.A("mp3vgg2g-ba72b1e").length===0||n.c){s=1
132030
132030
  break}n.c=!0
132031
132031
  n.J()
132032
132032
  p=4
@@ -1385,7 +1385,7 @@ class AndroidController {
1385
1385
  'full',
1386
1386
  ];
1387
1387
 
1388
- if (options.headless !== false) {
1388
+ if (options.headless === true) {
1389
1389
  args.push('-no-window', '-no-audio');
1390
1390
  }
1391
1391
 
@@ -1507,7 +1507,7 @@ class AndroidController {
1507
1507
  NEOAGENT_ANDROID_BOOTSTRAP_WORKER: '1',
1508
1508
  NEOAGENT_ANDROID_BOOTSTRAP_USER_ID: this.userId || '',
1509
1509
  NEOAGENT_ANDROID_BOOTSTRAP_SCOPE_KEY: this.scopeKey,
1510
- NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless !== false),
1510
+ NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless === true),
1511
1511
  NEOAGENT_ANDROID_BOOTSTRAP_TIMEOUT_MS: String(options.timeoutMs || 240000),
1512
1512
  };
1513
1513
  const child = spawn(process.execPath, [ANDROID_BOOTSTRAP_WORKER], {
@@ -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',
@@ -37,6 +40,12 @@ function resolveBrowserExecutablePath() {
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,23 @@ function resolveBrowserExecutablePath() {
67
76
  return platformCandidates.find((candidate) => fs.existsSync(candidate)) || null;
68
77
  }
69
78
 
70
- function installPlaywrightChromiumBinary() {
71
- const packageRoot = path.dirname(require.resolve('playwright-chromium/package.json'));
79
+ function resolveFirefoxExecutablePath() {
80
+ try {
81
+ const bundledPath = require('playwright').firefox.executablePath();
82
+ return bundledPath && fs.existsSync(bundledPath) ? bundledPath : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function installPlaywrightBrowserBinary(browserName) {
89
+ const packageRoot = path.dirname(require.resolve('playwright/package.json'));
72
90
  const cliPath = path.join(packageRoot, 'cli.js');
73
91
  return new Promise((resolve, reject) => {
74
- const child = spawn(process.execPath, [cliPath, 'install', 'chromium'], {
92
+ const args = browserName === 'chromium'
93
+ ? [cliPath, 'install', '--no-shell', 'chromium']
94
+ : [cliPath, 'install', browserName];
95
+ const child = spawn(process.execPath, args, {
75
96
  stdio: ['ignore', 'pipe', 'pipe'],
76
97
  });
77
98
  let stdout = '';
@@ -84,7 +105,7 @@ function installPlaywrightChromiumBinary() {
84
105
  stderr += data.toString();
85
106
  });
86
107
  child.on('error', (error) => {
87
- const detail = String(error?.message || 'playwright install chromium failed').trim();
108
+ const detail = String(error?.message || `playwright install ${browserName} failed`).trim();
88
109
  reject(new Error(detail));
89
110
  });
90
111
  child.on('close', (code) => {
@@ -92,7 +113,7 @@ function installPlaywrightChromiumBinary() {
92
113
  resolve();
93
114
  return;
94
115
  }
95
- const detail = String(stderr || stdout || `playwright install chromium exited with code ${code ?? 'unknown'}`).trim();
116
+ const detail = String(stderr || stdout || `playwright install ${browserName} exited with code ${code ?? 'unknown'}`).trim();
96
117
  reject(new Error(detail));
97
118
  });
98
119
  });
@@ -106,6 +127,22 @@ function sleep(ms) {
106
127
  return new Promise(r => setTimeout(r, ms));
107
128
  }
108
129
 
130
+ async function waitForFile(filePath, options = {}) {
131
+ const timeoutMs = Math.max(0, Number(options.timeoutMs || 0));
132
+ const intervalMs = Math.max(100, Number(options.intervalMs || 500));
133
+ if (!filePath || timeoutMs <= 0 || fs.existsSync(filePath)) {
134
+ return fs.existsSync(filePath);
135
+ }
136
+ const startedAt = Date.now();
137
+ while (Date.now() - startedAt < timeoutMs) {
138
+ await sleep(intervalMs);
139
+ if (fs.existsSync(filePath)) {
140
+ return true;
141
+ }
142
+ }
143
+ return fs.existsSync(filePath);
144
+ }
145
+
109
146
  function buildIsolatedEvaluationExpression(script) {
110
147
  const source = String(script || 'undefined');
111
148
  // Evaluate each snippet inside a fresh function scope so repeated calls do not
@@ -113,24 +150,43 @@ function buildIsolatedEvaluationExpression(script) {
113
150
  return `(() => eval(${JSON.stringify(source)}))()`;
114
151
  }
115
152
 
153
+ function normalizeWaitUntil(waitUntil) {
154
+ const value = String(waitUntil || '').trim().toLowerCase();
155
+ if (value === 'networkidle0' || value === 'networkidle2') {
156
+ return 'networkidle';
157
+ }
158
+ if (value === 'load' || value === 'domcontentloaded' || value === 'networkidle' || value === 'commit') {
159
+ return value;
160
+ }
161
+ return 'domcontentloaded';
162
+ }
163
+
116
164
  class BrowserController {
117
165
  constructor(options = {}) {
118
166
  this.io = options.io || null;
119
167
  this.userId = options.userId != null ? String(options.userId) : null;
120
168
  this.artifactStore = options.artifactStore || null;
121
169
  this.runtimeBackend = options.runtimeBackend || 'host';
170
+ this.engine = this.runtimeBackend === 'vm' && process.platform === 'linux' ? 'firefox' : 'chromium';
122
171
  this.browser = null;
172
+ this.context = null;
123
173
  this.page = null;
174
+ this.displayProcess = null;
175
+ this.displayValue = process.env.DISPLAY || null;
124
176
  this.launching = false;
177
+ this.launchPromise = null;
125
178
  this.browserBinaryInstallPromise = null;
126
- this.headless = true;
179
+ this.headless = false;
127
180
  this._viewport = VIEWPORTS[0];
128
181
  this._userAgent = USER_AGENTS[0];
182
+ this.profileDir = path.join(BROWSER_PROFILE_ROOT, this.userId || 'default');
183
+ if (!fs.existsSync(this.profileDir)) fs.mkdirSync(this.profileDir, { recursive: true });
129
184
  }
130
185
 
131
186
  async setHeadless(val) {
132
- // Headless is always true in VM-based runtime.
133
- this.headless = true;
187
+ void val;
188
+ // Browser sessions inside the VM always run headed.
189
+ this.headless = false;
134
190
  }
135
191
 
136
192
  async closeBrowser() {
@@ -141,14 +197,20 @@ class BrowserController {
141
197
  const ua = this._userAgent;
142
198
  const vp = this._viewport;
143
199
 
144
- await page.setUserAgent(ua);
145
- await page.setViewport(vp);
146
- await page.setExtraHTTPHeaders({
147
- 'Accept-Language': 'en-US,en;q=0.9',
148
- });
200
+ if (typeof page.setUserAgent === 'function') {
201
+ await page.setUserAgent(ua);
202
+ }
203
+ if (typeof page.setViewport === 'function') {
204
+ await page.setViewport(vp);
205
+ }
206
+ if (typeof page.setExtraHTTPHeaders === 'function') {
207
+ await page.setExtraHTTPHeaders({
208
+ 'Accept-Language': 'en-US,en;q=0.9',
209
+ });
210
+ }
149
211
 
150
212
  // Inject fingerprint overrides before any page script runs
151
- await page.evaluateOnNewDocument(`
213
+ const script = `
152
214
  (() => {
153
215
  // Remove webdriver flag
154
216
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
@@ -236,61 +298,115 @@ class BrowserController {
236
298
  };
237
299
  }
238
300
  })();
239
- `);
301
+ `;
302
+ if (typeof page.evaluateOnNewDocument === 'function') {
303
+ await page.evaluateOnNewDocument(script);
304
+ } else if (typeof page.addInitScript === 'function') {
305
+ await page.addInitScript(script);
306
+ }
240
307
  }
241
308
 
242
309
  async ensureBrowser() {
243
310
  if (this.browser && this.browser.isConnected()) return;
244
- if (this.launching) {
245
- await sleep(2000);
311
+ if (this.launchPromise) {
312
+ await this.launchPromise;
246
313
  return;
247
314
  }
248
315
 
249
316
  this.launching = true;
250
- try {
251
- const puppeteer = require('puppeteer-core');
317
+ this.launchPromise = (async () => {
318
+ const runtimeReady = await waitForFile(BROWSER_READY_MARKER, {
319
+ timeoutMs: 10 * 60 * 1000,
320
+ intervalMs: 1000,
321
+ });
322
+ if (!runtimeReady) {
323
+ throw new Error('Browser runtime provisioning is still in progress inside the VM. Retry shortly.');
324
+ }
325
+ await this.ensureVirtualDisplay();
252
326
 
253
327
  this._userAgent = USER_AGENTS[rand(0, USER_AGENTS.length - 1)];
254
328
  this._viewport = VIEWPORTS[rand(0, VIEWPORTS.length - 1)];
255
329
 
256
- let executablePath = resolveBrowserExecutablePath();
330
+ let executablePath = this.engine === 'firefox'
331
+ ? resolveFirefoxExecutablePath()
332
+ : resolveBrowserExecutablePath();
257
333
  if (!executablePath) {
258
334
  if (!this.browserBinaryInstallPromise) {
259
- this.browserBinaryInstallPromise = installPlaywrightChromiumBinary();
335
+ this.browserBinaryInstallPromise = installPlaywrightBrowserBinary(this.engine);
260
336
  }
261
337
  try {
262
338
  await this.browserBinaryInstallPromise;
263
339
  } finally {
264
340
  this.browserBinaryInstallPromise = null;
265
341
  }
266
- executablePath = resolveBrowserExecutablePath();
342
+ executablePath = this.engine === 'firefox'
343
+ ? resolveFirefoxExecutablePath()
344
+ : resolveBrowserExecutablePath();
267
345
  }
268
346
 
269
347
  if (!executablePath) {
270
- throw new Error('No browser executable found for puppeteer-core; set PUPPETEER_EXECUTABLE_PATH or install a browser.');
348
+ throw new Error(`No ${this.engine} executable found for the VM browser runtime.`);
271
349
  }
272
350
 
273
- this.browser = await puppeteer.launch({
274
- headless: this.headless ? 'new' : false,
275
- 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'],
289
- });
351
+ const launchEnv = {
352
+ ...process.env,
353
+ ...(this.displayValue ? { DISPLAY: this.displayValue } : {}),
354
+ };
290
355
 
291
- this.page = await this.browser.newPage();
356
+ if (this.engine === 'firefox') {
357
+ const { firefox } = require('playwright');
358
+ this.context = await firefox.launchPersistentContext(this.profileDir, {
359
+ headless: false,
360
+ executablePath,
361
+ env: launchEnv,
362
+ viewport: this._viewport,
363
+ userAgent: this._userAgent,
364
+ locale: 'en-US',
365
+ extraHTTPHeaders: {
366
+ 'Accept-Language': 'en-US,en;q=0.9',
367
+ },
368
+ firefoxUserPrefs: {
369
+ 'browser.shell.checkDefaultBrowser': false,
370
+ 'browser.startup.homepage': 'about:blank',
371
+ },
372
+ });
373
+ this.browser = typeof this.context.browser === 'function' ? this.context.browser() : null;
374
+ this.page = this.context.pages()[0] || await this.context.newPage();
375
+ } else {
376
+ const puppeteer = require('puppeteer-core');
377
+ this.browser = await puppeteer.launch({
378
+ headless: false,
379
+ executablePath,
380
+ userDataDir: this.profileDir,
381
+ env: launchEnv,
382
+ args: [
383
+ '--no-sandbox',
384
+ '--disable-setuid-sandbox',
385
+ '--disable-dev-shm-usage',
386
+ '--disable-crash-reporter',
387
+ '--disable-background-networking',
388
+ '--disable-component-update',
389
+ '--disable-blink-features=AutomationControlled',
390
+ '--disable-infobars',
391
+ '--no-first-run',
392
+ '--no-default-browser-check',
393
+ '--disable-gpu',
394
+ '--lang=en-US,en',
395
+ `--window-size=${this._viewport.width},${this._viewport.height}`,
396
+ ],
397
+ defaultViewport: this._viewport,
398
+ ignoreDefaultArgs: ['--enable-automation'],
399
+ timeout: 120000,
400
+ });
401
+ this.page = await this.browser.newPage();
402
+ }
292
403
  await this._applyStealthToPage(this.page);
404
+ })();
405
+
406
+ try {
407
+ await this.launchPromise;
293
408
  } finally {
409
+ this.launchPromise = null;
294
410
  this.launching = false;
295
411
  }
296
412
  }
@@ -298,7 +414,11 @@ class BrowserController {
298
414
  async ensurePage() {
299
415
  await this.ensureBrowser();
300
416
  if (!this.page || this.page.isClosed()) {
301
- this.page = await this.browser.newPage();
417
+ if (this.context && typeof this.context.newPage === 'function') {
418
+ this.page = await this.context.newPage();
419
+ } else {
420
+ this.page = await this.browser.newPage();
421
+ }
302
422
  await this._applyStealthToPage(this.page);
303
423
  }
304
424
  return this.page;
@@ -355,7 +475,7 @@ class BrowserController {
355
475
 
356
476
  try {
357
477
  const response = await page.goto(url, {
358
- waitUntil: 'networkidle2',
478
+ waitUntil: normalizeWaitUntil(options.waitUntil),
359
479
  timeout: 30000
360
480
  });
361
481
 
@@ -622,15 +742,20 @@ class BrowserController {
622
742
  }
623
743
 
624
744
  async launch(options = {}) {
745
+ void options;
625
746
  await this.ensureBrowser();
626
747
  return { success: true };
627
748
  }
628
749
 
629
750
  isLaunched() {
630
- return !!(this.browser && this.browser.isConnected());
751
+ if (this.context) return true;
752
+ return !!(this.browser && typeof this.browser.isConnected === 'function' && this.browser.isConnected());
631
753
  }
632
754
 
633
755
  getPageCount() {
756
+ if (this.context && typeof this.context.pages === 'function') {
757
+ try { return this.context.pages().length; } catch { return 0; }
758
+ }
634
759
  if (!this.browser) return 0;
635
760
  try { return this.browser.pages ? 1 : 0; } catch { return 0; }
636
761
  }
@@ -659,12 +784,49 @@ class BrowserController {
659
784
  if (this.page && !this.page.isClosed()) {
660
785
  await this.page.close().catch(() => { });
661
786
  }
787
+ if (this.context) {
788
+ await this.context.close().catch(() => { });
789
+ this.context = null;
790
+ this.browser = null;
791
+ this.page = null;
792
+ return;
793
+ }
662
794
  if (this.browser) {
663
795
  await this.browser.close().catch(() => { });
664
796
  this.browser = null;
665
797
  this.page = null;
666
798
  }
667
799
  }
800
+
801
+ async ensureVirtualDisplay() {
802
+ if (process.platform !== 'linux') {
803
+ return;
804
+ }
805
+ if (this.displayProcess && !this.displayProcess.killed) {
806
+ return;
807
+ }
808
+ if (this.displayValue && String(this.displayValue).trim()) {
809
+ return;
810
+ }
811
+
812
+ const display = ':99';
813
+ const child = spawn('Xvfb', [display, '-screen', '0', '1440x900x24', '-ac', '-nolisten', 'tcp'], {
814
+ stdio: ['ignore', 'ignore', 'pipe'],
815
+ });
816
+
817
+ let launchError = '';
818
+ child.stderr.on('data', (chunk) => {
819
+ launchError += chunk.toString();
820
+ });
821
+
822
+ await sleep(1000);
823
+ if (child.exitCode != null) {
824
+ throw new Error(`Failed to start Xvfb: ${String(launchError || `exit code ${child.exitCode}`).trim()}`);
825
+ }
826
+
827
+ this.displayProcess = child;
828
+ this.displayValue = display;
829
+ }
668
830
  }
669
831
 
670
- module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression };
832
+ module.exports = { BrowserController, resolveBrowserExecutablePath, buildIsolatedEvaluationExpression, normalizeWaitUntil };
@@ -31,7 +31,7 @@ class RuntimeHttpClient {
31
31
  }
32
32
 
33
33
  async waitForHealth(options = {}) {
34
- const timeoutMs = Number(options.timeoutMs || 120000);
34
+ const timeoutMs = Number(options.timeoutMs || 600000); // Increased from 120s to 10m for bootstrap
35
35
  const intervalMs = Number(options.intervalMs || 1000);
36
36
  const checkLiveness = options.checkLiveness || (() => true);
37
37
  const startedAt = Date.now();
@@ -65,37 +65,53 @@ class RuntimeHttpClient {
65
65
  }
66
66
 
67
67
  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(() => '') };
68
+ const retryCount = Math.max(0, Number(options.retryCount ?? 6));
69
+ const retryDelayMs = Math.max(100, Number(options.retryDelayMs ?? 1000));
70
+ let lastError = null;
85
71
 
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);
72
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
73
+ const controller = options.timeoutMs ? new AbortController() : null;
74
+ const timer = controller ? setTimeout(() => controller.abort(new Error(`Request timed out after ${options.timeoutMs} ms.`)), options.timeoutMs) : null;
75
+ try {
76
+ const response = await fetch(`${this.baseUrl}${pathname}`, {
77
+ method,
78
+ headers: {
79
+ 'content-type': 'application/json',
80
+ ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
81
+ },
82
+ body: body === undefined ? undefined : JSON.stringify(body),
83
+ signal: controller?.signal,
84
+ });
85
+
86
+ const contentType = response.headers.get('content-type') || '';
87
+ const payload = contentType.includes('application/json')
88
+ ? await response.json().catch(() => ({}))
89
+ : { text: await response.text().catch(() => '') };
90
+
91
+ if (!response.ok) {
92
+ const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
93
+ throw new Error(errorMessage);
94
+ }
95
+ if (typeof this.onActivity === 'function') {
96
+ this.onActivity();
97
+ }
98
+ return payload;
99
+ } catch (error) {
100
+ lastError = error;
101
+ const message = String(error?.message || error);
102
+ const retryable = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|timed out/i.test(message);
103
+ if (!retryable || attempt === retryCount) {
104
+ throw error;
105
+ }
106
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
107
+ } finally {
108
+ if (timer) {
109
+ clearTimeout(timer);
110
+ }
97
111
  }
98
112
  }
113
+
114
+ throw lastError || new Error('Runtime request failed.');
99
115
  }
100
116
 
101
117
  async requestStream(method, pathname, stream, options = {}) {
@@ -366,7 +382,7 @@ class LocalVmExecutionBackend {
366
382
  }
367
383
  const session = await this.vmManager.ensureVm(userId);
368
384
  this.#touch(userId);
369
- const client = new RuntimeHttpClient(session.baseUrl, this.token, {
385
+ const client = new RuntimeHttpClient(session.baseUrl, session.guestToken || this.token, {
370
386
  onActivity: () => this.#touch(userId),
371
387
  });
372
388
  try {