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.
@@ -7,6 +7,7 @@ const APK_UPLOAD_ROOT = path.resolve(
7
7
  || path.join(DATA_DIR, 'uploads', 'android-apks'),
8
8
  );
9
9
  const MAX_APK_BYTES = Number(process.env.NEOAGENT_ANDROID_APK_MAX_BYTES || 512 * 1024 * 1024);
10
+ const IDLE_TIMEOUT_MS = Number(process.env.NEOAGENT_VM_IDLE_TIMEOUT_MS || 10 * 60 * 1000);
10
11
 
11
12
  function assertPathInside(baseDir, candidatePath, label) {
12
13
  const resolvedBase = path.resolve(baseDir);
@@ -23,9 +24,10 @@ function assertPathInside(baseDir, candidatePath, label) {
23
24
  }
24
25
 
25
26
  class RuntimeHttpClient {
26
- constructor(baseUrl, token = '') {
27
+ constructor(baseUrl, token = '', options = {}) {
27
28
  this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
28
29
  this.token = String(token || '').trim();
30
+ this.onActivity = options.onActivity || null;
29
31
  }
30
32
 
31
33
  async waitForHealth(options = {}) {
@@ -53,26 +55,38 @@ class RuntimeHttpClient {
53
55
  throw new Error('Timed out waiting for the guest runtime to become ready.');
54
56
  }
55
57
 
56
- async request(method, pathname, body) {
57
- const response = await fetch(`${this.baseUrl}${pathname}`, {
58
- method,
59
- headers: {
60
- 'content-type': 'application/json',
61
- ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
62
- },
63
- body: body === undefined ? undefined : JSON.stringify(body),
64
- });
58
+ async request(method, pathname, body, options = {}) {
59
+ const controller = options.timeoutMs ? new AbortController() : null;
60
+ const timer = controller ? setTimeout(() => controller.abort(new Error(`Request timed out after ${options.timeoutMs} ms.`)), options.timeoutMs) : null;
61
+ try {
62
+ const response = await fetch(`${this.baseUrl}${pathname}`, {
63
+ method,
64
+ headers: {
65
+ 'content-type': 'application/json',
66
+ ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
67
+ },
68
+ body: body === undefined ? undefined : JSON.stringify(body),
69
+ signal: controller?.signal,
70
+ });
65
71
 
66
- const contentType = response.headers.get('content-type') || '';
67
- const payload = contentType.includes('application/json')
68
- ? await response.json().catch(() => ({}))
69
- : { text: await response.text().catch(() => '') };
72
+ const contentType = response.headers.get('content-type') || '';
73
+ const payload = contentType.includes('application/json')
74
+ ? await response.json().catch(() => ({}))
75
+ : { text: await response.text().catch(() => '') };
70
76
 
71
- if (!response.ok) {
72
- const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
73
- throw new Error(errorMessage);
77
+ if (!response.ok) {
78
+ const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
79
+ throw new Error(errorMessage);
80
+ }
81
+ if (response.ok && typeof this.onActivity === 'function') {
82
+ this.onActivity();
83
+ }
84
+ return payload;
85
+ } finally {
86
+ if (timer) {
87
+ clearTimeout(timer);
88
+ }
74
89
  }
75
- return payload;
76
90
  }
77
91
 
78
92
  async requestStream(method, pathname, stream, options = {}) {
@@ -88,6 +102,10 @@ class RuntimeHttpClient {
88
102
  duplex: 'half',
89
103
  });
90
104
 
105
+ if (response.ok && typeof this.onActivity === 'function') {
106
+ this.onActivity();
107
+ }
108
+
91
109
  const contentType = response.headers.get('content-type') || '';
92
110
  const payload = contentType.includes('application/json')
93
111
  ? await response.json().catch(() => ({}))
@@ -300,6 +318,37 @@ class LocalVmExecutionBackend {
300
318
  this.vmManager = options.vmManager;
301
319
  this.token = options.token || process.env.NEOAGENT_VM_GUEST_TOKEN || '';
302
320
  this.artifactStore = options.artifactStore || null;
321
+ this.lastActivity = new Map();
322
+ this.reaperInterval = null;
323
+
324
+ if (IDLE_TIMEOUT_MS > 0) {
325
+ this.#startIdleReaper();
326
+ }
327
+ }
328
+
329
+ #touch(userId) {
330
+ const key = String(userId || '').trim();
331
+ if (key) {
332
+ this.lastActivity.set(key, Date.now());
333
+ }
334
+ }
335
+
336
+ #startIdleReaper() {
337
+ if (this.reaperInterval) return;
338
+ this.reaperInterval = setInterval(async () => {
339
+ const now = Date.now();
340
+ for (const [userId, lastUsed] of this.lastActivity.entries()) {
341
+ if (now - lastUsed > IDLE_TIMEOUT_MS) {
342
+ console.log(`[Runtime] User ${userId} runtime idle for ${Math.round((now - lastUsed) / 1000)}s, shutting down VM.`);
343
+ this.lastActivity.delete(userId);
344
+ try {
345
+ await this.vmManager?.killVm?.(userId);
346
+ } catch (err) {
347
+ console.error(`[Runtime] Failed to shut down idle VM for user ${userId}:`, err.message);
348
+ }
349
+ }
350
+ }
351
+ }, Math.min(IDLE_TIMEOUT_MS, 60 * 1000));
303
352
  }
304
353
 
305
354
  async #clientForUser(userId) {
@@ -307,10 +356,13 @@ class LocalVmExecutionBackend {
307
356
  throw new Error('Local VM manager is not available.');
308
357
  }
309
358
  const session = await this.vmManager.ensureVm(userId);
310
- const client = new RuntimeHttpClient(session.baseUrl, this.token);
359
+ this.#touch(userId);
360
+ const client = new RuntimeHttpClient(session.baseUrl, this.token, {
361
+ onActivity: () => this.#touch(userId),
362
+ });
311
363
  try {
312
364
  await client.waitForHealth({
313
- timeoutMs: Number(process.env.NEOAGENT_VM_BOOT_TIMEOUT_MS || 120000),
365
+ timeoutMs: Number(process.env.NEOAGENT_VM_BOOT_TIMEOUT_MS || 20 * 60 * 1000),
314
366
  });
315
367
  } catch (error) {
316
368
  const runtimeError = typeof session.getLastError === 'function' ? session.getLastError() : '';
@@ -366,7 +418,32 @@ class LocalVmExecutionBackend {
366
418
  });
367
419
  }
368
420
 
421
+ async isGuestAgentReadyForUser(userId, timeoutMs = 1000) {
422
+ if (!this.vmManager) {
423
+ return false;
424
+ }
425
+ const key = String(userId || '').trim();
426
+ if (!key) {
427
+ return false;
428
+ }
429
+ const session = this.vmManager.instances?.get?.(key);
430
+ if (!session?.baseUrl) {
431
+ return false;
432
+ }
433
+ const client = new RuntimeHttpClient(session.baseUrl, this.token);
434
+ try {
435
+ await client.request('GET', '/health', undefined, { timeoutMs });
436
+ return true;
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+
369
442
  async shutdown() {
443
+ if (this.reaperInterval) {
444
+ clearInterval(this.reaperInterval);
445
+ this.reaperInterval = null;
446
+ }
370
447
  await this.vmManager?.shutdown?.();
371
448
  }
372
449
  }
@@ -0,0 +1,450 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+ const { DATA_DIR } = require('../../../runtime/paths');
5
+
6
+ const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
7
+ const GUEST_BOOTSTRAP_ROOT = path.join(VM_ROOT, 'guest-bootstrap');
8
+
9
+ fs.mkdirSync(GUEST_BOOTSTRAP_ROOT, { recursive: true });
10
+
11
+ function encodeGuestToken(value) {
12
+ return Buffer.from(String(value || ''), 'utf8').toString('base64');
13
+ }
14
+
15
+ function createCloudInitScript({
16
+ guestToken,
17
+ hostShareMount,
18
+ hostDataMount = '/mnt/neoagent-data',
19
+ guestAgentPort = 8421,
20
+ }) {
21
+ const guestTokenB64 = encodeGuestToken(guestToken);
22
+ const envFile = '/etc/neoagent/neoagent.env';
23
+ const appDir = '/opt/neoagent';
24
+ const bootstrapMarker = '/var/lib/neoagent/bootstrap-complete';
25
+ const nodeSourceSetupUrl = 'https://deb.nodesource.com/setup_20.x';
26
+
27
+ return [
28
+ '#!/usr/bin/env bash',
29
+ 'set -euo pipefail',
30
+ '',
31
+ 'export DEBIAN_FRONTEND=noninteractive',
32
+ `HOST_SHARE_MOUNT=${JSON.stringify(hostShareMount)}`,
33
+ `HOST_DATA_MOUNT=${JSON.stringify(hostDataMount)}`,
34
+ `APP_DIR=${JSON.stringify(appDir)}`,
35
+ `BOOTSTRAP_MARKER=${JSON.stringify(bootstrapMarker)}`,
36
+ `ENV_FILE=${JSON.stringify(envFile)}`,
37
+ '',
38
+ 'mkdir -p /etc/neoagent /var/lib/neoagent "$HOST_SHARE_MOUNT" "$HOST_DATA_MOUNT" "$APP_DIR"',
39
+ '',
40
+ '# Ensure the 9p virtio filesystem driver is loaded',
41
+ 'modprobe 9p 2>/dev/null || true',
42
+ 'modprobe 9pnet_virtio 2>/dev/null || true',
43
+ '',
44
+ 'if ! grep -qs "neoagent-host" /etc/fstab; then',
45
+ ' echo "neoagent-host ${HOST_SHARE_MOUNT} 9p trans=virtio,version=9p2000.L,msize=104857600,ro 0 0" >> /etc/fstab',
46
+ 'fi',
47
+ 'if ! grep -qs "neoagent-data" /etc/fstab; then',
48
+ ' echo "neoagent-data ${HOST_DATA_MOUNT} 9p trans=virtio,version=9p2000.L,msize=104857600,rw 0 0" >> /etc/fstab',
49
+ 'fi',
50
+ '',
51
+ 'mount "$HOST_SHARE_MOUNT" >/dev/null 2>&1 || mount -a >/dev/null 2>&1 || true',
52
+ 'mount "$HOST_DATA_MOUNT" >/dev/null 2>&1 || mount -a >/dev/null 2>&1 || true',
53
+ '',
54
+ '# Redirect logs to the host-writable share once mounted',
55
+ 'LOG_FILE="${HOST_DATA_MOUNT}/bootstrap.log"',
56
+ 'exec >"$LOG_FILE" 2>&1',
57
+ 'echo "NeoAgent guest bootstrap starting."',
58
+ '',
59
+ 'apt-get update',
60
+ 'apt-get install -y --no-install-recommends \\',
61
+ ' curl \\',
62
+ ' ca-certificates \\',
63
+ ' gnupg \\',
64
+ ' openjdk-17-jre-headless \\',
65
+ ' git \\',
66
+ ' rsync \\',
67
+ ' build-essential \\',
68
+ ' python3 \\',
69
+ ' unzip \\',
70
+ ' libatk1.0-0 \\',
71
+ ' libatk-bridge2.0-0 \\',
72
+ ' libatspi2.0-0 \\',
73
+ ' libcups2 \\',
74
+ ' libx11-xcb1 \\',
75
+ ' libgtk-3-0 \\',
76
+ ' libnss3 \\',
77
+ ' libnspr4 \\',
78
+ ' libxcomposite1 \\',
79
+ ' libxdamage1 \\',
80
+ ' libxrandr2 \\',
81
+ ' libxkbcommon0 \\',
82
+ ' libasound2t64 \\',
83
+ ' libgbm1 \\',
84
+ ' libdrm2 \\',
85
+ ' libdbus-1-3 \\',
86
+ ' libpango-1.0-0 \\',
87
+ ' libpangocairo-1.0-0 \\',
88
+ ' libxshmfence1',
89
+ 'apt-get clean >/dev/null 2>&1 || true',
90
+ 'rm -rf /var/lib/apt/lists/*',
91
+ '',
92
+ 'if [ -d "$HOST_SHARE_MOUNT" ]; then',
93
+ ' SYNC_PATHS=(',
94
+ ' server/guest-agent.package.json:package.json',
95
+ ' runtime/env.js',
96
+ ' runtime/paths.js',
97
+ ' server/guest_agent.js',
98
+ ' server/services/cli',
99
+ ' server/services/browser',
100
+ ' server/services/android',
101
+ ' )',
102
+ ' for relPath in "${SYNC_PATHS[@]}"; do',
103
+ ' sourceRelPath="$relPath"',
104
+ ' targetRelPath="$relPath"',
105
+ ' if [[ "$relPath" == *:* ]]; then',
106
+ ' sourceRelPath="${relPath%%:*}"',
107
+ ' targetRelPath="${relPath##*:}"',
108
+ ' fi',
109
+ ' sourcePath="$HOST_SHARE_MOUNT/$sourceRelPath"',
110
+ ' targetPath="$APP_DIR/$targetRelPath"',
111
+ ' if [ ! -e "$sourcePath" ]; then',
112
+ ' echo "Required host path is missing: $relPath" >&2',
113
+ ' exit 1',
114
+ ' fi',
115
+ ' mkdir -p "$(dirname "$targetPath")"',
116
+ ' if [ -d "$sourcePath" ]; then',
117
+ ' mkdir -p "$targetPath"',
118
+ ' rsync -a --delete "$sourcePath"/ "$targetPath"/',
119
+ ' else',
120
+ ' rsync -a "$sourcePath" "$targetPath"',
121
+ ' fi',
122
+ ' done',
123
+ 'else',
124
+ ' echo "Host repo share is not available." >&2',
125
+ ' exit 1',
126
+ 'fi',
127
+ '',
128
+ 'if ! command -v node >/dev/null 2>&1 || ! node -e "process.exit(Number(process.versions.node.split(\'.\')[0]) >= 20 ? 0 : 1)"; then',
129
+ ' curl -fsSL ' + JSON.stringify(nodeSourceSetupUrl) + ' | bash -',
130
+ ' apt-get install -y --no-install-recommends nodejs',
131
+ ' apt-get clean >/dev/null 2>&1 || true',
132
+ ' rm -rf /var/lib/apt/lists/*',
133
+ 'fi',
134
+ '',
135
+ `printf '%s\n' ${JSON.stringify(`NEOAGENT_VM_GUEST_TOKEN_B64=${guestTokenB64}`)} > "$ENV_FILE"`,
136
+ `printf '%s\n' ${JSON.stringify(`NEOAGENT_GUEST_AGENT_PORT=${guestAgentPort}`)} >> "$ENV_FILE"`,
137
+ 'chmod 0600 "$ENV_FILE"',
138
+ '',
139
+ 'cd "$APP_DIR"',
140
+ 'if [ ! -d node_modules ] || [ ! -f node_modules/.neoagent-bootstrap-stamp ] || [ package.json -nt node_modules/.neoagent-bootstrap-stamp ]; then',
141
+ ' export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1',
142
+ ' npm install --omit=dev --no-audit --no-fund',
143
+ ' mkdir -p node_modules',
144
+ ' date > node_modules/.neoagent-bootstrap-stamp',
145
+ 'fi',
146
+ '',
147
+ '# Install Playwright browser binaries (skipped if already present)',
148
+ 'PLAYWRIGHT_BROWSERS_PATH="$APP_DIR/.playwright-browsers"',
149
+ 'PLAYWRIGHT_STAMP="$PLAYWRIGHT_BROWSERS_PATH/.chromium-installed"',
150
+ 'if [ ! -f "$PLAYWRIGHT_STAMP" ]; then',
151
+ ' mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"',
152
+ ' PLAYWRIGHT_BROWSERS_PATH="$PLAYWRIGHT_BROWSERS_PATH" npx playwright install chromium --with-deps || \\',
153
+ ' PLAYWRIGHT_BROWSERS_PATH="$PLAYWRIGHT_BROWSERS_PATH" node ./node_modules/playwright-chromium/install.js || true',
154
+ ' date > "$PLAYWRIGHT_STAMP"',
155
+ 'fi',
156
+ 'export PLAYWRIGHT_BROWSERS_PATH',
157
+ '',
158
+ 'systemctl daemon-reload',
159
+ 'systemctl enable neoagent-guest-agent.service',
160
+ 'systemctl restart neoagent-guest-agent.service',
161
+ 'touch "$BOOTSTRAP_MARKER"',
162
+ 'echo "NeoAgent guest bootstrap completed."',
163
+ '',
164
+ ].join('\n');
165
+ }
166
+
167
+ function createCloudInitUserData({
168
+ guestToken,
169
+ hostShareMount = '/mnt/neoagent-host',
170
+ hostDataMount = '/mnt/neoagent-data',
171
+ guestAgentPort = 8421,
172
+ }) {
173
+ const guestTokenB64 = encodeGuestToken(guestToken);
174
+ const bootstrapScript = createCloudInitScript({
175
+ guestToken,
176
+ hostShareMount,
177
+ hostDataMount,
178
+ guestAgentPort,
179
+ });
180
+
181
+ return [
182
+ '#cloud-config',
183
+ 'package_update: false',
184
+ 'write_files:',
185
+ ' - path: /etc/neoagent/neoagent.env',
186
+ " permissions: '0600'",
187
+ ' owner: root:root',
188
+ ' content: |',
189
+ ` NEOAGENT_VM_GUEST_TOKEN_B64=${guestTokenB64}`,
190
+ ` NEOAGENT_GUEST_AGENT_PORT=${guestAgentPort}`,
191
+ ' - path: /usr/local/bin/neoagent-guest-bootstrap.sh',
192
+ " permissions: '0755'",
193
+ ' owner: root:root',
194
+ ' content: |',
195
+ ...bootstrapScript.split('\n').map((line) => ` ${line}`),
196
+ ' - path: /etc/systemd/system/neoagent-guest-agent.service',
197
+ " permissions: '0644'",
198
+ ' owner: root:root',
199
+ ' content: |',
200
+ ' [Unit]',
201
+ ' Description=NeoAgent guest agent',
202
+ ' After=network-online.target',
203
+ ' Wants=network-online.target',
204
+ '',
205
+ ' [Service]',
206
+ ' Type=simple',
207
+ ' EnvironmentFile=/etc/neoagent/neoagent.env',
208
+ ' Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/neoagent/.playwright-browsers',
209
+ ' WorkingDirectory=/opt/neoagent',
210
+ ' ExecStart=/usr/bin/env node /opt/neoagent/server/guest_agent.js',
211
+ ' Restart=always',
212
+ ' RestartSec=5',
213
+ '',
214
+ ' [Install]',
215
+ ' WantedBy=multi-user.target',
216
+ ' - path: /etc/systemd/system/neoagent-guest-bootstrap.service',
217
+ " permissions: '0644'",
218
+ ' owner: root:root',
219
+ ' content: |',
220
+ ' [Unit]',
221
+ ' Description=NeoAgent guest bootstrap',
222
+ ' After=network-online.target',
223
+ ' Wants=network-online.target',
224
+ '',
225
+ ' [Service]',
226
+ ' Type=oneshot',
227
+ ' ExecStart=/usr/local/bin/neoagent-guest-bootstrap.sh',
228
+ ' RemainAfterExit=yes',
229
+ '',
230
+ ' [Install]',
231
+ ' WantedBy=multi-user.target',
232
+ 'runcmd:',
233
+ ' - [bash, -lc, "systemctl daemon-reload"]',
234
+ ' - [bash, -lc, "systemctl enable neoagent-guest-bootstrap.service"]',
235
+ ' - [bash, -lc, "systemctl start neoagent-guest-bootstrap.service"]',
236
+ '',
237
+ ].join('\n');
238
+ }
239
+
240
+ function createCloudInitMetaData({ instanceId, localHostName }) {
241
+ return [
242
+ `instance-id: ${instanceId}`,
243
+ `local-hostname: ${localHostName}`,
244
+ '',
245
+ ].join('\n');
246
+ }
247
+
248
+ function commandExists(command) {
249
+ const probe = spawnSync(
250
+ process.platform === 'win32' ? 'where' : 'bash',
251
+ process.platform === 'win32' ? [command] : ['-lc', `command -v "${command}"`],
252
+ { stdio: 'ignore' },
253
+ );
254
+ return probe.status === 0;
255
+ }
256
+
257
+ function parseDiskutilMountPoint(output) {
258
+ const match = String(output || '').match(/Mount Point:\s+(.+)/);
259
+ return match ? match[1].trim() : null;
260
+ }
261
+
262
+ function copySeedFilesToVolume(volumePath, sourceDir) {
263
+ for (const entry of ['user-data', 'meta-data', 'startup.nsh']) {
264
+ const sourcePath = path.join(sourceDir, entry);
265
+ const targetPath = path.join(volumePath, entry);
266
+ fs.copyFileSync(sourcePath, targetPath);
267
+ }
268
+ }
269
+
270
+ function createFatSeedImage(sourceDir, imagePath) {
271
+ if (process.platform === 'win32') {
272
+ throw new Error('Creating a FAT seed image is not supported on Windows yet.');
273
+ }
274
+
275
+ const hdiutilAvailable = commandExists('hdiutil');
276
+ const newfsMsdosPath = commandExists('/sbin/newfs_msdos') ? '/sbin/newfs_msdos' : (commandExists('newfs_msdos') ? 'newfs_msdos' : null);
277
+ const diskutilAvailable = commandExists('diskutil');
278
+ if (!hdiutilAvailable || !newfsMsdosPath || !diskutilAvailable) {
279
+ throw new Error('Required disk image tools are not available.');
280
+ }
281
+
282
+ fs.mkdirSync(path.dirname(imagePath), { recursive: true });
283
+ fs.rmSync(imagePath, { force: true });
284
+ fs.writeFileSync(imagePath, Buffer.alloc(32 * 1024 * 1024));
285
+
286
+ let device = null;
287
+ try {
288
+ const attachResult = spawnSync('hdiutil', ['attach', '-nomount', imagePath], {
289
+ encoding: 'utf8',
290
+ stdio: ['ignore', 'pipe', 'pipe'],
291
+ });
292
+ if (attachResult.status !== 0) {
293
+ throw new Error(
294
+ String(attachResult.stderr || attachResult.stdout || attachResult.error?.message || 'Failed to attach FAT seed image.')
295
+ .trim(),
296
+ );
297
+ }
298
+
299
+ device = String(attachResult.stdout || '').trim().split('\n').find(Boolean)?.split(/\s+/)[0] || null;
300
+ if (!device) {
301
+ throw new Error('Failed to resolve the temporary FAT seed device.');
302
+ }
303
+ const rawDevice = device.replace('/dev/disk', '/dev/rdisk');
304
+
305
+ const formatResult = spawnSync(newfsMsdosPath, ['-v', 'CIDATA', rawDevice], {
306
+ encoding: 'utf8',
307
+ stdio: ['ignore', 'pipe', 'pipe'],
308
+ });
309
+ if (formatResult.status !== 0) {
310
+ throw new Error(
311
+ String(formatResult.stderr || formatResult.stdout || formatResult.error?.message || 'Failed to format FAT seed image.')
312
+ .trim(),
313
+ );
314
+ }
315
+
316
+ const mountResult = spawnSync('diskutil', ['mount', device], {
317
+ encoding: 'utf8',
318
+ stdio: ['ignore', 'pipe', 'pipe'],
319
+ });
320
+ if (mountResult.status !== 0) {
321
+ throw new Error(
322
+ String(mountResult.stderr || mountResult.stdout || mountResult.error?.message || 'Failed to mount FAT seed image.')
323
+ .trim(),
324
+ );
325
+ }
326
+
327
+ const mountPoint = parseDiskutilMountPoint(mountResult.stdout)
328
+ || parseDiskutilMountPoint(spawnSync('diskutil', ['info', device], {
329
+ encoding: 'utf8',
330
+ stdio: ['ignore', 'pipe', 'pipe'],
331
+ }).stdout)
332
+ || `/Volumes/CIDATA`;
333
+
334
+ if (!fs.existsSync(mountPoint)) {
335
+ throw new Error(`Mounted FAT seed volume is missing at ${mountPoint}.`);
336
+ }
337
+
338
+ copySeedFilesToVolume(mountPoint, sourceDir);
339
+ return imagePath;
340
+ } finally {
341
+ if (device) {
342
+ try {
343
+ spawnSync('diskutil', ['unmount', 'force', device], { stdio: 'ignore' });
344
+ } catch {}
345
+ try {
346
+ spawnSync('hdiutil', ['detach', device], { stdio: 'ignore' });
347
+ } catch {}
348
+ }
349
+ }
350
+ }
351
+
352
+ function createSeedIso(sourceDir, isoPath) {
353
+ const candidates = [
354
+ {
355
+ command: 'xorriso',
356
+ args: ['-as', 'mkisofs', '-output', isoPath, '-volid', 'CIDATA', '-joliet', '-rock', sourceDir],
357
+ },
358
+ {
359
+ command: 'cloud-localds',
360
+ args: [isoPath, path.join(sourceDir, 'user-data'), path.join(sourceDir, 'meta-data')],
361
+ },
362
+ {
363
+ command: 'hdiutil',
364
+ args: ['makehybrid', '-ov', '-o', isoPath, '-iso', '-joliet', '-iso-volume-name', 'CIDATA', '-joliet-volume-name', 'CIDATA', sourceDir],
365
+ },
366
+ {
367
+ command: 'mkisofs',
368
+ args: ['-o', isoPath, '-V', 'CIDATA', '-J', '-r', sourceDir],
369
+ },
370
+ ];
371
+
372
+ let lastError = null;
373
+ for (const candidate of candidates) {
374
+ if (!commandExists(candidate.command)) {
375
+ continue;
376
+ }
377
+
378
+ const result = spawnSync(candidate.command, candidate.args, {
379
+ encoding: 'utf8',
380
+ stdio: ['ignore', 'pipe', 'pipe'],
381
+ });
382
+ if (result.status === 0 && fs.existsSync(isoPath)) {
383
+ return isoPath;
384
+ }
385
+ lastError = new Error(
386
+ String(result.stderr || result.stdout || result.error?.message || `exit status ${result.status ?? 'unknown'}`).trim() || `Failed to create seed ISO with ${candidate.command}.`
387
+ );
388
+ }
389
+
390
+ throw new Error(`Unable to create cloud-init seed ISO: ${lastError ? lastError.message : 'no supported ISO writer was found.'}`);
391
+ }
392
+
393
+ function ensureGuestBootstrapSeed({
394
+ userRoot,
395
+ guestToken,
396
+ hostShareMount = '/mnt/neoagent-host',
397
+ guestAgentPort = 8421,
398
+ }) {
399
+ const seedRoot = path.join(userRoot, 'cloud-init');
400
+ const seedDir = path.join(seedRoot, 'seed');
401
+ const seedImagePath = path.join(seedRoot, 'cidata.img');
402
+ const isoPath = path.join(seedRoot, 'cidata.iso');
403
+ fs.mkdirSync(seedDir, { recursive: true });
404
+
405
+ const userDataPath = path.join(seedDir, 'user-data');
406
+ const metaDataPath = path.join(seedDir, 'meta-data');
407
+ const startupNshPath = path.join(seedDir, 'startup.nsh');
408
+ const userData = createCloudInitUserData({ guestToken, hostShareMount, guestAgentPort });
409
+ const metaData = createCloudInitMetaData({
410
+ instanceId: `neoagent-${path.basename(userRoot)}`,
411
+ localHostName: `neoagent-${path.basename(userRoot)}`,
412
+ });
413
+ const startupNsh = [
414
+ '@echo -off',
415
+ 'map -r',
416
+ 'fs0:',
417
+ '\\EFI\\ubuntu\\shimx64.efi',
418
+ '\\EFI\\ubuntu\\grubx64.efi',
419
+ '\\EFI\\BOOT\\BOOTX64.EFI',
420
+ ].join('\r\n');
421
+
422
+ fs.writeFileSync(userDataPath, userData);
423
+ fs.writeFileSync(metaDataPath, metaData);
424
+ fs.writeFileSync(startupNshPath, startupNsh);
425
+ let createdSeedPath = null;
426
+ try {
427
+ createdSeedPath = createFatSeedImage(seedDir, seedImagePath);
428
+ } catch (error) {
429
+ createdSeedPath = createSeedIso(seedDir, isoPath);
430
+ }
431
+
432
+ return {
433
+ seedRoot,
434
+ seedDir,
435
+ seedImagePath: createdSeedPath,
436
+ isoPath: createdSeedPath === isoPath ? isoPath : null,
437
+ userDataPath,
438
+ metaDataPath,
439
+ startupNshPath,
440
+ hostShareMount,
441
+ };
442
+ }
443
+
444
+ module.exports = {
445
+ createCloudInitMetaData,
446
+ createCloudInitUserData,
447
+ createSeedIso,
448
+ ensureGuestBootstrapSeed,
449
+ GUEST_BOOTSTRAP_ROOT,
450
+ };
@@ -40,6 +40,10 @@ class RuntimeManager {
40
40
  return backend.executeCommand(userId, command, options);
41
41
  }
42
42
 
43
+ hasVmForUser(userId) {
44
+ return Boolean(this.vmBackend?.vmManager?.hasVm?.(userId));
45
+ }
46
+
43
47
  async killCommand(userId, pid, reason = 'aborted') {
44
48
  return this.vmBackend.killCommand(userId, pid, reason);
45
49
  }
@@ -60,6 +64,13 @@ class RuntimeManager {
60
64
  return this.vmBackend.getAndroidProviderForUser(userId);
61
65
  }
62
66
 
67
+ async isGuestAgentReadyForUser(userId, timeoutMs = 1000) {
68
+ if (typeof this.vmBackend?.isGuestAgentReadyForUser !== 'function') {
69
+ return false;
70
+ }
71
+ return this.vmBackend.isGuestAgentReadyForUser(userId, timeoutMs);
72
+ }
73
+
63
74
  async shutdown() {
64
75
  await Promise.allSettled([this.vmBackend.shutdown()]);
65
76
  }