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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const http = require('http');
|
|
3
4
|
const https = require('https');
|
|
4
5
|
const net = require('net');
|
|
@@ -8,23 +9,15 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
8
9
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
9
10
|
const { ensureGuestBootstrapSeed } = require('./guest_bootstrap');
|
|
10
11
|
|
|
12
|
+
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
|
11
13
|
const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
|
|
12
|
-
const BASE_IMAGE_CACHE_ROOT = path.join(VM_ROOT, 'base-images');
|
|
13
|
-
const REPO_ROOT = path.resolve(__dirname, '../../../');
|
|
14
|
-
const HOST_SHARE_ROOT = path.join(VM_ROOT, 'host-share');
|
|
15
14
|
fs.mkdirSync(VM_ROOT, { recursive: true });
|
|
16
|
-
fs.mkdirSync(BASE_IMAGE_CACHE_ROOT, { recursive: true });
|
|
17
15
|
|
|
18
16
|
const DEFAULT_UBUNTU_BASE_IMAGE_URLS = Object.freeze({
|
|
19
17
|
x64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img',
|
|
20
18
|
arm64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img',
|
|
21
19
|
});
|
|
22
20
|
|
|
23
|
-
const HOST_SHARE_LINKS = [
|
|
24
|
-
{ name: 'server', source: path.join(REPO_ROOT, 'server') },
|
|
25
|
-
{ name: 'runtime', source: path.join(REPO_ROOT, 'runtime') },
|
|
26
|
-
];
|
|
27
|
-
|
|
28
21
|
const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
29
22
|
path.resolve(process.execPath, '..', '..', 'share', 'qemu'),
|
|
30
23
|
path.resolve(process.execPath, '..', '..', '..', 'share', 'qemu'),
|
|
@@ -34,10 +27,7 @@ const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
|
34
27
|
];
|
|
35
28
|
|
|
36
29
|
function guestArchForHost() {
|
|
37
|
-
|
|
38
|
-
return process.arch;
|
|
39
|
-
}
|
|
40
|
-
return 'x64';
|
|
30
|
+
return process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
function resolveQemuBinary({ arch = guestArchForHost(), platform = process.platform } = {}) {
|
|
@@ -70,6 +60,67 @@ function isHttpUrl(value) {
|
|
|
70
60
|
return /^https?:\/\//i.test(candidate);
|
|
71
61
|
}
|
|
72
62
|
|
|
63
|
+
function generateGuestToken() {
|
|
64
|
+
return crypto.randomBytes(32).toString('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function computeRuntimeTemplateSignature(guestArch, runtimeProfile = 'browser_cli') {
|
|
68
|
+
const hash = crypto.createHash('sha256');
|
|
69
|
+
const normalizedProfile = runtimeProfile === 'android' ? 'android' : 'browser_cli';
|
|
70
|
+
const trackedFiles = normalizedProfile === 'android'
|
|
71
|
+
? [
|
|
72
|
+
'server/guest-agent.android.package.json',
|
|
73
|
+
'server/guest_agent.js',
|
|
74
|
+
'server/services/android/controller.js',
|
|
75
|
+
'server/services/cli/executor.js',
|
|
76
|
+
'server/services/runtime/guest_bootstrap.js',
|
|
77
|
+
'runtime/env.js',
|
|
78
|
+
'runtime/paths.js',
|
|
79
|
+
]
|
|
80
|
+
: [
|
|
81
|
+
'server/guest-agent.browser.package.json',
|
|
82
|
+
'server/guest_agent.js',
|
|
83
|
+
'server/services/browser/controller.js',
|
|
84
|
+
'server/services/cli/executor.js',
|
|
85
|
+
'server/services/runtime/guest_bootstrap.js',
|
|
86
|
+
'runtime/env.js',
|
|
87
|
+
'runtime/paths.js',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
hash.update(String(guestArch || 'x64'));
|
|
91
|
+
hash.update('\0');
|
|
92
|
+
hash.update(normalizedProfile);
|
|
93
|
+
for (const relativePath of trackedFiles) {
|
|
94
|
+
const filePath = path.join(REPO_ROOT, relativePath);
|
|
95
|
+
hash.update('\0');
|
|
96
|
+
hash.update(relativePath);
|
|
97
|
+
hash.update('\0');
|
|
98
|
+
try {
|
|
99
|
+
hash.update(fs.readFileSync(filePath));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
hash.update(`missing:${error?.code || 'unknown'}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return hash.digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveGuestToken(userRoot) {
|
|
109
|
+
const tokenPath = path.join(userRoot, 'guest-token.txt');
|
|
110
|
+
try {
|
|
111
|
+
const existing = String(fs.readFileSync(tokenPath, 'utf8') || '').trim();
|
|
112
|
+
if (existing.length >= 32) {
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
|
|
117
|
+
const candidate = String(process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
|
|
118
|
+
const token = candidate.length >= 32 ? candidate : generateGuestToken();
|
|
119
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
120
|
+
fs.writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
|
|
121
|
+
return token;
|
|
122
|
+
}
|
|
123
|
+
|
|
73
124
|
function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
|
|
74
125
|
return new Promise((resolve, reject) => {
|
|
75
126
|
if (redirectCount > 5) {
|
|
@@ -167,37 +218,6 @@ function resolveQemuShareRoot() {
|
|
|
167
218
|
return null;
|
|
168
219
|
}
|
|
169
220
|
|
|
170
|
-
function ensureHostShareRoot() {
|
|
171
|
-
fs.mkdirSync(HOST_SHARE_ROOT, { recursive: true });
|
|
172
|
-
|
|
173
|
-
for (const entry of HOST_SHARE_LINKS) {
|
|
174
|
-
const sourcePath = path.resolve(entry.source);
|
|
175
|
-
if (!fs.existsSync(sourcePath)) {
|
|
176
|
-
throw new Error(`Host share source is missing: ${sourcePath}`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const linkPath = path.join(HOST_SHARE_ROOT, entry.name);
|
|
180
|
-
let needsLink = true;
|
|
181
|
-
if (fs.existsSync(linkPath)) {
|
|
182
|
-
try {
|
|
183
|
-
const resolved = fs.realpathSync.native ? fs.realpathSync.native(linkPath) : fs.realpathSync(linkPath);
|
|
184
|
-
needsLink = resolved !== sourcePath;
|
|
185
|
-
} catch {
|
|
186
|
-
needsLink = true;
|
|
187
|
-
}
|
|
188
|
-
if (needsLink) {
|
|
189
|
-
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (needsLink) {
|
|
194
|
-
fs.symlinkSync(sourcePath, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return HOST_SHARE_ROOT;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
221
|
function resolveAarch64FirmwarePaths() {
|
|
202
222
|
const shareRoot = resolveQemuShareRoot();
|
|
203
223
|
if (!shareRoot) {
|
|
@@ -261,8 +281,6 @@ function buildQemuArgs({
|
|
|
261
281
|
cpus = 2,
|
|
262
282
|
arch = guestArchForHost(),
|
|
263
283
|
platform = process.platform,
|
|
264
|
-
hostShareRoot = null,
|
|
265
|
-
hostDataRoot = null,
|
|
266
284
|
seedPath = null,
|
|
267
285
|
seedIsRaw = false,
|
|
268
286
|
consoleLogPath = null,
|
|
@@ -281,42 +299,29 @@ function buildQemuArgs({
|
|
|
281
299
|
} else {
|
|
282
300
|
args.push('-machine', `q35,accel=${accel}`);
|
|
283
301
|
if (platform !== 'win32') {
|
|
284
|
-
args.push('-cpu', process.arch === arch ? 'host' : '
|
|
302
|
+
args.push('-cpu', process.arch === arch ? 'host' : 'qemu64');
|
|
285
303
|
}
|
|
286
304
|
}
|
|
287
305
|
|
|
306
|
+
const isMmio = arch === 'arm64';
|
|
307
|
+
const blkDev = isMmio ? 'virtio-blk-device' : 'virtio-blk-pci';
|
|
308
|
+
const netDev = isMmio ? 'virtio-net-device' : 'virtio-net-pci';
|
|
309
|
+
const p9Dev = isMmio ? 'virtio-9p-device' : 'virtio-9p-pci';
|
|
310
|
+
|
|
288
311
|
// OS disk — always first boot candidate
|
|
289
312
|
args.push(
|
|
290
313
|
'-drive', `if=none,id=os,file=${imagePath},format=qcow2`,
|
|
291
|
-
'-device',
|
|
314
|
+
'-device', `${blkDev},drive=os,bootindex=1`,
|
|
292
315
|
'-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22,hostfwd=tcp:127.0.0.1:${agentPort}-:8421`,
|
|
293
|
-
'-device',
|
|
316
|
+
'-device', `${netDev},netdev=net0`,
|
|
294
317
|
);
|
|
295
318
|
|
|
296
|
-
if (hostShareRoot) {
|
|
297
|
-
const id = 'fsdev-host';
|
|
298
|
-
const deviceType = arch === 'arm64' ? 'virtio-9p-pci,disable-legacy=on,disable-modern=off,romfile=' : 'virtio-9p-pci';
|
|
299
|
-
args.push(
|
|
300
|
-
'-fsdev', `local,path=${hostShareRoot},id=${id},security_model=none,readonly=on`,
|
|
301
|
-
'-device', `${deviceType},fsdev=${id},mount_tag=neoagent-host`,
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (hostDataRoot) {
|
|
306
|
-
const id = 'fsdev-data';
|
|
307
|
-
const deviceType = arch === 'arm64' ? 'virtio-9p-pci,disable-legacy=on,disable-modern=off,romfile=' : 'virtio-9p-pci';
|
|
308
|
-
args.push(
|
|
309
|
-
'-fsdev', `local,path=${hostDataRoot},id=${id},security_model=none`,
|
|
310
|
-
'-device', `${deviceType},fsdev=${id},mount_tag=neoagent-data`,
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
319
|
if (seedPath) {
|
|
315
320
|
if (seedIsRaw) {
|
|
316
321
|
// Raw FAT image — attach as a plain virtio block device
|
|
317
322
|
args.push(
|
|
318
323
|
'-drive', `if=none,id=cidata,file=${seedPath},format=raw,readonly=on`,
|
|
319
|
-
'-device',
|
|
324
|
+
'-device', `${blkDev},drive=cidata`,
|
|
320
325
|
);
|
|
321
326
|
} else if (arch === 'arm64') {
|
|
322
327
|
// ARM virt machine has no IDE controller; use virtio-scsi for the seed ISO
|
|
@@ -388,6 +393,135 @@ function allocatePort() {
|
|
|
388
393
|
});
|
|
389
394
|
}
|
|
390
395
|
|
|
396
|
+
function sleep(ms) {
|
|
397
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function waitForPath(targetPath, timeoutMs, intervalMs = 1000) {
|
|
401
|
+
const startedAt = Date.now();
|
|
402
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
403
|
+
if (fs.existsSync(targetPath)) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
await sleep(intervalMs);
|
|
407
|
+
}
|
|
408
|
+
return fs.existsSync(targetPath);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function writeLockMetadata(lockDir) {
|
|
412
|
+
try {
|
|
413
|
+
fs.writeFileSync(
|
|
414
|
+
path.join(lockDir, 'owner.json'),
|
|
415
|
+
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
|
416
|
+
'utf8',
|
|
417
|
+
);
|
|
418
|
+
} catch {}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readLockMetadata(lockDir) {
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(fs.readFileSync(path.join(lockDir, 'owner.json'), 'utf8'));
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function isPidAlive(pid) {
|
|
430
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
process.kill(pid, 0);
|
|
435
|
+
return true;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function requestGuestAgent(baseUrl, token, pathname, body, options = {}) {
|
|
442
|
+
const controller = new AbortController();
|
|
443
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 5000));
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
controller.abort(new Error(`Request timed out after ${timeoutMs} ms.`));
|
|
446
|
+
}, timeoutMs);
|
|
447
|
+
try {
|
|
448
|
+
const response = await fetch(`${String(baseUrl || '').replace(/\/+$/, '')}${pathname}`, {
|
|
449
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
450
|
+
headers: {
|
|
451
|
+
'content-type': 'application/json',
|
|
452
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
453
|
+
},
|
|
454
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
455
|
+
signal: controller.signal,
|
|
456
|
+
});
|
|
457
|
+
const contentType = response.headers.get('content-type') || '';
|
|
458
|
+
const payload = contentType.includes('application/json')
|
|
459
|
+
? await response.json().catch(() => ({}))
|
|
460
|
+
: { text: await response.text().catch(() => '') };
|
|
461
|
+
if (!response.ok) {
|
|
462
|
+
const detail = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
|
|
463
|
+
throw new Error(detail);
|
|
464
|
+
}
|
|
465
|
+
return payload;
|
|
466
|
+
} finally {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function waitForGuestAgentHealth(baseUrl, token, options = {}) {
|
|
472
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 5 * 60 * 1000));
|
|
473
|
+
const intervalMs = Math.max(250, Number(options.intervalMs || 1000));
|
|
474
|
+
const checkLiveness = options.checkLiveness || (() => true);
|
|
475
|
+
const startedAt = Date.now();
|
|
476
|
+
let lastError = null;
|
|
477
|
+
|
|
478
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
479
|
+
if (!checkLiveness()) {
|
|
480
|
+
throw new Error('Guest runtime process exited unexpectedly during bootstrap.');
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const health = await requestGuestAgent(baseUrl, token, '/health', undefined, { timeoutMs: 2000 });
|
|
484
|
+
if (health?.status === 'ok') {
|
|
485
|
+
return health;
|
|
486
|
+
}
|
|
487
|
+
lastError = new Error('Guest agent health check returned a non-ok status.');
|
|
488
|
+
} catch (error) {
|
|
489
|
+
lastError = error;
|
|
490
|
+
}
|
|
491
|
+
await sleep(intervalMs);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
throw new Error(`Timed out waiting for guest agent health: ${lastError?.message || 'unknown error'}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function waitForGuestMarker(baseUrl, token, markerPath, options = {}) {
|
|
498
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 15 * 60 * 1000));
|
|
499
|
+
const intervalMs = Math.max(250, Number(options.intervalMs || 2000));
|
|
500
|
+
const checkLiveness = options.checkLiveness || (() => true);
|
|
501
|
+
const startedAt = Date.now();
|
|
502
|
+
let lastError = null;
|
|
503
|
+
|
|
504
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
505
|
+
if (!checkLiveness()) {
|
|
506
|
+
throw new Error('Guest runtime process exited unexpectedly while waiting for guest marker.');
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const result = await requestGuestAgent(baseUrl, token, '/exec', {
|
|
510
|
+
command: `test -f ${JSON.stringify(String(markerPath || ''))} && printf ready || printf pending`,
|
|
511
|
+
timeout: 15000,
|
|
512
|
+
}, { timeoutMs: 20000 });
|
|
513
|
+
if (String(result?.stdout || '').trim() === 'ready') {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
} catch (error) {
|
|
517
|
+
lastError = error;
|
|
518
|
+
}
|
|
519
|
+
await sleep(intervalMs);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
throw new Error(`Timed out waiting for guest marker ${markerPath}: ${lastError?.message || 'unknown error'}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
391
525
|
function ensureUserVmDisk(userRoot, baseImagePath) {
|
|
392
526
|
fs.mkdirSync(userRoot, { recursive: true });
|
|
393
527
|
const diskPath = path.join(userRoot, 'disk.qcow2');
|
|
@@ -455,9 +589,22 @@ function formatReadinessIssues(readiness) {
|
|
|
455
589
|
return issues.length > 0 ? issues : ['VM runtime is unavailable on this host.'];
|
|
456
590
|
}
|
|
457
591
|
|
|
592
|
+
function readTemplateReadyMetadata(readySentinelPath) {
|
|
593
|
+
try {
|
|
594
|
+
const parsed = JSON.parse(fs.readFileSync(readySentinelPath, 'utf8'));
|
|
595
|
+
if (parsed && typeof parsed === 'object') {
|
|
596
|
+
return parsed;
|
|
597
|
+
}
|
|
598
|
+
} catch {}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
458
602
|
class QemuVmManager {
|
|
459
603
|
constructor(options = {}) {
|
|
460
|
-
this.
|
|
604
|
+
this.runtimeProfile = options.runtimeProfile === 'android' ? 'android' : 'browser_cli';
|
|
605
|
+
this.rootDir = path.resolve(options.rootDir || path.join(VM_ROOT, this.runtimeProfile));
|
|
606
|
+
this.baseImageCacheRoot = path.resolve(options.baseImageCacheRoot || path.join(this.rootDir, 'base-images'));
|
|
607
|
+
this.templateRootDir = path.resolve(options.templateRootDir || path.join(this.rootDir, 'templates'));
|
|
461
608
|
this.baseImagePath = options.baseImagePath || process.env.NEOAGENT_VM_BASE_IMAGE || '';
|
|
462
609
|
this.guestArch = options.guestArch || guestArchForHost();
|
|
463
610
|
this.baseImageUrl = normalizeBaseImageUrlForArch(
|
|
@@ -468,7 +615,18 @@ class QemuVmManager {
|
|
|
468
615
|
this.cpus = Number(options.cpus || process.env.NEOAGENT_VM_CPUS || 2);
|
|
469
616
|
this.instances = new Map();
|
|
470
617
|
this.baseImagePromise = null;
|
|
618
|
+
this.runtimeTemplatePromise = null;
|
|
619
|
+
this.warmupEnabled = options.warmup === true;
|
|
471
620
|
fs.mkdirSync(this.rootDir, { recursive: true });
|
|
621
|
+
fs.mkdirSync(this.baseImageCacheRoot, { recursive: true });
|
|
622
|
+
fs.mkdirSync(this.templateRootDir, { recursive: true });
|
|
623
|
+
if (this.warmupEnabled) {
|
|
624
|
+
setTimeout(() => {
|
|
625
|
+
this.ensureRuntimeTemplateAvailable().catch((error) => {
|
|
626
|
+
console.warn(`[VM:${this.runtimeProfile}] Background runtime template warmup failed: ${error.message}`);
|
|
627
|
+
});
|
|
628
|
+
}, 0);
|
|
629
|
+
}
|
|
472
630
|
}
|
|
473
631
|
|
|
474
632
|
getBaseImageCachePath() {
|
|
@@ -477,7 +635,7 @@ class QemuVmManager {
|
|
|
477
635
|
}
|
|
478
636
|
const parsed = new URL(this.baseImageUrl);
|
|
479
637
|
const filename = path.basename(parsed.pathname || '') || `${this.guestArch}-base.img`;
|
|
480
|
-
return path.join(
|
|
638
|
+
return path.join(this.baseImageCacheRoot, filename);
|
|
481
639
|
}
|
|
482
640
|
|
|
483
641
|
resolveBaseImagePath() {
|
|
@@ -516,6 +674,217 @@ class QemuVmManager {
|
|
|
516
674
|
return this.baseImagePromise;
|
|
517
675
|
}
|
|
518
676
|
|
|
677
|
+
getRuntimeTemplateRoot() {
|
|
678
|
+
return path.join(this.templateRootDir, this.guestArch);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
getRuntimeTemplateDiskPath() {
|
|
682
|
+
return path.join(this.getRuntimeTemplateRoot(), 'disk.qcow2');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
getRuntimeTemplateLockDir() {
|
|
686
|
+
return `${this.getRuntimeTemplateRoot()}.lock`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
getRuntimeTemplateReadyMarker() {
|
|
690
|
+
return this.runtimeProfile === 'android'
|
|
691
|
+
? '/var/lib/neoagent/bootstrap-complete'
|
|
692
|
+
: '/var/lib/neoagent/browser-runtime-ready';
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
getRuntimeTemplateSignature() {
|
|
696
|
+
return computeRuntimeTemplateSignature(this.guestArch, this.runtimeProfile);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async ensureRuntimeTemplateAvailable() {
|
|
700
|
+
const readyDiskPath = this.getRuntimeTemplateDiskPath();
|
|
701
|
+
const readySentinelPath = path.join(this.getRuntimeTemplateRoot(), '.runtime-template-ready');
|
|
702
|
+
const readyMetadata = readTemplateReadyMetadata(readySentinelPath);
|
|
703
|
+
if (
|
|
704
|
+
fs.existsSync(readyDiskPath)
|
|
705
|
+
&& readyMetadata
|
|
706
|
+
&& readyMetadata.signature === this.getRuntimeTemplateSignature()
|
|
707
|
+
) {
|
|
708
|
+
return readyDiskPath;
|
|
709
|
+
}
|
|
710
|
+
if (!this.runtimeTemplatePromise) {
|
|
711
|
+
this.runtimeTemplatePromise = this.#ensureRuntimeTemplateAvailableWithLock().finally(() => {
|
|
712
|
+
this.runtimeTemplatePromise = null;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return this.runtimeTemplatePromise;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async #ensureRuntimeTemplateAvailableWithLock() {
|
|
719
|
+
const readyDiskPath = this.getRuntimeTemplateDiskPath();
|
|
720
|
+
const readySentinelPath = path.join(this.getRuntimeTemplateRoot(), '.runtime-template-ready');
|
|
721
|
+
const lockDir = this.getRuntimeTemplateLockDir();
|
|
722
|
+
const acquireStartedAt = Date.now();
|
|
723
|
+
const expectedSignature = this.getRuntimeTemplateSignature();
|
|
724
|
+
|
|
725
|
+
while (true) {
|
|
726
|
+
const readyMetadata = readTemplateReadyMetadata(readySentinelPath);
|
|
727
|
+
if (
|
|
728
|
+
fs.existsSync(readyDiskPath)
|
|
729
|
+
&& readyMetadata
|
|
730
|
+
&& readyMetadata.signature === expectedSignature
|
|
731
|
+
) {
|
|
732
|
+
return readyDiskPath;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
737
|
+
writeLockMetadata(lockDir);
|
|
738
|
+
try {
|
|
739
|
+
if (fs.existsSync(readyDiskPath) && fs.existsSync(readySentinelPath)) {
|
|
740
|
+
return readyDiskPath;
|
|
741
|
+
}
|
|
742
|
+
return await this.#buildRuntimeTemplate();
|
|
743
|
+
} finally {
|
|
744
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
745
|
+
}
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error?.code !== 'EEXIST') {
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const lockStats = fs.existsSync(lockDir) ? fs.statSync(lockDir) : null;
|
|
753
|
+
const lockMetadata = readLockMetadata(lockDir);
|
|
754
|
+
const lockAgeMs = lockStats ? Date.now() - lockStats.mtimeMs : 0;
|
|
755
|
+
const staleLock = lockAgeMs > 45 * 60 * 1000 || (lockMetadata?.pid && !isPidAlive(lockMetadata.pid));
|
|
756
|
+
if (staleLock) {
|
|
757
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (Date.now() - acquireStartedAt > 30 * 60 * 1000) {
|
|
762
|
+
throw new Error('Timed out waiting for the shared runtime template build lock.');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
await sleep(2000);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async #buildRuntimeTemplate() {
|
|
770
|
+
const templateRoot = this.getRuntimeTemplateRoot();
|
|
771
|
+
const templateDiskPath = this.getRuntimeTemplateDiskPath();
|
|
772
|
+
const readyMarkerPath = this.getRuntimeTemplateReadyMarker();
|
|
773
|
+
const readySentinelPath = path.join(templateRoot, '.runtime-template-ready');
|
|
774
|
+
const templateSignature = this.getRuntimeTemplateSignature();
|
|
775
|
+
|
|
776
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
777
|
+
|
|
778
|
+
const baseImagePath = await this.ensureBaseImageAvailable();
|
|
779
|
+
const diskPath = ensureUserVmDisk(templateRoot, baseImagePath);
|
|
780
|
+
const guestToken = resolveGuestToken(templateRoot);
|
|
781
|
+
const bootstrap = ensureGuestBootstrapSeed({
|
|
782
|
+
userRoot: templateRoot,
|
|
783
|
+
guestToken,
|
|
784
|
+
guestArch: this.guestArch,
|
|
785
|
+
runtimeMode: 'template',
|
|
786
|
+
runtimeProfile: this.runtimeProfile,
|
|
787
|
+
});
|
|
788
|
+
const consoleLogPath = path.join(templateRoot, 'console.log');
|
|
789
|
+
const firmware = this.guestArch === 'arm64'
|
|
790
|
+
? resolveAarch64FirmwarePaths()
|
|
791
|
+
: resolveX86_64FirmwarePaths();
|
|
792
|
+
const firmwareVarsPath = firmware ? path.join(templateRoot, 'uefi-vars.fd') : null;
|
|
793
|
+
if (firmware && !fs.existsSync(firmwareVarsPath)) {
|
|
794
|
+
fs.copyFileSync(firmware.varsTemplatePath, firmwareVarsPath);
|
|
795
|
+
}
|
|
796
|
+
const agentPort = await allocatePort();
|
|
797
|
+
const sshPort = await allocatePort();
|
|
798
|
+
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
799
|
+
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
800
|
+
const args = buildQemuArgs({
|
|
801
|
+
imagePath: diskPath,
|
|
802
|
+
sshPort,
|
|
803
|
+
agentPort,
|
|
804
|
+
memoryMb: this.memoryMb,
|
|
805
|
+
cpus: this.cpus,
|
|
806
|
+
arch: this.guestArch,
|
|
807
|
+
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
808
|
+
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
809
|
+
consoleLogPath,
|
|
810
|
+
firmwareCodePath: firmware?.codePath || null,
|
|
811
|
+
firmwareVarsPath,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
console.log(`[VM:${this.runtimeProfile}] Building runtime template for ${this.guestArch}: ${qemuBinaryPath} ${args.join(' ')}`);
|
|
815
|
+
const child = spawn(qemuBinaryPath, args, {
|
|
816
|
+
cwd: templateRoot,
|
|
817
|
+
detached: process.platform !== 'win32',
|
|
818
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
let stderrText = '';
|
|
822
|
+
child.stderr.on('data', (chunk) => {
|
|
823
|
+
stderrText += chunk.toString('utf8');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const baseUrl = `http://127.0.0.1:${agentPort}`;
|
|
827
|
+
const checkLiveness = () => isPidAlive(child.pid);
|
|
828
|
+
try {
|
|
829
|
+
await waitForGuestAgentHealth(baseUrl, guestToken, {
|
|
830
|
+
timeoutMs: 30 * 60 * 1000,
|
|
831
|
+
intervalMs: 1000,
|
|
832
|
+
checkLiveness,
|
|
833
|
+
});
|
|
834
|
+
await waitForGuestMarker(baseUrl, guestToken, readyMarkerPath, {
|
|
835
|
+
timeoutMs: 45 * 60 * 1000,
|
|
836
|
+
intervalMs: 2000,
|
|
837
|
+
checkLiveness,
|
|
838
|
+
});
|
|
839
|
+
try {
|
|
840
|
+
await requestGuestAgent(baseUrl, guestToken, '/exec', {
|
|
841
|
+
command: [
|
|
842
|
+
'cloud-init clean --logs --seed --machine-id || true',
|
|
843
|
+
'rm -rf /var/lib/cloud/instances/* /var/lib/cloud/seed/* || true',
|
|
844
|
+
'rm -f /var/lib/neoagent/bootstrap-complete /var/lib/neoagent/browser-runtime-ready || true',
|
|
845
|
+
'rm -f /var/lib/systemd/random-seed || true',
|
|
846
|
+
'truncate -s 0 /etc/machine-id || true',
|
|
847
|
+
'sync',
|
|
848
|
+
].join('; '),
|
|
849
|
+
timeout: 120000,
|
|
850
|
+
}, { timeoutMs: 120000 });
|
|
851
|
+
} catch (cleanupError) {
|
|
852
|
+
console.warn(`[VM:${this.runtimeProfile}] Template cleanup after bootstrap failed: ${cleanupError.message}`);
|
|
853
|
+
}
|
|
854
|
+
fs.writeFileSync(
|
|
855
|
+
readySentinelPath,
|
|
856
|
+
JSON.stringify({
|
|
857
|
+
signature: templateSignature,
|
|
858
|
+
runtimeProfile: this.runtimeProfile,
|
|
859
|
+
guestArch: this.guestArch,
|
|
860
|
+
builtAt: new Date().toISOString(),
|
|
861
|
+
}, null, 2),
|
|
862
|
+
'utf8',
|
|
863
|
+
);
|
|
864
|
+
} finally {
|
|
865
|
+
try {
|
|
866
|
+
if (process.platform === 'win32') {
|
|
867
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', child.pid]);
|
|
868
|
+
} else {
|
|
869
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
870
|
+
}
|
|
871
|
+
} catch {
|
|
872
|
+
try {
|
|
873
|
+
child.kill('SIGKILL');
|
|
874
|
+
} catch {}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!fs.existsSync(templateDiskPath)) {
|
|
879
|
+
throw new Error('Runtime template disk was not created.');
|
|
880
|
+
}
|
|
881
|
+
return templateDiskPath;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async ensureRuntimeImageAvailable() {
|
|
885
|
+
return this.ensureRuntimeTemplateAvailable();
|
|
886
|
+
}
|
|
887
|
+
|
|
519
888
|
isConfigured() {
|
|
520
889
|
return this.getReadiness().ready;
|
|
521
890
|
}
|
|
@@ -564,18 +933,16 @@ class QemuVmManager {
|
|
|
564
933
|
}
|
|
565
934
|
|
|
566
935
|
const userRoot = path.join(this.rootDir, key, this.guestArch);
|
|
567
|
-
const baseImagePath = await this.
|
|
936
|
+
const baseImagePath = await this.ensureRuntimeImageAvailable();
|
|
568
937
|
const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
|
|
569
|
-
const guestToken =
|
|
570
|
-
if (!guestToken) {
|
|
571
|
-
throw new Error('NEOAGENT_VM_GUEST_TOKEN is required to bootstrap the guest runtime.');
|
|
572
|
-
}
|
|
938
|
+
const guestToken = resolveGuestToken(userRoot);
|
|
573
939
|
const bootstrap = ensureGuestBootstrapSeed({
|
|
574
940
|
userRoot,
|
|
575
941
|
guestToken,
|
|
576
942
|
guestArch: this.guestArch,
|
|
943
|
+
runtimeMode: 'user',
|
|
944
|
+
runtimeProfile: this.runtimeProfile,
|
|
577
945
|
});
|
|
578
|
-
const guestDataRoot = path.join(userRoot, 'guest-data');
|
|
579
946
|
const consoleLogPath = path.join(userRoot, 'console.log');
|
|
580
947
|
const firmware = this.guestArch === 'arm64'
|
|
581
948
|
? resolveAarch64FirmwarePaths()
|
|
@@ -597,12 +964,10 @@ class QemuVmManager {
|
|
|
597
964
|
throw new Error(`Failed to copy firmware vars template: ${detail}`);
|
|
598
965
|
}
|
|
599
966
|
}
|
|
600
|
-
fs.mkdirSync(guestDataRoot, { recursive: true });
|
|
601
967
|
const agentPort = await allocatePort();
|
|
602
968
|
const sshPort = await allocatePort();
|
|
603
969
|
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
604
970
|
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
605
|
-
const hostShareRoot = ensureHostShareRoot();
|
|
606
971
|
const args = buildQemuArgs({
|
|
607
972
|
imagePath: diskPath,
|
|
608
973
|
sshPort,
|
|
@@ -610,8 +975,6 @@ class QemuVmManager {
|
|
|
610
975
|
memoryMb: this.memoryMb,
|
|
611
976
|
cpus: this.cpus,
|
|
612
977
|
arch: this.guestArch,
|
|
613
|
-
hostShareRoot,
|
|
614
|
-
hostDataRoot: guestDataRoot,
|
|
615
978
|
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
616
979
|
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
617
980
|
consoleLogPath,
|
|
@@ -619,7 +982,7 @@ class QemuVmManager {
|
|
|
619
982
|
firmwareVarsPath,
|
|
620
983
|
});
|
|
621
984
|
|
|
622
|
-
console.log(`[VM] Starting QEMU for user ${key} (${this.guestArch}): ${qemuBinaryPath} ${args.join(' ')}`);
|
|
985
|
+
console.log(`[VM:${this.runtimeProfile}] Starting QEMU for user ${key} (${this.guestArch}): ${qemuBinaryPath} ${args.join(' ')}`);
|
|
623
986
|
const child = spawn(qemuBinaryPath, args, {
|
|
624
987
|
cwd: userRoot,
|
|
625
988
|
detached: process.platform !== 'win32',
|
|
@@ -656,17 +1019,22 @@ class QemuVmManager {
|
|
|
656
1019
|
}
|
|
657
1020
|
});
|
|
658
1021
|
child.on('exit', () => {
|
|
659
|
-
this.instances.
|
|
1022
|
+
const current = this.instances.get(key);
|
|
1023
|
+
if (current?.process === child) {
|
|
1024
|
+
this.instances.delete(key);
|
|
1025
|
+
}
|
|
660
1026
|
});
|
|
661
1027
|
|
|
662
1028
|
const session = {
|
|
663
1029
|
userId: key,
|
|
1030
|
+
runtimeProfile: this.runtimeProfile,
|
|
664
1031
|
process: child,
|
|
665
1032
|
qemuBinary,
|
|
666
1033
|
qemuArgs: args,
|
|
667
1034
|
guestArch: this.guestArch,
|
|
668
1035
|
userRoot,
|
|
669
1036
|
diskPath,
|
|
1037
|
+
guestToken,
|
|
670
1038
|
agentPort,
|
|
671
1039
|
sshPort,
|
|
672
1040
|
baseUrl: `http://127.0.0.1:${agentPort}`,
|
|
@@ -687,12 +1055,36 @@ class QemuVmManager {
|
|
|
687
1055
|
if (!session) return;
|
|
688
1056
|
|
|
689
1057
|
try {
|
|
1058
|
+
try {
|
|
1059
|
+
await requestGuestAgent(session.baseUrl, session.guestToken, '/browser/close', {}, { timeoutMs: 10000 });
|
|
1060
|
+
} catch {}
|
|
1061
|
+
try {
|
|
1062
|
+
await requestGuestAgent(session.baseUrl, session.guestToken, '/android/stop', {}, { timeoutMs: 10000 });
|
|
1063
|
+
} catch {}
|
|
1064
|
+
try {
|
|
1065
|
+
await requestGuestAgent(session.baseUrl, session.guestToken, '/exec', {
|
|
1066
|
+
command: 'sync || true',
|
|
1067
|
+
timeout: 15000,
|
|
1068
|
+
}, { timeoutMs: 20000 });
|
|
1069
|
+
} catch {}
|
|
1070
|
+
|
|
690
1071
|
if (process.platform === 'win32') {
|
|
691
|
-
spawnSync('taskkill', ['/
|
|
1072
|
+
spawnSync('taskkill', ['/T', '/PID', session.process.pid]);
|
|
692
1073
|
} else {
|
|
693
|
-
process.kill(-session.process.pid, '
|
|
1074
|
+
process.kill(-session.process.pid, 'SIGTERM');
|
|
1075
|
+
}
|
|
1076
|
+
const shutdownStartedAt = Date.now();
|
|
1077
|
+
while (isPidAlive(session.process.pid) && Date.now() - shutdownStartedAt < 10000) {
|
|
1078
|
+
await sleep(250);
|
|
1079
|
+
}
|
|
1080
|
+
if (isPidAlive(session.process.pid)) {
|
|
1081
|
+
if (process.platform === 'win32') {
|
|
1082
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', session.process.pid]);
|
|
1083
|
+
} else {
|
|
1084
|
+
process.kill(-session.process.pid, 'SIGKILL');
|
|
1085
|
+
}
|
|
694
1086
|
}
|
|
695
|
-
} catch
|
|
1087
|
+
} catch {
|
|
696
1088
|
try {
|
|
697
1089
|
session.process.kill('SIGKILL');
|
|
698
1090
|
} catch {}
|
|
@@ -712,7 +1104,6 @@ class QemuVmManager {
|
|
|
712
1104
|
}
|
|
713
1105
|
|
|
714
1106
|
module.exports = {
|
|
715
|
-
BASE_IMAGE_CACHE_ROOT,
|
|
716
1107
|
DEFAULT_UBUNTU_BASE_IMAGE_URLS,
|
|
717
1108
|
QemuVmManager,
|
|
718
1109
|
VM_ROOT,
|