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.
@@ -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
- if (process.arch === 'arm64' || process.arch === 'x64') {
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' : 'max');
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', 'virtio-blk-pci,drive=os,bootindex=1',
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', 'virtio-net-pci,netdev=net0',
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', 'virtio-blk-pci,drive=cidata',
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.rootDir = path.resolve(options.rootDir || VM_ROOT);
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(BASE_IMAGE_CACHE_ROOT, filename);
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.ensureBaseImageAvailable();
936
+ const baseImagePath = await this.ensureRuntimeImageAvailable();
568
937
  const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
569
- const guestToken = String(process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
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.delete(key);
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', ['/F', '/T', '/PID', session.process.pid]);
1072
+ spawnSync('taskkill', ['/T', '/PID', session.process.pid]);
692
1073
  } else {
693
- process.kill(-session.process.pid, 'SIGKILL');
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 (err) {
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,