neoagent 2.3.1-beta.70 → 2.3.1-beta.72
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/docs/configuration.md +2 -0
- package/docs/getting-started.md +17 -0
- package/package.json +1 -1
- package/runtime/paths.js +4 -3
- package/server/guest-agent.README.md +8 -0
- package/server/guest-agent.package.json +16 -0
- package/server/guest_agent.js +13 -1
- package/server/index.js +11 -2
- 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 +30 -1
- package/server/routes/browser.js +29 -0
- package/server/services/android/android_bootstrap_worker.js +47 -0
- package/server/services/android/controller.js +297 -57
- package/server/services/browser/controller.js +65 -4
- package/server/services/cli/executor.js +36 -1
- package/server/services/runtime/backends/local-vm.js +97 -20
- package/server/services/runtime/guest_bootstrap.js +450 -0
- package/server/services/runtime/manager.js +11 -0
- package/server/services/runtime/qemu.js +328 -41
- package/server/services/runtime/validation.js +0 -3
|
@@ -6,9 +6,12 @@ const path = require('path');
|
|
|
6
6
|
const { StringDecoder } = require('string_decoder');
|
|
7
7
|
const { spawn, spawnSync } = require('child_process');
|
|
8
8
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
9
|
+
const { ensureGuestBootstrapSeed } = require('./guest_bootstrap');
|
|
9
10
|
|
|
10
11
|
const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
|
|
11
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');
|
|
12
15
|
fs.mkdirSync(VM_ROOT, { recursive: true });
|
|
13
16
|
fs.mkdirSync(BASE_IMAGE_CACHE_ROOT, { recursive: true });
|
|
14
17
|
|
|
@@ -17,8 +20,21 @@ const DEFAULT_UBUNTU_BASE_IMAGE_URLS = Object.freeze({
|
|
|
17
20
|
arm64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img',
|
|
18
21
|
});
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
29
|
+
path.resolve(process.execPath, '..', '..', 'share', 'qemu'),
|
|
30
|
+
path.resolve(process.execPath, '..', '..', '..', 'share', 'qemu'),
|
|
31
|
+
'/opt/homebrew/share/qemu',
|
|
32
|
+
'/usr/local/share/qemu',
|
|
33
|
+
'/usr/share/qemu',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function guestArchForHost() {
|
|
37
|
+
return 'x64';
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
function resolveQemuBinary({ arch = guestArchForHost(), platform = process.platform } = {}) {
|
|
@@ -32,6 +48,20 @@ function defaultBaseImageUrlForArch(arch = guestArchForHost()) {
|
|
|
32
48
|
return DEFAULT_UBUNTU_BASE_IMAGE_URLS[arch] || DEFAULT_UBUNTU_BASE_IMAGE_URLS.x64;
|
|
33
49
|
}
|
|
34
50
|
|
|
51
|
+
function normalizeBaseImageUrlForArch(baseImageUrl, arch = guestArchForHost()) {
|
|
52
|
+
const candidate = String(baseImageUrl || '').trim();
|
|
53
|
+
if (!candidate) {
|
|
54
|
+
return defaultBaseImageUrlForArch(arch);
|
|
55
|
+
}
|
|
56
|
+
if (arch === 'x64' && /arm64|aarch64/i.test(candidate)) {
|
|
57
|
+
return defaultBaseImageUrlForArch('x64');
|
|
58
|
+
}
|
|
59
|
+
if (arch === 'arm64' && /amd64|x86_64/i.test(candidate)) {
|
|
60
|
+
return defaultBaseImageUrlForArch('arm64');
|
|
61
|
+
}
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
function isHttpUrl(value) {
|
|
36
66
|
const candidate = String(value || '').trim();
|
|
37
67
|
return /^https?:\/\//i.test(candidate);
|
|
@@ -103,7 +133,7 @@ function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
|
|
|
103
133
|
});
|
|
104
134
|
});
|
|
105
135
|
|
|
106
|
-
request.setTimeout(
|
|
136
|
+
request.setTimeout(15 * 60 * 1000, () => {
|
|
107
137
|
request.destroy(new Error('Timed out while downloading VM base image.'));
|
|
108
138
|
});
|
|
109
139
|
request.on('timeout', () => {
|
|
@@ -118,13 +148,108 @@ function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
|
|
|
118
148
|
});
|
|
119
149
|
}
|
|
120
150
|
|
|
121
|
-
function resolveAcceleration({ platform = process.platform } = {}) {
|
|
122
|
-
if (platform === 'linux') return 'kvm';
|
|
123
|
-
if (platform === 'darwin') return 'hvf';
|
|
124
|
-
if (platform === 'win32') return 'whpx';
|
|
151
|
+
function resolveAcceleration({ platform = process.platform, arch = guestArchForHost() } = {}) {
|
|
152
|
+
if (platform === 'linux') return arch === process.arch ? 'kvm' : 'tcg';
|
|
153
|
+
if (platform === 'darwin') return arch === process.arch ? 'hvf' : 'tcg';
|
|
154
|
+
if (platform === 'win32') return arch === process.arch ? 'whpx' : 'tcg';
|
|
125
155
|
return 'tcg';
|
|
126
156
|
}
|
|
127
157
|
|
|
158
|
+
function resolveQemuShareRoot() {
|
|
159
|
+
for (const candidate of QEMU_SHARE_ROOT_CANDIDATES) {
|
|
160
|
+
if (candidate && fs.existsSync(candidate)) {
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ensureHostShareRoot() {
|
|
168
|
+
fs.mkdirSync(HOST_SHARE_ROOT, { recursive: true });
|
|
169
|
+
|
|
170
|
+
for (const entry of HOST_SHARE_LINKS) {
|
|
171
|
+
const sourcePath = path.resolve(entry.source);
|
|
172
|
+
if (!fs.existsSync(sourcePath)) {
|
|
173
|
+
throw new Error(`Host share source is missing: ${sourcePath}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const linkPath = path.join(HOST_SHARE_ROOT, entry.name);
|
|
177
|
+
let needsLink = true;
|
|
178
|
+
if (fs.existsSync(linkPath)) {
|
|
179
|
+
try {
|
|
180
|
+
const resolved = fs.realpathSync.native ? fs.realpathSync.native(linkPath) : fs.realpathSync(linkPath);
|
|
181
|
+
needsLink = resolved !== sourcePath;
|
|
182
|
+
} catch {
|
|
183
|
+
needsLink = true;
|
|
184
|
+
}
|
|
185
|
+
if (needsLink) {
|
|
186
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (needsLink) {
|
|
191
|
+
fs.symlinkSync(sourcePath, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return HOST_SHARE_ROOT;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveAarch64FirmwarePaths() {
|
|
199
|
+
const shareRoot = resolveQemuShareRoot();
|
|
200
|
+
if (!shareRoot) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const codePath = path.join(shareRoot, 'edk2-aarch64-code.fd');
|
|
205
|
+
const varsTemplatePathCandidates = [
|
|
206
|
+
path.join(shareRoot, 'edk2-aarch64-vars.fd'),
|
|
207
|
+
path.join(shareRoot, 'edk2-arm-vars.fd'),
|
|
208
|
+
];
|
|
209
|
+
const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
210
|
+
|
|
211
|
+
if (!fs.existsSync(codePath) || !varsTemplatePath) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
shareRoot,
|
|
217
|
+
codePath,
|
|
218
|
+
varsTemplatePath,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveX86_64FirmwarePaths() {
|
|
223
|
+
const shareRoot = resolveQemuShareRoot();
|
|
224
|
+
if (!shareRoot) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// edk2-x86_64-code.fd is the UEFI code ROM; edk2-i386-vars.fd is the
|
|
229
|
+
// correct writable vars companion (Homebrew QEMU does not ship an
|
|
230
|
+
// edk2-x86_64-vars.fd — they share the i386 vars image).
|
|
231
|
+
const codePathCandidates = [
|
|
232
|
+
path.join(shareRoot, 'edk2-x86_64-code.fd'),
|
|
233
|
+
path.join(shareRoot, 'edk2-x86_64-secure-code.fd'),
|
|
234
|
+
];
|
|
235
|
+
const codePath = codePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
236
|
+
const varsTemplatePathCandidates = [
|
|
237
|
+
path.join(shareRoot, 'edk2-i386-vars.fd'),
|
|
238
|
+
path.join(shareRoot, 'edk2-x86_64-vars.fd'),
|
|
239
|
+
];
|
|
240
|
+
const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
241
|
+
|
|
242
|
+
if (!codePath || !varsTemplatePath) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
shareRoot,
|
|
248
|
+
codePath,
|
|
249
|
+
varsTemplatePath,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
128
253
|
function buildQemuArgs({
|
|
129
254
|
imagePath,
|
|
130
255
|
sshPort,
|
|
@@ -133,8 +258,15 @@ function buildQemuArgs({
|
|
|
133
258
|
cpus = 2,
|
|
134
259
|
arch = guestArchForHost(),
|
|
135
260
|
platform = process.platform,
|
|
261
|
+
hostShareRoot = null,
|
|
262
|
+
hostDataRoot = null,
|
|
263
|
+
seedPath = null,
|
|
264
|
+
seedIsRaw = false,
|
|
265
|
+
consoleLogPath = null,
|
|
266
|
+
firmwareCodePath = null,
|
|
267
|
+
firmwareVarsPath = null,
|
|
136
268
|
}) {
|
|
137
|
-
const accel = resolveAcceleration({ platform });
|
|
269
|
+
const accel = resolveAcceleration({ platform, arch });
|
|
138
270
|
const args = ['-display', 'none', '-m', String(memoryMb), '-smp', String(cpus)];
|
|
139
271
|
|
|
140
272
|
if (arch === 'arm64') {
|
|
@@ -145,16 +277,67 @@ function buildQemuArgs({
|
|
|
145
277
|
} else {
|
|
146
278
|
args.push('-machine', `q35,accel=${accel}`);
|
|
147
279
|
if (platform !== 'win32') {
|
|
148
|
-
args.push('-cpu', 'host');
|
|
280
|
+
args.push('-cpu', process.arch === arch ? 'host' : 'max');
|
|
149
281
|
}
|
|
150
282
|
}
|
|
151
283
|
|
|
284
|
+
// OS disk — always first boot candidate
|
|
152
285
|
args.push(
|
|
153
|
-
'-drive', `if=
|
|
286
|
+
'-drive', `if=none,id=os,file=${imagePath},format=qcow2`,
|
|
287
|
+
'-device', 'virtio-blk-pci,drive=os,bootindex=1',
|
|
154
288
|
'-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22,hostfwd=tcp:127.0.0.1:${agentPort}-:8421`,
|
|
155
289
|
'-device', 'virtio-net-pci,netdev=net0',
|
|
156
290
|
);
|
|
157
291
|
|
|
292
|
+
if (hostShareRoot) {
|
|
293
|
+
args.push(
|
|
294
|
+
'-virtfs',
|
|
295
|
+
`local,path=${hostShareRoot},mount_tag=neoagent-host,security_model=none,readonly=on`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (hostDataRoot) {
|
|
300
|
+
args.push(
|
|
301
|
+
'-virtfs',
|
|
302
|
+
`local,path=${hostDataRoot},mount_tag=neoagent-data,security_model=none`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (seedPath) {
|
|
307
|
+
if (seedIsRaw) {
|
|
308
|
+
// Raw FAT image — attach as a plain virtio block device
|
|
309
|
+
args.push(
|
|
310
|
+
'-drive', `if=none,id=cidata,file=${seedPath},format=raw,readonly=on`,
|
|
311
|
+
'-device', 'virtio-blk-pci,drive=cidata',
|
|
312
|
+
);
|
|
313
|
+
} else if (arch === 'arm64') {
|
|
314
|
+
// ARM virt machine has no IDE controller; use virtio-scsi for the seed ISO
|
|
315
|
+
args.push(
|
|
316
|
+
'-device', 'virtio-scsi-pci,id=scsi0',
|
|
317
|
+
'-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
|
|
318
|
+
'-device', 'scsi-cd,drive=cidata,bus=scsi0.0',
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
// x86_64 q35 machine: plain IDE CD-ROM is the most reliably detected
|
|
322
|
+
// path for cloud-init NoCloud discovery without extra guest drivers.
|
|
323
|
+
args.push(
|
|
324
|
+
'-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
|
|
325
|
+
'-device', 'ide-cd,drive=cidata,bus=ide.0',
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (firmwareCodePath && firmwareVarsPath) {
|
|
331
|
+
args.push(
|
|
332
|
+
'-drive', `if=pflash,format=raw,readonly=on,file=${firmwareCodePath}`,
|
|
333
|
+
'-drive', `if=pflash,format=raw,file=${firmwareVarsPath}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (consoleLogPath) {
|
|
338
|
+
args.push('-serial', `file:${consoleLogPath}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
158
341
|
return args;
|
|
159
342
|
}
|
|
160
343
|
|
|
@@ -165,6 +348,22 @@ function commandExists(command) {
|
|
|
165
348
|
return probe.status === 0;
|
|
166
349
|
}
|
|
167
350
|
|
|
351
|
+
function resolveCommandPath(command) {
|
|
352
|
+
const probe = spawnSync(
|
|
353
|
+
process.platform === 'win32' ? 'where' : 'bash',
|
|
354
|
+
process.platform === 'win32' ? [command] : ['-lc', `command -v "${command}"`],
|
|
355
|
+
{
|
|
356
|
+
encoding: 'utf8',
|
|
357
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
if (probe.status !== 0) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const resolved = String(probe.stdout || '').trim().split('\n').find(Boolean);
|
|
364
|
+
return resolved || null;
|
|
365
|
+
}
|
|
366
|
+
|
|
168
367
|
function allocatePort() {
|
|
169
368
|
return new Promise((resolve, reject) => {
|
|
170
369
|
const server = net.createServer();
|
|
@@ -192,29 +391,46 @@ function ensureUserVmDisk(userRoot, baseImagePath) {
|
|
|
192
391
|
}
|
|
193
392
|
|
|
194
393
|
const qemuImg = process.platform === 'win32' ? 'qemu-img.exe' : 'qemu-img';
|
|
195
|
-
|
|
196
|
-
|
|
394
|
+
const qemuImgPath = resolveCommandPath(qemuImg);
|
|
395
|
+
if (!qemuImgPath) {
|
|
396
|
+
try {
|
|
397
|
+
fs.copyFileSync(baseImagePath, diskPath);
|
|
398
|
+
return diskPath;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throw new Error(`Failed to create VM disk copy: ${error.message}`);
|
|
401
|
+
}
|
|
197
402
|
}
|
|
198
403
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
404
|
+
try {
|
|
405
|
+
const result = spawnSync(
|
|
406
|
+
qemuImgPath,
|
|
407
|
+
['create', '-f', 'qcow2', '-F', 'qcow2', '-b', baseImagePath, diskPath, '32G'],
|
|
408
|
+
{
|
|
409
|
+
stdio: 'pipe',
|
|
410
|
+
encoding: 'utf8',
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
if (result.status === 0 && fs.existsSync(diskPath)) {
|
|
414
|
+
return diskPath;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const detail = String(
|
|
418
|
+
result.stderr
|
|
419
|
+
|| result.stdout
|
|
420
|
+
|| result.error?.message
|
|
421
|
+
|| `exit status ${result.status ?? 'unknown'}`
|
|
422
|
+
).trim();
|
|
423
|
+
fs.copyFileSync(baseImagePath, diskPath);
|
|
208
424
|
return diskPath;
|
|
425
|
+
} catch (error) {
|
|
426
|
+
try {
|
|
427
|
+
fs.copyFileSync(baseImagePath, diskPath);
|
|
428
|
+
return diskPath;
|
|
429
|
+
} catch (copyError) {
|
|
430
|
+
const detail = String(error?.message || copyError?.message || 'unknown error').trim();
|
|
431
|
+
throw new Error(`Failed to create VM overlay with qemu-img: ${detail}`);
|
|
432
|
+
}
|
|
209
433
|
}
|
|
210
|
-
|
|
211
|
-
const detail = String(
|
|
212
|
-
result.stderr
|
|
213
|
-
|| result.stdout
|
|
214
|
-
|| result.error?.message
|
|
215
|
-
|| `exit status ${result.status ?? 'unknown'}`
|
|
216
|
-
).trim();
|
|
217
|
-
throw new Error(`Failed to create VM overlay with qemu-img: ${detail}`);
|
|
218
434
|
}
|
|
219
435
|
|
|
220
436
|
function formatReadinessIssues(readiness) {
|
|
@@ -225,9 +441,6 @@ function formatReadinessIssues(readiness) {
|
|
|
225
441
|
if (!readiness.qemuAvailable) {
|
|
226
442
|
issues.push(`Missing QEMU binary (${readiness.qemuBinary}).`);
|
|
227
443
|
}
|
|
228
|
-
if (!readiness.qemuImgAvailable) {
|
|
229
|
-
issues.push(`Missing qemu-img binary (${readiness.qemuImgBinary}).`);
|
|
230
|
-
}
|
|
231
444
|
if (!readiness.baseImageExists && !readiness.downloadConfigured) {
|
|
232
445
|
issues.push('No VM base image is available for download or local reuse.');
|
|
233
446
|
}
|
|
@@ -238,10 +451,13 @@ class QemuVmManager {
|
|
|
238
451
|
constructor(options = {}) {
|
|
239
452
|
this.rootDir = path.resolve(options.rootDir || VM_ROOT);
|
|
240
453
|
this.baseImagePath = options.baseImagePath || process.env.NEOAGENT_VM_BASE_IMAGE || '';
|
|
241
|
-
this.
|
|
454
|
+
this.guestArch = options.guestArch || guestArchForHost();
|
|
455
|
+
this.baseImageUrl = normalizeBaseImageUrlForArch(
|
|
456
|
+
options.baseImageUrl || process.env.NEOAGENT_VM_BASE_IMAGE_URL || defaultBaseImageUrlForArch(this.guestArch),
|
|
457
|
+
this.guestArch,
|
|
458
|
+
);
|
|
242
459
|
this.memoryMb = Number(options.memoryMb || process.env.NEOAGENT_VM_MEMORY_MB || 4096);
|
|
243
460
|
this.cpus = Number(options.cpus || process.env.NEOAGENT_VM_CPUS || 2);
|
|
244
|
-
this.guestArch = options.guestArch || guestArchForHost();
|
|
245
461
|
this.instances = new Map();
|
|
246
462
|
this.baseImagePromise = null;
|
|
247
463
|
fs.mkdirSync(this.rootDir, { recursive: true });
|
|
@@ -303,9 +519,8 @@ class QemuVmManager {
|
|
|
303
519
|
const baseImageExists = Boolean(resolvedBaseImagePath && fs.existsSync(resolvedBaseImagePath));
|
|
304
520
|
const downloadConfigured = !this.baseImagePath && isHttpUrl(this.baseImageUrl);
|
|
305
521
|
const qemuAvailable = commandExists(qemuBinary);
|
|
306
|
-
const qemuImgAvailable = commandExists(qemuImgBinary);
|
|
307
522
|
return {
|
|
308
|
-
ready: qemuAvailable &&
|
|
523
|
+
ready: qemuAvailable && (baseImageExists || downloadConfigured),
|
|
309
524
|
baseImagePath: resolvedBaseImagePath || null,
|
|
310
525
|
baseImageExists,
|
|
311
526
|
baseImageUrl: this.baseImageUrl || null,
|
|
@@ -313,8 +528,8 @@ class QemuVmManager {
|
|
|
313
528
|
qemuBinary,
|
|
314
529
|
qemuAvailable,
|
|
315
530
|
qemuImgBinary,
|
|
316
|
-
qemuImgAvailable,
|
|
317
|
-
acceleration: resolveAcceleration(),
|
|
531
|
+
qemuImgAvailable: commandExists(qemuImgBinary),
|
|
532
|
+
acceleration: resolveAcceleration({ arch: this.guestArch }),
|
|
318
533
|
guestArch: this.guestArch,
|
|
319
534
|
platform: process.platform,
|
|
320
535
|
};
|
|
@@ -326,20 +541,59 @@ class QemuVmManager {
|
|
|
326
541
|
throw new Error('VM runtime requires a user ID.');
|
|
327
542
|
}
|
|
328
543
|
const existing = this.instances.get(key);
|
|
329
|
-
if (existing?.process && !existing.process.killed) {
|
|
544
|
+
if (existing?.process && !existing.process.killed && existing.guestArch === this.guestArch) {
|
|
330
545
|
return existing;
|
|
331
546
|
}
|
|
547
|
+
if (existing?.process && existing.guestArch !== this.guestArch) {
|
|
548
|
+
try {
|
|
549
|
+
existing.process.kill('SIGTERM');
|
|
550
|
+
} catch {}
|
|
551
|
+
this.instances.delete(key);
|
|
552
|
+
}
|
|
332
553
|
const readiness = this.getReadiness();
|
|
333
554
|
if (!readiness.ready) {
|
|
334
555
|
throw new Error(formatReadinessIssues(readiness).join(' '));
|
|
335
556
|
}
|
|
336
557
|
|
|
337
|
-
const userRoot = path.join(this.rootDir, key);
|
|
558
|
+
const userRoot = path.join(this.rootDir, key, this.guestArch);
|
|
338
559
|
const baseImagePath = await this.ensureBaseImageAvailable();
|
|
339
560
|
const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
|
|
561
|
+
const guestToken = String(process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
|
|
562
|
+
if (!guestToken) {
|
|
563
|
+
throw new Error('NEOAGENT_VM_GUEST_TOKEN is required to bootstrap the guest runtime.');
|
|
564
|
+
}
|
|
565
|
+
const bootstrap = ensureGuestBootstrapSeed({
|
|
566
|
+
userRoot,
|
|
567
|
+
guestToken,
|
|
568
|
+
});
|
|
569
|
+
const guestDataRoot = path.join(userRoot, 'guest-data');
|
|
570
|
+
const consoleLogPath = path.join(userRoot, 'console.log');
|
|
571
|
+
const firmware = this.guestArch === 'arm64'
|
|
572
|
+
? resolveAarch64FirmwarePaths()
|
|
573
|
+
: resolveX86_64FirmwarePaths();
|
|
574
|
+
const firmwareVarsPath = firmware ? path.join(userRoot, 'uefi-vars.fd') : null;
|
|
575
|
+
if (firmware && !fs.existsSync(firmwareVarsPath)) {
|
|
576
|
+
if (!fs.existsSync(firmware.varsTemplatePath)) {
|
|
577
|
+
throw new Error(`Firmware vars template is missing: ${firmware.varsTemplatePath}`);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
fs.copyFileSync(firmware.varsTemplatePath, firmwareVarsPath);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
const detail = error?.message || error;
|
|
583
|
+
console.error('[VM] Failed to copy firmware vars template', {
|
|
584
|
+
source: firmware.varsTemplatePath,
|
|
585
|
+
destination: firmwareVarsPath,
|
|
586
|
+
error: detail,
|
|
587
|
+
});
|
|
588
|
+
throw new Error(`Failed to copy firmware vars template: ${detail}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
fs.mkdirSync(guestDataRoot, { recursive: true });
|
|
340
592
|
const agentPort = await allocatePort();
|
|
341
593
|
const sshPort = await allocatePort();
|
|
342
594
|
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
595
|
+
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
596
|
+
const hostShareRoot = ensureHostShareRoot();
|
|
343
597
|
const args = buildQemuArgs({
|
|
344
598
|
imagePath: diskPath,
|
|
345
599
|
sshPort,
|
|
@@ -347,9 +601,16 @@ class QemuVmManager {
|
|
|
347
601
|
memoryMb: this.memoryMb,
|
|
348
602
|
cpus: this.cpus,
|
|
349
603
|
arch: this.guestArch,
|
|
604
|
+
hostShareRoot,
|
|
605
|
+
hostDataRoot: guestDataRoot,
|
|
606
|
+
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
607
|
+
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
608
|
+
consoleLogPath,
|
|
609
|
+
firmwareCodePath: firmware?.codePath || null,
|
|
610
|
+
firmwareVarsPath,
|
|
350
611
|
});
|
|
351
612
|
|
|
352
|
-
const child = spawn(
|
|
613
|
+
const child = spawn(qemuBinaryPath, args, {
|
|
353
614
|
cwd: userRoot,
|
|
354
615
|
detached: process.platform !== 'win32',
|
|
355
616
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
@@ -375,6 +636,7 @@ class QemuVmManager {
|
|
|
375
636
|
process: child,
|
|
376
637
|
qemuBinary,
|
|
377
638
|
qemuArgs: args,
|
|
639
|
+
guestArch: this.guestArch,
|
|
378
640
|
userRoot,
|
|
379
641
|
diskPath,
|
|
380
642
|
agentPort,
|
|
@@ -386,6 +648,31 @@ class QemuVmManager {
|
|
|
386
648
|
return session;
|
|
387
649
|
}
|
|
388
650
|
|
|
651
|
+
hasVm(userId) {
|
|
652
|
+
const key = String(userId || '').trim();
|
|
653
|
+
return Boolean(key && this.instances.has(key));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async killVm(userId) {
|
|
657
|
+
const key = String(userId || '').trim();
|
|
658
|
+
const session = this.instances.get(key);
|
|
659
|
+
if (!session) return;
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
if (process.platform === 'win32') {
|
|
663
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', session.process.pid]);
|
|
664
|
+
} else {
|
|
665
|
+
process.kill(-session.process.pid, 'SIGKILL');
|
|
666
|
+
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
try {
|
|
669
|
+
session.process.kill('SIGKILL');
|
|
670
|
+
} catch {}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
this.instances.delete(key);
|
|
674
|
+
}
|
|
675
|
+
|
|
389
676
|
async shutdown() {
|
|
390
677
|
for (const session of this.instances.values()) {
|
|
391
678
|
try {
|
|
@@ -38,9 +38,6 @@ function getRuntimeValidation(runtimeManager) {
|
|
|
38
38
|
if (!vmReadiness.qemuAvailable) {
|
|
39
39
|
issues.push(`prod profile requires QEMU (${vmReadiness.qemuBinary}) to be installed.`);
|
|
40
40
|
}
|
|
41
|
-
if (!vmReadiness.qemuImgAvailable) {
|
|
42
|
-
issues.push(`prod profile requires qemu-img (${vmReadiness.qemuImgBinary}) to be installed.`);
|
|
43
|
-
}
|
|
44
41
|
if (!vmReadiness.baseImageExists && !vmReadiness.downloadConfigured) {
|
|
45
42
|
issues.push('prod profile requires a VM base image or a downloadable base image URL.');
|
|
46
43
|
}
|