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.
- package/package.json +1 -1
- package/runtime/paths.js +6 -6
- package/server/guest-agent.android.package.json +13 -0
- package/server/guest-agent.browser.package.json +14 -0
- package/server/guest_agent.js +61 -51
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/android.js +2 -11
- package/server/routes/browser.js +2 -2
- package/server/services/ai/capabilityHealth.js +6 -14
- package/server/services/android/android_bootstrap_worker.js +2 -2
- package/server/services/android/controller.js +529 -133
- package/server/services/browser/controller.js +187 -42
- package/server/services/runtime/backends/local-vm.js +62 -33
- package/server/services/runtime/guest_bootstrap.js +287 -113
- package/server/services/runtime/manager.js +53 -15
- package/server/services/runtime/qemu.js +477 -86
- package/server/services/runtime/settings.js +9 -14
- package/server/services/runtime/validation.js +11 -38
- package/server/utils/deployment.js +4 -4
- package/server/guest-agent.package.json +0 -16
|
@@ -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
|
|
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
|
|
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 ||
|
|
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
|
|
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 =
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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.
|
|
245
|
-
await
|
|
316
|
+
if (this.launchPromise) {
|
|
317
|
+
await this.launchPromise;
|
|
246
318
|
return;
|
|
247
319
|
}
|
|
248
320
|
|
|
249
321
|
this.launching = true;
|
|
250
|
-
|
|
251
|
-
const
|
|
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 =
|
|
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(
|
|
349
|
+
throw new Error(`No ${this.engine} executable found for the VM browser runtime.`);
|
|
271
350
|
}
|
|
272
351
|
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
69
|
-
const
|
|
70
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 &&
|
|
407
|
+
return Boolean(session && session.process && isPidAlive(session.process.pid));
|
|
379
408
|
},
|
|
380
409
|
});
|
|
381
410
|
} catch (error) {
|