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
|
@@ -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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
}
|