neoagent 2.3.1-beta.89 → 2.3.1-beta.91
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/.env.example +4 -0
- package/README.md +16 -7
- package/flutter_app/lib/features/location/location_service.dart +2 -4
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +17 -15
- package/flutter_app/lib/main_chat.dart +46 -42
- package/flutter_app/lib/main_controller.dart +6 -1
- package/flutter_app/lib/main_devices.dart +86 -742
- package/flutter_app/lib/main_integrations.dart +3 -3
- package/flutter_app/lib/main_settings.dart +50 -0
- package/flutter_app/lib/main_spacing.dart +18 -0
- package/flutter_app/lib/main_theme.dart +9 -0
- package/flutter_app/lib/main_unified.dart +3 -3
- package/lib/manager.js +33 -0
- package/package.json +1 -1
- package/server/db/database.js +74 -16
- package/server/guest_agent.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +50396 -50271
- package/server/services/ai/capabilityHealth.js +2 -3
- package/server/services/android/android_bootstrap_worker.js +18 -3
- package/server/services/android/controller.js +460 -2753
- package/server/services/runtime/backends/local-vm.js +33 -145
- package/server/services/runtime/docker-vm-manager.js +392 -0
- package/server/services/runtime/manager.js +53 -38
- package/server/services/runtime/settings.js +12 -10
- package/server/services/runtime/validation.js +4 -1
- package/server/utils/deployment.js +8 -2
- package/server/services/runtime/qemu.js +0 -1118
|
@@ -1,1118 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const crypto = require('crypto');
|
|
3
|
-
const http = require('http');
|
|
4
|
-
const https = require('https');
|
|
5
|
-
const net = require('net');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { StringDecoder } = require('string_decoder');
|
|
8
|
-
const { spawn, spawnSync } = require('child_process');
|
|
9
|
-
const { DATA_DIR } = require('../../../runtime/paths');
|
|
10
|
-
const { ensureGuestBootstrapSeed } = require('./guest_bootstrap');
|
|
11
|
-
|
|
12
|
-
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
|
13
|
-
const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
|
|
14
|
-
fs.mkdirSync(VM_ROOT, { recursive: true });
|
|
15
|
-
|
|
16
|
-
const DEFAULT_UBUNTU_BASE_IMAGE_URLS = Object.freeze({
|
|
17
|
-
x64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img',
|
|
18
|
-
arm64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img',
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
22
|
-
path.resolve(process.execPath, '..', '..', 'share', 'qemu'),
|
|
23
|
-
path.resolve(process.execPath, '..', '..', '..', 'share', 'qemu'),
|
|
24
|
-
'/opt/homebrew/share/qemu',
|
|
25
|
-
'/usr/local/share/qemu',
|
|
26
|
-
'/usr/share/qemu',
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
function guestArchForHost() {
|
|
30
|
-
return process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function resolveQemuBinary({ arch = guestArchForHost(), platform = process.platform } = {}) {
|
|
34
|
-
if (platform === 'win32') {
|
|
35
|
-
return arch === 'arm64' ? 'qemu-system-aarch64.exe' : 'qemu-system-x86_64.exe';
|
|
36
|
-
}
|
|
37
|
-
return arch === 'arm64' ? 'qemu-system-aarch64' : 'qemu-system-x86_64';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function defaultBaseImageUrlForArch(arch = guestArchForHost()) {
|
|
41
|
-
return DEFAULT_UBUNTU_BASE_IMAGE_URLS[arch] || DEFAULT_UBUNTU_BASE_IMAGE_URLS.x64;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function normalizeBaseImageUrlForArch(baseImageUrl, arch = guestArchForHost()) {
|
|
45
|
-
const candidate = String(baseImageUrl || '').trim();
|
|
46
|
-
if (!candidate) {
|
|
47
|
-
return defaultBaseImageUrlForArch(arch);
|
|
48
|
-
}
|
|
49
|
-
if (arch === 'x64' && /arm64|aarch64/i.test(candidate)) {
|
|
50
|
-
return defaultBaseImageUrlForArch('x64');
|
|
51
|
-
}
|
|
52
|
-
if (arch === 'arm64' && /amd64|x86_64/i.test(candidate)) {
|
|
53
|
-
return defaultBaseImageUrlForArch('arm64');
|
|
54
|
-
}
|
|
55
|
-
return candidate;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isHttpUrl(value) {
|
|
59
|
-
const candidate = String(value || '').trim();
|
|
60
|
-
return /^https?:\/\//i.test(candidate);
|
|
61
|
-
}
|
|
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
|
-
|
|
124
|
-
function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
|
|
125
|
-
return new Promise((resolve, reject) => {
|
|
126
|
-
if (redirectCount > 5) {
|
|
127
|
-
reject(new Error('Too many redirects while downloading VM base image.'));
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const client = String(sourceUrl).startsWith('https:') ? https : http;
|
|
132
|
-
let settled = false;
|
|
133
|
-
let output = null;
|
|
134
|
-
const tempPath = `${destinationPath}.download`;
|
|
135
|
-
|
|
136
|
-
const cleanup = (error) => {
|
|
137
|
-
if (settled) {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
settled = true;
|
|
141
|
-
try { output?.destroy(); } catch {}
|
|
142
|
-
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
|
143
|
-
reject(error);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const request = client.get(sourceUrl, (response) => {
|
|
147
|
-
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
148
|
-
response.resume();
|
|
149
|
-
const nextUrl = new URL(response.headers.location, sourceUrl).toString();
|
|
150
|
-
if (!settled) {
|
|
151
|
-
settled = true;
|
|
152
|
-
resolve(downloadFile(nextUrl, destinationPath, redirectCount + 1));
|
|
153
|
-
}
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (response.statusCode !== 200) {
|
|
158
|
-
response.resume();
|
|
159
|
-
cleanup(new Error(`Failed to download VM base image: HTTP ${response.statusCode}`));
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
164
|
-
output = fs.createWriteStream(tempPath);
|
|
165
|
-
|
|
166
|
-
response.pipe(output);
|
|
167
|
-
output.on('finish', () => {
|
|
168
|
-
output.close(() => {
|
|
169
|
-
if (settled) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
settled = true;
|
|
173
|
-
try {
|
|
174
|
-
fs.renameSync(tempPath, destinationPath);
|
|
175
|
-
resolve(destinationPath);
|
|
176
|
-
} catch (error) {
|
|
177
|
-
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
|
178
|
-
reject(error);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
output.on('error', (error) => {
|
|
183
|
-
cleanup(error);
|
|
184
|
-
});
|
|
185
|
-
response.on('error', (error) => {
|
|
186
|
-
cleanup(error);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
request.setTimeout(15 * 60 * 1000, () => {
|
|
191
|
-
request.destroy(new Error('Timed out while downloading VM base image.'));
|
|
192
|
-
});
|
|
193
|
-
request.on('timeout', () => {
|
|
194
|
-
cleanup(new Error('Timed out while downloading VM base image.'));
|
|
195
|
-
});
|
|
196
|
-
request.on('abort', () => {
|
|
197
|
-
cleanup(new Error('VM base image download was aborted.'));
|
|
198
|
-
});
|
|
199
|
-
request.on('error', (error) => {
|
|
200
|
-
cleanup(error);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function resolveAcceleration({ platform = process.platform, arch = guestArchForHost() } = {}) {
|
|
206
|
-
if (platform === 'linux') return arch === process.arch ? 'kvm' : 'tcg';
|
|
207
|
-
if (platform === 'darwin') return arch === process.arch ? 'hvf' : 'tcg';
|
|
208
|
-
if (platform === 'win32') return arch === process.arch ? 'whpx' : 'tcg';
|
|
209
|
-
return 'tcg';
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function resolveQemuShareRoot() {
|
|
213
|
-
for (const candidate of QEMU_SHARE_ROOT_CANDIDATES) {
|
|
214
|
-
if (candidate && fs.existsSync(candidate)) {
|
|
215
|
-
return candidate;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function resolveAarch64FirmwarePaths() {
|
|
222
|
-
const shareRoot = resolveQemuShareRoot();
|
|
223
|
-
if (!shareRoot) {
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const codePath = path.join(shareRoot, 'edk2-aarch64-code.fd');
|
|
228
|
-
const varsTemplatePathCandidates = [
|
|
229
|
-
path.join(shareRoot, 'edk2-aarch64-vars.fd'),
|
|
230
|
-
path.join(shareRoot, 'edk2-arm-vars.fd'),
|
|
231
|
-
];
|
|
232
|
-
const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
233
|
-
|
|
234
|
-
if (!fs.existsSync(codePath) || !varsTemplatePath) {
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
shareRoot,
|
|
240
|
-
codePath,
|
|
241
|
-
varsTemplatePath,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function resolveX86_64FirmwarePaths() {
|
|
246
|
-
const shareRoot = resolveQemuShareRoot();
|
|
247
|
-
if (!shareRoot) {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// edk2-x86_64-code.fd is the UEFI code ROM; edk2-i386-vars.fd is the
|
|
252
|
-
// correct writable vars companion (Homebrew QEMU does not ship an
|
|
253
|
-
// edk2-x86_64-vars.fd — they share the i386 vars image).
|
|
254
|
-
const codePathCandidates = [
|
|
255
|
-
path.join(shareRoot, 'edk2-x86_64-code.fd'),
|
|
256
|
-
path.join(shareRoot, 'edk2-x86_64-secure-code.fd'),
|
|
257
|
-
];
|
|
258
|
-
const codePath = codePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
259
|
-
const varsTemplatePathCandidates = [
|
|
260
|
-
path.join(shareRoot, 'edk2-i386-vars.fd'),
|
|
261
|
-
path.join(shareRoot, 'edk2-x86_64-vars.fd'),
|
|
262
|
-
];
|
|
263
|
-
const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
264
|
-
|
|
265
|
-
if (!codePath || !varsTemplatePath) {
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
shareRoot,
|
|
271
|
-
codePath,
|
|
272
|
-
varsTemplatePath,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function buildQemuArgs({
|
|
277
|
-
imagePath,
|
|
278
|
-
sshPort,
|
|
279
|
-
agentPort = 8421,
|
|
280
|
-
memoryMb = 4096,
|
|
281
|
-
cpus = 2,
|
|
282
|
-
arch = guestArchForHost(),
|
|
283
|
-
platform = process.platform,
|
|
284
|
-
seedPath = null,
|
|
285
|
-
seedIsRaw = false,
|
|
286
|
-
consoleLogPath = null,
|
|
287
|
-
firmwareCodePath = null,
|
|
288
|
-
firmwareVarsPath = null,
|
|
289
|
-
}) {
|
|
290
|
-
console.log(`[QEMU] Building args for ${arch} (MMIO 9p ENABLED)`);
|
|
291
|
-
const accel = resolveAcceleration({ platform, arch });
|
|
292
|
-
const args = ['-display', 'none', '-m', String(memoryMb), '-smp', String(cpus)];
|
|
293
|
-
|
|
294
|
-
if (arch === 'arm64') {
|
|
295
|
-
args.push('-machine', `virt,accel=${accel},gic-version=max`);
|
|
296
|
-
if (platform !== 'win32') {
|
|
297
|
-
args.push('-cpu', 'host');
|
|
298
|
-
}
|
|
299
|
-
} else {
|
|
300
|
-
args.push('-machine', `q35,accel=${accel}`);
|
|
301
|
-
if (platform !== 'win32') {
|
|
302
|
-
args.push('-cpu', process.arch === arch ? 'host' : 'qemu64');
|
|
303
|
-
}
|
|
304
|
-
}
|
|
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
|
-
|
|
311
|
-
// OS disk — always first boot candidate
|
|
312
|
-
args.push(
|
|
313
|
-
'-drive', `if=none,id=os,file=${imagePath},format=qcow2`,
|
|
314
|
-
'-device', `${blkDev},drive=os,bootindex=1`,
|
|
315
|
-
'-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22,hostfwd=tcp:127.0.0.1:${agentPort}-:8421`,
|
|
316
|
-
'-device', `${netDev},netdev=net0`,
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
if (seedPath) {
|
|
320
|
-
if (seedIsRaw) {
|
|
321
|
-
// Raw FAT image — attach as a plain virtio block device
|
|
322
|
-
args.push(
|
|
323
|
-
'-drive', `if=none,id=cidata,file=${seedPath},format=raw,readonly=on`,
|
|
324
|
-
'-device', `${blkDev},drive=cidata`,
|
|
325
|
-
);
|
|
326
|
-
} else if (arch === 'arm64') {
|
|
327
|
-
// ARM virt machine has no IDE controller; use virtio-scsi for the seed ISO
|
|
328
|
-
args.push(
|
|
329
|
-
'-device', 'virtio-scsi-pci,id=scsi0',
|
|
330
|
-
'-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
|
|
331
|
-
'-device', 'scsi-cd,drive=cidata,bus=scsi0.0',
|
|
332
|
-
);
|
|
333
|
-
} else {
|
|
334
|
-
// x86_64 q35 machine: plain IDE CD-ROM is the most reliably detected
|
|
335
|
-
// path for cloud-init NoCloud discovery without extra guest drivers.
|
|
336
|
-
args.push(
|
|
337
|
-
'-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
|
|
338
|
-
'-device', 'ide-cd,drive=cidata,bus=ide.0',
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (firmwareCodePath && firmwareVarsPath) {
|
|
344
|
-
args.push(
|
|
345
|
-
'-drive', `if=pflash,format=raw,readonly=on,file=${firmwareCodePath}`,
|
|
346
|
-
'-drive', `if=pflash,format=raw,file=${firmwareVarsPath}`,
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (consoleLogPath) {
|
|
351
|
-
args.push('-serial', `file:${consoleLogPath}`);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return args;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function commandExists(command) {
|
|
358
|
-
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'bash', process.platform === 'win32' ? [command] : ['-lc', `command -v "${command}"`], {
|
|
359
|
-
stdio: 'ignore',
|
|
360
|
-
});
|
|
361
|
-
return probe.status === 0;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function resolveCommandPath(command) {
|
|
365
|
-
const probe = spawnSync(
|
|
366
|
-
process.platform === 'win32' ? 'where' : 'bash',
|
|
367
|
-
process.platform === 'win32' ? [command] : ['-lc', `command -v "${command}"`],
|
|
368
|
-
{
|
|
369
|
-
encoding: 'utf8',
|
|
370
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
371
|
-
},
|
|
372
|
-
);
|
|
373
|
-
if (probe.status !== 0) {
|
|
374
|
-
return null;
|
|
375
|
-
}
|
|
376
|
-
const resolved = String(probe.stdout || '').trim().split('\n').find(Boolean);
|
|
377
|
-
return resolved || null;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function allocatePort() {
|
|
381
|
-
return new Promise((resolve, reject) => {
|
|
382
|
-
const server = net.createServer();
|
|
383
|
-
server.unref();
|
|
384
|
-
server.on('error', reject);
|
|
385
|
-
server.listen(0, '127.0.0.1', () => {
|
|
386
|
-
const address = server.address();
|
|
387
|
-
const port = typeof address === 'object' && address ? address.port : null;
|
|
388
|
-
server.close((err) => {
|
|
389
|
-
if (err) return reject(err);
|
|
390
|
-
resolve(port);
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
}
|
|
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
|
-
|
|
525
|
-
function ensureUserVmDisk(userRoot, baseImagePath) {
|
|
526
|
-
fs.mkdirSync(userRoot, { recursive: true });
|
|
527
|
-
const diskPath = path.join(userRoot, 'disk.qcow2');
|
|
528
|
-
if (fs.existsSync(diskPath)) {
|
|
529
|
-
return diskPath;
|
|
530
|
-
}
|
|
531
|
-
if (!fs.existsSync(baseImagePath)) {
|
|
532
|
-
throw new Error(`VM base image not found: ${baseImagePath}`);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const qemuImg = process.platform === 'win32' ? 'qemu-img.exe' : 'qemu-img';
|
|
536
|
-
const qemuImgPath = resolveCommandPath(qemuImg);
|
|
537
|
-
if (!qemuImgPath) {
|
|
538
|
-
try {
|
|
539
|
-
fs.copyFileSync(baseImagePath, diskPath);
|
|
540
|
-
return diskPath;
|
|
541
|
-
} catch (error) {
|
|
542
|
-
throw new Error(`Failed to create VM disk copy: ${error.message}`);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
try {
|
|
547
|
-
const result = spawnSync(
|
|
548
|
-
qemuImgPath,
|
|
549
|
-
['create', '-f', 'qcow2', '-F', 'qcow2', '-b', baseImagePath, diskPath, '32G'],
|
|
550
|
-
{
|
|
551
|
-
stdio: 'pipe',
|
|
552
|
-
encoding: 'utf8',
|
|
553
|
-
},
|
|
554
|
-
);
|
|
555
|
-
if (result.status === 0 && fs.existsSync(diskPath)) {
|
|
556
|
-
return diskPath;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const detail = String(
|
|
560
|
-
result.stderr
|
|
561
|
-
|| result.stdout
|
|
562
|
-
|| result.error?.message
|
|
563
|
-
|| `exit status ${result.status ?? 'unknown'}`
|
|
564
|
-
).trim();
|
|
565
|
-
fs.copyFileSync(baseImagePath, diskPath);
|
|
566
|
-
return diskPath;
|
|
567
|
-
} catch (error) {
|
|
568
|
-
try {
|
|
569
|
-
fs.copyFileSync(baseImagePath, diskPath);
|
|
570
|
-
return diskPath;
|
|
571
|
-
} catch (copyError) {
|
|
572
|
-
const detail = String(error?.message || copyError?.message || 'unknown error').trim();
|
|
573
|
-
throw new Error(`Failed to create VM overlay with qemu-img: ${detail}`);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function formatReadinessIssues(readiness) {
|
|
579
|
-
if (!readiness) {
|
|
580
|
-
return ['VM runtime is unavailable on this host.'];
|
|
581
|
-
}
|
|
582
|
-
const issues = [];
|
|
583
|
-
if (!readiness.qemuAvailable) {
|
|
584
|
-
issues.push(`Missing QEMU binary (${readiness.qemuBinary}).`);
|
|
585
|
-
}
|
|
586
|
-
if (!readiness.baseImageExists && !readiness.downloadConfigured) {
|
|
587
|
-
issues.push('No VM base image is available for download or local reuse.');
|
|
588
|
-
}
|
|
589
|
-
return issues.length > 0 ? issues : ['VM runtime is unavailable on this host.'];
|
|
590
|
-
}
|
|
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
|
-
|
|
602
|
-
class QemuVmManager {
|
|
603
|
-
constructor(options = {}) {
|
|
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'));
|
|
608
|
-
this.baseImagePath = options.baseImagePath || process.env.NEOAGENT_VM_BASE_IMAGE || '';
|
|
609
|
-
this.guestArch = options.guestArch || guestArchForHost();
|
|
610
|
-
this.baseImageUrl = normalizeBaseImageUrlForArch(
|
|
611
|
-
options.baseImageUrl || process.env.NEOAGENT_VM_BASE_IMAGE_URL || defaultBaseImageUrlForArch(this.guestArch),
|
|
612
|
-
this.guestArch,
|
|
613
|
-
);
|
|
614
|
-
this.memoryMb = Number(options.memoryMb || process.env.NEOAGENT_VM_MEMORY_MB || 4096);
|
|
615
|
-
this.cpus = Number(options.cpus || process.env.NEOAGENT_VM_CPUS || 2);
|
|
616
|
-
this.instances = new Map();
|
|
617
|
-
this.baseImagePromise = null;
|
|
618
|
-
this.runtimeTemplatePromise = null;
|
|
619
|
-
this.warmupEnabled = options.warmup === true;
|
|
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
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
getBaseImageCachePath() {
|
|
633
|
-
if (!isHttpUrl(this.baseImageUrl)) {
|
|
634
|
-
return null;
|
|
635
|
-
}
|
|
636
|
-
const parsed = new URL(this.baseImageUrl);
|
|
637
|
-
const filename = path.basename(parsed.pathname || '') || `${this.guestArch}-base.img`;
|
|
638
|
-
return path.join(this.baseImageCacheRoot, filename);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
resolveBaseImagePath() {
|
|
642
|
-
const explicitPath = String(this.baseImagePath || '').trim();
|
|
643
|
-
if (explicitPath) {
|
|
644
|
-
return explicitPath;
|
|
645
|
-
}
|
|
646
|
-
return this.getBaseImageCachePath();
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
async ensureBaseImageAvailable() {
|
|
650
|
-
const explicitPath = String(this.baseImagePath || '').trim();
|
|
651
|
-
if (explicitPath) {
|
|
652
|
-
if (!fs.existsSync(explicitPath)) {
|
|
653
|
-
throw new Error(`VM base image not found: ${explicitPath}`);
|
|
654
|
-
}
|
|
655
|
-
return explicitPath;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (!isHttpUrl(this.baseImageUrl)) {
|
|
659
|
-
throw new Error('VM base image is not configured and no downloadable base image URL is available.');
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const cachePath = this.getBaseImageCachePath();
|
|
663
|
-
if (cachePath && fs.existsSync(cachePath)) {
|
|
664
|
-
return cachePath;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
if (!this.baseImagePromise) {
|
|
668
|
-
this.baseImagePromise = downloadFile(this.baseImageUrl, cachePath)
|
|
669
|
-
.finally(() => {
|
|
670
|
-
this.baseImagePromise = null;
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return this.baseImagePromise;
|
|
675
|
-
}
|
|
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
|
-
|
|
888
|
-
isConfigured() {
|
|
889
|
-
return this.getReadiness().ready;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
getReadiness() {
|
|
893
|
-
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
894
|
-
const qemuImgBinary = process.platform === 'win32' ? 'qemu-img.exe' : 'qemu-img';
|
|
895
|
-
const resolvedBaseImagePath = this.resolveBaseImagePath();
|
|
896
|
-
const baseImageExists = Boolean(resolvedBaseImagePath && fs.existsSync(resolvedBaseImagePath));
|
|
897
|
-
const downloadConfigured = !this.baseImagePath && isHttpUrl(this.baseImageUrl);
|
|
898
|
-
const qemuAvailable = commandExists(qemuBinary);
|
|
899
|
-
return {
|
|
900
|
-
ready: qemuAvailable && (baseImageExists || downloadConfigured),
|
|
901
|
-
baseImagePath: resolvedBaseImagePath || null,
|
|
902
|
-
baseImageExists,
|
|
903
|
-
baseImageUrl: this.baseImageUrl || null,
|
|
904
|
-
downloadConfigured,
|
|
905
|
-
qemuBinary,
|
|
906
|
-
qemuAvailable,
|
|
907
|
-
qemuImgBinary,
|
|
908
|
-
qemuImgAvailable: commandExists(qemuImgBinary),
|
|
909
|
-
acceleration: resolveAcceleration({ arch: this.guestArch }),
|
|
910
|
-
guestArch: this.guestArch,
|
|
911
|
-
platform: process.platform,
|
|
912
|
-
};
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
async ensureVm(userId) {
|
|
916
|
-
const key = String(userId || '').trim();
|
|
917
|
-
if (!key) {
|
|
918
|
-
throw new Error('VM runtime requires a user ID.');
|
|
919
|
-
}
|
|
920
|
-
const existing = this.instances.get(key);
|
|
921
|
-
if (existing?.process && !existing.process.killed && existing.guestArch === this.guestArch) {
|
|
922
|
-
return existing;
|
|
923
|
-
}
|
|
924
|
-
if (existing?.process && existing.guestArch !== this.guestArch) {
|
|
925
|
-
try {
|
|
926
|
-
existing.process.kill('SIGTERM');
|
|
927
|
-
} catch {}
|
|
928
|
-
this.instances.delete(key);
|
|
929
|
-
}
|
|
930
|
-
const readiness = this.getReadiness();
|
|
931
|
-
if (!readiness.ready) {
|
|
932
|
-
throw new Error(formatReadinessIssues(readiness).join(' '));
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
const userRoot = path.join(this.rootDir, key, this.guestArch);
|
|
936
|
-
const baseImagePath = await this.ensureRuntimeImageAvailable();
|
|
937
|
-
const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
|
|
938
|
-
const guestToken = resolveGuestToken(userRoot);
|
|
939
|
-
const bootstrap = ensureGuestBootstrapSeed({
|
|
940
|
-
userRoot,
|
|
941
|
-
guestToken,
|
|
942
|
-
guestArch: this.guestArch,
|
|
943
|
-
runtimeMode: 'user',
|
|
944
|
-
runtimeProfile: this.runtimeProfile,
|
|
945
|
-
});
|
|
946
|
-
const consoleLogPath = path.join(userRoot, 'console.log');
|
|
947
|
-
const firmware = this.guestArch === 'arm64'
|
|
948
|
-
? resolveAarch64FirmwarePaths()
|
|
949
|
-
: resolveX86_64FirmwarePaths();
|
|
950
|
-
const firmwareVarsPath = firmware ? path.join(userRoot, 'uefi-vars.fd') : null;
|
|
951
|
-
if (firmware && !fs.existsSync(firmwareVarsPath)) {
|
|
952
|
-
if (!fs.existsSync(firmware.varsTemplatePath)) {
|
|
953
|
-
throw new Error(`Firmware vars template is missing: ${firmware.varsTemplatePath}`);
|
|
954
|
-
}
|
|
955
|
-
try {
|
|
956
|
-
fs.copyFileSync(firmware.varsTemplatePath, firmwareVarsPath);
|
|
957
|
-
} catch (error) {
|
|
958
|
-
const detail = error?.message || error;
|
|
959
|
-
console.error('[VM] Failed to copy firmware vars template', {
|
|
960
|
-
source: firmware.varsTemplatePath,
|
|
961
|
-
destination: firmwareVarsPath,
|
|
962
|
-
error: detail,
|
|
963
|
-
});
|
|
964
|
-
throw new Error(`Failed to copy firmware vars template: ${detail}`);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
const agentPort = await allocatePort();
|
|
968
|
-
const sshPort = await allocatePort();
|
|
969
|
-
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
970
|
-
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
971
|
-
const args = buildQemuArgs({
|
|
972
|
-
imagePath: diskPath,
|
|
973
|
-
sshPort,
|
|
974
|
-
agentPort,
|
|
975
|
-
memoryMb: this.memoryMb,
|
|
976
|
-
cpus: this.cpus,
|
|
977
|
-
arch: this.guestArch,
|
|
978
|
-
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
979
|
-
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
980
|
-
consoleLogPath,
|
|
981
|
-
firmwareCodePath: firmware?.codePath || null,
|
|
982
|
-
firmwareVarsPath,
|
|
983
|
-
});
|
|
984
|
-
|
|
985
|
-
console.log(`[VM:${this.runtimeProfile}] Starting QEMU for user ${key} (${this.guestArch}): ${qemuBinaryPath} ${args.join(' ')}`);
|
|
986
|
-
const child = spawn(qemuBinaryPath, args, {
|
|
987
|
-
cwd: userRoot,
|
|
988
|
-
detached: process.platform !== 'win32',
|
|
989
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
let lastError = '';
|
|
993
|
-
const stderrDecoder = new StringDecoder('utf8');
|
|
994
|
-
child.stderr.on('data', (chunk) => {
|
|
995
|
-
const text = stderrDecoder.write(chunk);
|
|
996
|
-
if (text.trim()) console.error(`[VM:${key}:stderr] ${text.trim()}`);
|
|
997
|
-
lastError = [...`${lastError}${text}`].slice(-4000).join('');
|
|
998
|
-
});
|
|
999
|
-
|
|
1000
|
-
if (consoleLogPath) {
|
|
1001
|
-
// Ensure file exists before reading to avoid race condition
|
|
1002
|
-
try {
|
|
1003
|
-
fs.closeSync(fs.openSync(consoleLogPath, 'a'));
|
|
1004
|
-
} catch (err) {
|
|
1005
|
-
console.warn(`[VM] Failed to pre-create console log at ${consoleLogPath}: ${err.message}`);
|
|
1006
|
-
}
|
|
1007
|
-
// Stream serial output to console for easier debugging on remote machines
|
|
1008
|
-
const serialStream = fs.createReadStream(consoleLogPath, { flags: 'r' });
|
|
1009
|
-
serialStream.on('data', (chunk) => {
|
|
1010
|
-
const text = chunk.toString('utf8');
|
|
1011
|
-
if (text.trim()) console.log(`[VM:${key}:serial] ${text.trim()}`);
|
|
1012
|
-
});
|
|
1013
|
-
child.on('exit', () => serialStream.destroy());
|
|
1014
|
-
}
|
|
1015
|
-
child.stderr.on('close', () => {
|
|
1016
|
-
const remainder = stderrDecoder.end();
|
|
1017
|
-
if (remainder) {
|
|
1018
|
-
lastError = [...`${lastError}${remainder}`].slice(-4000).join('');
|
|
1019
|
-
}
|
|
1020
|
-
});
|
|
1021
|
-
child.on('exit', () => {
|
|
1022
|
-
const current = this.instances.get(key);
|
|
1023
|
-
if (current?.process === child) {
|
|
1024
|
-
this.instances.delete(key);
|
|
1025
|
-
}
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
const session = {
|
|
1029
|
-
userId: key,
|
|
1030
|
-
runtimeProfile: this.runtimeProfile,
|
|
1031
|
-
process: child,
|
|
1032
|
-
qemuBinary,
|
|
1033
|
-
qemuArgs: args,
|
|
1034
|
-
guestArch: this.guestArch,
|
|
1035
|
-
userRoot,
|
|
1036
|
-
diskPath,
|
|
1037
|
-
guestToken,
|
|
1038
|
-
agentPort,
|
|
1039
|
-
sshPort,
|
|
1040
|
-
baseUrl: `http://127.0.0.1:${agentPort}`,
|
|
1041
|
-
getLastError: () => lastError.trim(),
|
|
1042
|
-
};
|
|
1043
|
-
this.instances.set(key, session);
|
|
1044
|
-
return session;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
hasVm(userId) {
|
|
1048
|
-
const key = String(userId || '').trim();
|
|
1049
|
-
return Boolean(key && this.instances.has(key));
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
async killVm(userId) {
|
|
1053
|
-
const key = String(userId || '').trim();
|
|
1054
|
-
const session = this.instances.get(key);
|
|
1055
|
-
if (!session) return;
|
|
1056
|
-
|
|
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
|
-
|
|
1071
|
-
if (process.platform === 'win32') {
|
|
1072
|
-
spawnSync('taskkill', ['/T', '/PID', session.process.pid]);
|
|
1073
|
-
} else {
|
|
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
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
} catch {
|
|
1088
|
-
try {
|
|
1089
|
-
session.process.kill('SIGKILL');
|
|
1090
|
-
} catch {}
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
this.instances.delete(key);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
async shutdown() {
|
|
1097
|
-
for (const session of this.instances.values()) {
|
|
1098
|
-
try {
|
|
1099
|
-
session.process.kill('SIGTERM');
|
|
1100
|
-
} catch {}
|
|
1101
|
-
}
|
|
1102
|
-
this.instances.clear();
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
module.exports = {
|
|
1107
|
-
DEFAULT_UBUNTU_BASE_IMAGE_URLS,
|
|
1108
|
-
QemuVmManager,
|
|
1109
|
-
VM_ROOT,
|
|
1110
|
-
allocatePort,
|
|
1111
|
-
buildQemuArgs,
|
|
1112
|
-
defaultBaseImageUrlForArch,
|
|
1113
|
-
downloadFile,
|
|
1114
|
-
guestArchForHost,
|
|
1115
|
-
isHttpUrl,
|
|
1116
|
-
resolveAcceleration,
|
|
1117
|
-
resolveQemuBinary,
|
|
1118
|
-
};
|