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.
Files changed (31) hide show
  1. package/.env.example +4 -0
  2. package/README.md +16 -7
  3. package/flutter_app/lib/features/location/location_service.dart +2 -4
  4. package/flutter_app/lib/main.dart +1 -0
  5. package/flutter_app/lib/main_app_shell.dart +17 -15
  6. package/flutter_app/lib/main_chat.dart +46 -42
  7. package/flutter_app/lib/main_controller.dart +6 -1
  8. package/flutter_app/lib/main_devices.dart +86 -742
  9. package/flutter_app/lib/main_integrations.dart +3 -3
  10. package/flutter_app/lib/main_settings.dart +50 -0
  11. package/flutter_app/lib/main_spacing.dart +18 -0
  12. package/flutter_app/lib/main_theme.dart +9 -0
  13. package/flutter_app/lib/main_unified.dart +3 -3
  14. package/lib/manager.js +33 -0
  15. package/package.json +1 -1
  16. package/server/db/database.js +74 -16
  17. package/server/guest_agent.js +1 -0
  18. package/server/public/.last_build_id +1 -1
  19. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  20. package/server/public/flutter_bootstrap.js +1 -1
  21. package/server/public/main.dart.js +50396 -50271
  22. package/server/services/ai/capabilityHealth.js +2 -3
  23. package/server/services/android/android_bootstrap_worker.js +18 -3
  24. package/server/services/android/controller.js +460 -2753
  25. package/server/services/runtime/backends/local-vm.js +33 -145
  26. package/server/services/runtime/docker-vm-manager.js +392 -0
  27. package/server/services/runtime/manager.js +53 -38
  28. package/server/services/runtime/settings.js +12 -10
  29. package/server/services/runtime/validation.js +4 -1
  30. package/server/utils/deployment.js +8 -2
  31. package/server/services/runtime/qemu.js +0 -1118
@@ -9,6 +9,10 @@ const APK_UPLOAD_ROOT = path.resolve(
9
9
  const MAX_APK_BYTES = Number(process.env.NEOAGENT_ANDROID_APK_MAX_BYTES || 512 * 1024 * 1024);
10
10
  const IDLE_TIMEOUT_MS = Number(process.env.NEOAGENT_VM_IDLE_TIMEOUT_MS || 10 * 60 * 1000);
11
11
 
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
12
16
  function assertPathInside(baseDir, candidatePath, label) {
13
17
  const resolvedBase = path.resolve(baseDir);
14
18
  const resolvedCandidate = path.resolve(candidatePath);
@@ -173,7 +177,7 @@ class VmBrowserProvider {
173
177
  if (result.fullPath) {
174
178
  readablePathCandidates.push(String(result.fullPath));
175
179
  }
176
- if (typeof result.screenshotPath === 'string' && result.screenshotPath.startsWith('/screenshots/')) {
180
+ if (typeof result.screenshotPath === 'string' && result.screenshotPath.trim() !== '') {
177
181
  readablePathCandidates.push(result.screenshotPath);
178
182
  }
179
183
  if (readablePathCandidates.length === 0) {
@@ -181,19 +185,37 @@ class VmBrowserProvider {
181
185
  }
182
186
 
183
187
  let file = null;
184
- for (const candidate of readablePathCandidates) {
185
- try {
186
- file = await this.client.request('POST', '/files/read', {
187
- path: candidate,
188
- encoding: 'base64',
189
- });
190
- if (file?.content) {
191
- break;
188
+ const maxAttempts = 20;
189
+ for (let attempt = 0; attempt < maxAttempts && !file?.content; attempt += 1) {
190
+ for (const candidate of readablePathCandidates) {
191
+ try {
192
+ file = await this.client.request('POST', '/files/read', {
193
+ path: candidate,
194
+ encoding: 'base64',
195
+ });
196
+ if (file?.content) {
197
+ break;
198
+ }
199
+ } catch (error) {
200
+ if (attempt === maxAttempts - 1) {
201
+ console.warn('[Runtime:browser_vm] screenshot materialization read failed', {
202
+ userId: this.userId,
203
+ candidate,
204
+ error: String(error?.message || error),
205
+ });
206
+ }
192
207
  }
193
- } catch {}
208
+ }
209
+ if (!file?.content) {
210
+ await sleep(250);
211
+ }
194
212
  }
195
213
  if (!file?.content) {
196
- if (typeof result.screenshotPath === 'string' && result.screenshotPath.startsWith('/screenshots/')) {
214
+ if (typeof result.screenshotPath === 'string' && result.screenshotPath.trim() !== '') {
215
+ console.warn('[Runtime:browser_vm] unresolved VM screenshot path suppressed', {
216
+ userId: this.userId,
217
+ screenshotPath: result.screenshotPath,
218
+ });
197
219
  return {
198
220
  ...result,
199
221
  screenshotPath: null,
@@ -256,132 +278,6 @@ class VmBrowserProvider {
256
278
  }
257
279
  }
258
280
 
259
- class VmAndroidProvider {
260
- constructor(client, options = {}) {
261
- this.client = client;
262
- this.userId = options.userId;
263
- this.artifactStore = options.artifactStore || null;
264
- }
265
-
266
- async #promoteBinary(pathname, kind, contentType, extension) {
267
- if (!pathname || !this.artifactStore || this.userId == null || /^\/api\/artifacts\//.test(pathname)) {
268
- return { url: pathname, artifactId: null, fullPath: null };
269
- }
270
- const file = await this.client.request('POST', '/files/read', {
271
- path: pathname,
272
- encoding: 'base64',
273
- });
274
- const allocation = this.artifactStore.allocateFile(this.userId, {
275
- kind,
276
- backend: 'vm',
277
- extension,
278
- contentType,
279
- filenameBase: kind,
280
- });
281
- fs.writeFileSync(allocation.storagePath, Buffer.from(String(file.content || ''), 'base64'));
282
- this.artifactStore.finalizeFile(allocation.artifactId, allocation.storagePath);
283
- return {
284
- url: allocation.url,
285
- artifactId: allocation.artifactId,
286
- fullPath: allocation.storagePath,
287
- };
288
- }
289
-
290
- async #promoteText(pathname, kind, contentType, extension) {
291
- if (!pathname || !this.artifactStore || this.userId == null || /^\/api\/artifacts\//.test(pathname)) {
292
- return { url: pathname, artifactId: null };
293
- }
294
- const file = await this.client.request('POST', '/files/read', {
295
- path: pathname,
296
- encoding: 'utf8',
297
- });
298
- const artifact = this.artifactStore.createTextArtifact(this.userId, {
299
- kind,
300
- backend: 'vm',
301
- extension,
302
- contentType,
303
- filenameBase: kind,
304
- content: String(file.content || ''),
305
- });
306
- return {
307
- url: artifact.url,
308
- artifactId: artifact.artifactId,
309
- };
310
- }
311
-
312
- async #materializeObservation(result = {}) {
313
- if (!result || typeof result !== 'object') return result;
314
- let next = { ...result };
315
- if (result.fullPath) {
316
- const screenshot = await this.#promoteBinary(result.fullPath, 'android-screenshot', 'image/png', 'png');
317
- next = {
318
- ...next,
319
- screenshotPath: screenshot.url,
320
- artifactId: screenshot.artifactId || next.artifactId || null,
321
- fullPath: screenshot.fullPath,
322
- };
323
- }
324
- if (result.uiDumpPath && !/^https?:|^\/api\/artifacts\//.test(String(result.uiDumpPath))) {
325
- const dump = await this.#promoteText(result.uiDumpPath, 'android-ui-dump', 'application/xml', 'xml');
326
- next = {
327
- ...next,
328
- uiDumpPath: dump.url,
329
- uiDumpArtifactId: dump.artifactId || next.uiDumpArtifactId || null,
330
- };
331
- }
332
- return next;
333
- }
334
-
335
- getStatus() { return this.client.request('GET', '/android/status'); }
336
- requestStartEmulator(options = {}) { return this.client.request('POST', '/android/start', options); }
337
- startEmulator(options = {}) { return this.requestStartEmulator(options); }
338
- stopEmulator() { return this.client.request('POST', '/android/stop'); }
339
- listDevices() { return this.client.request('GET', '/android/devices').then((result) => result.devices || []); }
340
- async screenshot(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/screenshot', options)); }
341
- async observe(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/observe', options)); }
342
- async dumpUi(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/ui-dump', options)); }
343
- listApps(options = {}) {
344
- const query = options.includeSystem === true ? '?includeSystem=true' : '';
345
- return this.client.request('GET', `/android/apps${query}`);
346
- }
347
- async openApp(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/open-app', options)); }
348
- async openIntent(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/open-intent', options)); }
349
- async tap(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/tap', options)); }
350
- async longPress(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/long-press', options)); }
351
- async type(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/type', options)); }
352
- async swipe(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/swipe', options)); }
353
- async pressKey(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/press-key', options)); }
354
- async waitFor(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/wait-for', options)); }
355
- async shell(options = {}) { return this.#materializeObservation(await this.client.request('POST', '/android/shell', options)); }
356
- async installApk(options = {}) {
357
- const apkPath = assertPathInside(
358
- APK_UPLOAD_ROOT,
359
- String(options.apkPath || ''),
360
- 'APK path',
361
- );
362
- const stat = await fs.promises.stat(apkPath).catch(() => null);
363
- if (!stat || !stat.isFile()) {
364
- throw new Error(`APK not found: ${apkPath}`);
365
- }
366
- if (stat.size > MAX_APK_BYTES) {
367
- throw new Error(`APK is too large: ${stat.size} bytes (limit ${MAX_APK_BYTES}).`);
368
- }
369
- return this.client.requestStream(
370
- 'POST',
371
- '/android/install-apk-stream',
372
- fs.createReadStream(apkPath),
373
- {
374
- contentType: 'application/octet-stream',
375
- contentLength: stat.size,
376
- headers: {
377
- 'x-neoagent-filename': encodeURIComponent(path.basename(apkPath)),
378
- },
379
- },
380
- );
381
- }
382
- close() { return Promise.resolve(); }
383
- }
384
-
385
281
  class LocalVmExecutionBackend {
386
282
  constructor(options = {}) {
387
283
  this.vmManager = options.vmManager;
@@ -486,13 +382,6 @@ class LocalVmExecutionBackend {
486
382
  };
487
383
  }
488
384
 
489
- async getAndroidProviderForUser(userId) {
490
- return new VmAndroidProvider(await this.#clientForUser(userId), {
491
- userId,
492
- artifactStore: this.artifactStore,
493
- });
494
- }
495
-
496
385
  async isGuestAgentReadyForUser(userId, timeoutMs = 1000) {
497
386
  if (!this.vmManager) {
498
387
  return false;
@@ -526,6 +415,5 @@ class LocalVmExecutionBackend {
526
415
  module.exports = {
527
416
  LocalVmExecutionBackend,
528
417
  RuntimeHttpClient,
529
- VmAndroidProvider,
530
418
  VmBrowserProvider,
531
419
  };
@@ -0,0 +1,392 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const http = require('http');
5
+ const net = require('net');
6
+
7
+ const CONTAINER_IMAGE = 'mcr.microsoft.com/playwright:v1.44.0-focal';
8
+ const CONTAINER_LABEL = 'neoagent.managed=1';
9
+
10
+ // ─── Guest agent ─────────────────────────────────────────────────────────────
11
+ // Injected into every container. Pure Node.js — only built-in modules + playwright
12
+ // (installed at /tmp/pw after container start). Served on $AGENT_PORT.
13
+ const GUEST_AGENT = `
14
+ const http = require('http');
15
+ const { spawn } = require('child_process');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const PORT = parseInt(process.env.AGENT_PORT || '3000', 10);
20
+ const SCREENSHOTS = '/tmp/screenshots';
21
+ fs.mkdirSync(SCREENSHOTS, { recursive: true });
22
+
23
+ const procs = new Map();
24
+ let browser = null, page = null, pw = null;
25
+
26
+ function loadPlaywright() {
27
+ if (pw) return pw;
28
+ try { pw = require('/tmp/pw/node_modules/playwright'); return pw; } catch { return null; }
29
+ }
30
+
31
+ function chromiumExec() {
32
+ const base = '/ms-playwright';
33
+ if (!fs.existsSync(base)) return null;
34
+ for (const dir of fs.readdirSync(base)) {
35
+ if (!dir.startsWith('chromium')) continue;
36
+ const bin = base + '/' + dir + '/chrome-linux/chrome';
37
+ if (fs.existsSync(bin)) return bin;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function json(res, data, status) {
43
+ const body = JSON.stringify(data);
44
+ res.writeHead(status || 200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
45
+ res.end(body);
46
+ }
47
+
48
+ const MAX_BODY_BYTES = 1 * 1024 * 1024;
49
+
50
+ function body(req) {
51
+ return new Promise((resolve, reject) => {
52
+ let s = '', size = 0;
53
+ req.on('data', d => {
54
+ size += d.length;
55
+ if (size > MAX_BODY_BYTES) { req.destroy(); reject(Object.assign(new Error('Request body too large'), { status: 413 })); return; }
56
+ s += d;
57
+ });
58
+ req.on('end', () => { try { resolve(JSON.parse(s)); } catch { resolve({}); } });
59
+ req.on('error', err => reject(err));
60
+ });
61
+ }
62
+
63
+ async function screenshot(label) {
64
+ if (!page) return null;
65
+ const p = path.join(SCREENSHOTS, label + '.png');
66
+ await page.screenshot({ path: p, fullPage: false });
67
+ return p;
68
+ }
69
+
70
+ async function ensureBrowser() {
71
+ if (browser) return;
72
+ const lib = loadPlaywright();
73
+ if (!lib) throw new Error('Playwright not ready — container still installing dependencies. Retry in a moment.');
74
+ const exec = chromiumExec();
75
+ browser = await lib.chromium.launch({ headless: true, executablePath: exec || undefined, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
76
+ try {
77
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
78
+ page = await ctx.newPage();
79
+ } catch (err) {
80
+ await browser.close().catch(() => {});
81
+ browser = null;
82
+ page = null;
83
+ throw err;
84
+ }
85
+ }
86
+
87
+ const server = http.createServer(async (req, res) => {
88
+ try {
89
+ const url = req.url.split('?')[0];
90
+
91
+ if (req.method === 'GET' && url === '/health') {
92
+ return json(res, { status: 'ok' });
93
+ }
94
+
95
+ if (req.method === 'GET' && url === '/browser/status') {
96
+ const info = page ? await page.evaluate(() => ({ url: location.href, title: document.title })).catch(() => ({})) : {};
97
+ const pageInfo = page ? { url: info.url || null, title: info.title || null } : null;
98
+ return json(res, { launched: !!browser, pageInfo, pageCount: page ? 1 : 0 });
99
+ }
100
+
101
+ const b = await body(req);
102
+
103
+ // ── CLI execution ──────────────────────────────────────────────────────
104
+ if (req.method === 'POST' && url === '/exec') {
105
+ const child = spawn('sh', ['-c', b.command || 'true'], {
106
+ cwd: b.cwd || '/tmp',
107
+ env: { ...process.env, ...b.env },
108
+ });
109
+ const pid = child.pid;
110
+ let stdout = '', stderr = '';
111
+ procs.set(pid, child);
112
+ child.stdout.on('data', d => { stdout += d; });
113
+ child.stderr.on('data', d => { stderr += d; });
114
+ child.on('close', code => { procs.delete(pid); json(res, { stdout, stderr, code: code ?? 1, pid }); });
115
+ child.on('error', err => { procs.delete(pid); json(res, { stdout, stderr, code: 1, pid, error: err.message }); });
116
+ return;
117
+ }
118
+
119
+ if (req.method === 'POST' && url === '/exec/kill') {
120
+ const child = procs.get(b.pid);
121
+ try { child?.kill('SIGKILL'); } catch {}
122
+ return json(res, { success: true });
123
+ }
124
+
125
+ // ── File access ────────────────────────────────────────────────────────
126
+ if (req.method === 'POST' && url === '/files/read') {
127
+ try {
128
+ const content = fs.readFileSync(b.path, 'base64');
129
+ return json(res, { content });
130
+ } catch (err) {
131
+ return json(res, { error: err.message }, 404);
132
+ }
133
+ }
134
+
135
+ // ── Browser ────────────────────────────────────────────────────────────
136
+ if (req.method === 'POST' && url === '/browser/launch') {
137
+ await ensureBrowser();
138
+ return json(res, { success: true });
139
+ }
140
+
141
+ if (req.method === 'POST' && url === '/browser/close') {
142
+ if (browser) { await browser.close().catch(() => {}); browser = null; page = null; }
143
+ return json(res, { success: true });
144
+ }
145
+
146
+ if (req.method === 'POST' && url === '/browser/navigate') {
147
+ await ensureBrowser();
148
+ await page.goto(b.url, { waitUntil: b.waitUntil || 'domcontentloaded', timeout: b.timeout || 30000 });
149
+ const info = await page.evaluate(() => ({ url: location.href, title: document.title }));
150
+ const screenshotPath = b.screenshot !== false ? await screenshot('nav-' + Date.now()) : null;
151
+ return json(res, { url: info.url, title: info.title, screenshotPath });
152
+ }
153
+
154
+ if (req.method === 'POST' && url === '/browser/screenshot') {
155
+ await ensureBrowser();
156
+ return json(res, { screenshotPath: await screenshot('ss-' + Date.now()) });
157
+ }
158
+
159
+ if (req.method === 'POST' && url === '/browser/click') {
160
+ if (b.selector) await page.click(b.selector, { timeout: 10000 }).catch(() => {});
161
+ const screenshotPath = b.screenshot !== false ? await screenshot('click-' + Date.now()) : null;
162
+ return json(res, { screenshotPath });
163
+ }
164
+
165
+ if (req.method === 'POST' && url === '/browser/click-point') {
166
+ await page.mouse.click(b.x, b.y);
167
+ const screenshotPath = b.screenshot !== false ? await screenshot('clickpt-' + Date.now()) : null;
168
+ return json(res, { screenshotPath });
169
+ }
170
+
171
+ if (req.method === 'POST' && url === '/browser/fill') {
172
+ await page.fill(b.selector, b.value || b.text || '', { timeout: 10000 });
173
+ const screenshotPath = b.screenshot !== false ? await screenshot('fill-' + Date.now()) : null;
174
+ return json(res, { screenshotPath });
175
+ }
176
+
177
+ if (req.method === 'POST' && url === '/browser/type-text') {
178
+ await page.keyboard.type(b.text || '');
179
+ const screenshotPath = b.screenshot !== false ? await screenshot('type-' + Date.now()) : null;
180
+ return json(res, { screenshotPath });
181
+ }
182
+
183
+ if (req.method === 'POST' && url === '/browser/press-key') {
184
+ await page.keyboard.press(b.key || '');
185
+ const screenshotPath = b.screenshot !== false ? await screenshot('key-' + Date.now()) : null;
186
+ return json(res, { screenshotPath });
187
+ }
188
+
189
+ if (req.method === 'POST' && url === '/browser/scroll') {
190
+ await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: b.deltaX || 0, y: b.deltaY || 0 });
191
+ const screenshotPath = b.screenshot !== false ? await screenshot('scroll-' + Date.now()) : null;
192
+ return json(res, { screenshotPath });
193
+ }
194
+
195
+ if (req.method === 'POST' && url === '/browser/extract') {
196
+ const result = b.all
197
+ ? await page.$$(b.selector).then(els => Promise.all(els.map(el => el.getAttribute(b.attribute).catch(() => null))))
198
+ : await page.$(b.selector).then(el => el ? el.getAttribute(b.attribute) : null);
199
+ return json(res, { result });
200
+ }
201
+
202
+ if (req.method === 'POST' && url === '/browser/execute') {
203
+ const result = await page.evaluate(b.script || b.code || '').catch(err => ({ error: err.message }));
204
+ return json(res, { result });
205
+ }
206
+
207
+ json(res, { error: 'Not found' }, 404);
208
+ } catch (err) {
209
+ json(res, { error: err.message }, err.status || 500);
210
+ }
211
+ });
212
+
213
+ server.listen(PORT, '0.0.0.0', () => process.stdout.write('AGENT_READY\\n'));
214
+ `.trim();
215
+
216
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
217
+
218
+ function findAvailablePort() {
219
+ return new Promise((resolve, reject) => {
220
+ const srv = net.createServer();
221
+ srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); });
222
+ srv.on('error', reject);
223
+ });
224
+ }
225
+
226
+ function docker(args, opts = {}) {
227
+ const result = spawnSync('docker', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: opts.timeout || 30000, ...opts });
228
+ if (result.error) throw new Error(`Docker unavailable: ${result.error.message}`);
229
+ if (result.status !== 0) {
230
+ const msg = (result.stderr || result.stdout || '').trim();
231
+ throw new Error(`docker ${args[0]} failed: ${msg || `exit ${result.status}`}`);
232
+ }
233
+ return (result.stdout || '').trim();
234
+ }
235
+
236
+ function isContainerRunning(containerId) {
237
+ try { return docker(['inspect', '--format={{.State.Running}}', containerId]) === 'true'; }
238
+ catch { return false; }
239
+ }
240
+
241
+ function waitForAgent(port, timeoutMs) {
242
+ return new Promise((resolve, reject) => {
243
+ const deadline = Date.now() + (timeoutMs || 180000);
244
+ function attempt() {
245
+ if (Date.now() > deadline) return reject(new Error(`Agent on port ${port} not ready within ${Math.round((timeoutMs || 180000) / 1000)}s`));
246
+ const req = http.get(`http://localhost:${port}/health`, res => {
247
+ if (res.statusCode === 200) return resolve();
248
+ setTimeout(attempt, 3000);
249
+ });
250
+ req.on('error', () => setTimeout(attempt, 3000));
251
+ req.setTimeout(2000, () => { req.destroy(); setTimeout(attempt, 3000); });
252
+ }
253
+ attempt();
254
+ });
255
+ }
256
+
257
+ // ─── DockerVMManager ─────────────────────────────────────────────────────────
258
+
259
+ class DockerVMManager {
260
+ /** @type {Map<string, {baseUrl:string, guestToken:null, process:{pid:number}, getLastError:()=>null, containerId:string}>} */
261
+ instances = new Map();
262
+ #pending = new Map();
263
+ #readiness = null;
264
+ #readinessAt = 0;
265
+
266
+ constructor(options = {}) {
267
+ this.profile = options.runtimeProfile || 'default';
268
+ this.image = options.image || CONTAINER_IMAGE;
269
+ this.memoryMb = options.memoryMb || 2048;
270
+ this.cpus = options.cpus || 2;
271
+ this.#cleanupOrphans();
272
+ }
273
+
274
+ // Remove containers left over from a previous server run.
275
+ #cleanupOrphans() {
276
+ try {
277
+ const ids = docker(['ps', '-a', '-q', '--filter', `label=${CONTAINER_LABEL}`, '--filter', `label=neoagent.profile=${this.profile}`])
278
+ .split('\n').filter(Boolean);
279
+ if (ids.length > 0) {
280
+ docker(['rm', '-f', ...ids]);
281
+ console.log(`[DockerVM:${this.profile}] Removed ${ids.length} orphaned container(s)`);
282
+ }
283
+ } catch { /* Docker may not be available yet — ignore */ }
284
+ }
285
+
286
+ async ensureVm(userId) {
287
+ const key = String(userId || '').trim();
288
+
289
+ // Already running — return immediately.
290
+ const existing = this.instances.get(key);
291
+ if (existing && isContainerRunning(existing.containerId)) return existing;
292
+
293
+ // Already starting for this user — share the in-flight promise.
294
+ const inflight = this.#pending.get(key);
295
+ if (inflight) return inflight;
296
+
297
+ const promise = this.#startContainer(key).finally(() => this.#pending.delete(key));
298
+ this.#pending.set(key, promise);
299
+ return promise;
300
+ }
301
+
302
+ async #startContainer(key) {
303
+ const port = await findAvailablePort();
304
+ console.log(`[DockerVM:${this.profile}] Starting container for user ${key} on port ${port}`);
305
+
306
+ const containerId = docker([
307
+ 'run', '-d',
308
+ '--memory', `${this.memoryMb}m`,
309
+ '--cpus', String(this.cpus),
310
+ '-p', `127.0.0.1:${port}:${port}`,
311
+ '-e', `AGENT_PORT=${port}`,
312
+ '--shm-size=2g',
313
+ '--security-opt', 'no-new-privileges',
314
+ '--label', CONTAINER_LABEL,
315
+ '--label', `neoagent.profile=${this.profile}`,
316
+ '--label', `neoagent.user=${key}`,
317
+ this.image,
318
+ 'sleep', 'infinity',
319
+ ]);
320
+ console.log(`[DockerVM:${this.profile}] Container ${containerId.slice(0, 12)} started`);
321
+
322
+ // Inject agent source file
323
+ spawnSync('docker', ['exec', '-i', containerId, 'sh', '-c', 'cat > /tmp/agent.js'], {
324
+ input: GUEST_AGENT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
325
+ });
326
+
327
+ // Install playwright then start agent (detached so npm install doesn't block)
328
+ docker(['exec', '-d', containerId, 'sh', '-c',
329
+ 'npm install playwright --prefix /tmp/pw > /tmp/pw-install.log 2>&1 && node /tmp/agent.js',
330
+ ]);
331
+
332
+ const session = {
333
+ baseUrl: `http://localhost:${port}`,
334
+ guestToken: null,
335
+ process: { pid: process.pid }, // server PID — always alive while server runs
336
+ getLastError: () => null,
337
+ containerId,
338
+ };
339
+ this.instances.set(key, session);
340
+
341
+ console.log(`[DockerVM:${this.profile}] Waiting for agent on port ${port}…`);
342
+ try {
343
+ await waitForAgent(port, 180000);
344
+ } catch (err) {
345
+ this.instances.delete(key);
346
+ try { docker(['rm', '-f', containerId]); } catch {}
347
+ throw err;
348
+ }
349
+ console.log(`[DockerVM:${this.profile}] Agent ready — ${session.baseUrl}`);
350
+ return session;
351
+ }
352
+
353
+ async killVm(userId) {
354
+ const key = String(userId || '').trim();
355
+ const session = this.instances.get(key);
356
+ this.instances.delete(key);
357
+ if (!session) return;
358
+ try {
359
+ docker(['rm', '-f', session.containerId]);
360
+ console.log(`[DockerVM:${this.profile}] Container ${session.containerId.slice(0, 12)} removed`);
361
+ } catch (err) {
362
+ console.error(`[DockerVM:${this.profile}] Failed to remove container:`, err.message);
363
+ }
364
+ }
365
+
366
+ async shutdown() {
367
+ await Promise.allSettled([...this.#pending.values()]);
368
+ await Promise.allSettled([...this.instances.keys()].map(k => this.killVm(k)));
369
+ }
370
+
371
+ hasVm(userId) {
372
+ const key = String(userId || '').trim();
373
+ const session = this.instances.get(key);
374
+ return Boolean(session && isContainerRunning(session.containerId));
375
+ }
376
+
377
+ // Used by validation.js — cached to avoid a docker call on every status poll.
378
+ getReadiness() {
379
+ const now = Date.now();
380
+ if (this.#readiness && now - this.#readinessAt < 30000) return this.#readiness;
381
+ try {
382
+ docker(['info'], { timeout: 5000 });
383
+ this.#readiness = { ready: true, dockerAvailable: true };
384
+ } catch {
385
+ this.#readiness = { ready: false, dockerAvailable: false };
386
+ }
387
+ this.#readinessAt = now;
388
+ return this.#readiness;
389
+ }
390
+ }
391
+
392
+ module.exports = { DockerVMManager };