neoagent 3.0.1-beta.8 → 3.1.0

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 (100) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.css +78 -0
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +165 -42
  44. package/server/admin/index.html +80 -5
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/http/routes.js +1 -0
  48. package/server/http/static.js +4 -2
  49. package/server/public/.last_build_id +1 -1
  50. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  51. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  52. package/server/public/canvaskit/wimp.wasm +0 -0
  53. package/server/public/flutter_bootstrap.js +2 -2
  54. package/server/public/main.dart.js +91077 -87037
  55. package/server/routes/account.js +53 -0
  56. package/server/routes/admin.js +345 -64
  57. package/server/routes/agents.js +203 -21
  58. package/server/routes/billing.js +5 -0
  59. package/server/routes/browser.js +8 -1
  60. package/server/routes/recordings.js +96 -6
  61. package/server/routes/screenHistory.js +140 -2
  62. package/server/routes/timeline.js +43 -0
  63. package/server/services/account/erasure.js +263 -0
  64. package/server/services/ai/hooks.js +4 -1
  65. package/server/services/ai/loop/agent_engine_core.js +8 -1
  66. package/server/services/ai/loop/blank_recovery.js +36 -0
  67. package/server/services/ai/loop/conversation_loop.js +166 -33
  68. package/server/services/ai/messagingFallback.js +22 -0
  69. package/server/services/ai/rate_limits.js +28 -5
  70. package/server/services/ai/systemPrompt.js +6 -5
  71. package/server/services/ai/taskAnalysis.js +2 -0
  72. package/server/services/ai/toolEvidence.js +15 -0
  73. package/server/services/ai/toolResult.js +40 -0
  74. package/server/services/ai/tools.js +163 -4
  75. package/server/services/android/controller.js +6 -2
  76. package/server/services/billing/plans.js +2 -1
  77. package/server/services/browser/anti_detection.js +192 -0
  78. package/server/services/browser/controller.js +180 -54
  79. package/server/services/desktop/auth.js +3 -0
  80. package/server/services/desktop/registry.js +50 -2
  81. package/server/services/integrations/google/calendar.js +22 -14
  82. package/server/services/manager.js +12 -42
  83. package/server/services/memory/ingestion_chunking.js +268 -0
  84. package/server/services/messaging/telnyx.js +9 -8
  85. package/server/services/recordings/manager.js +60 -27
  86. package/server/services/runtime/backends/local-vm.js +40 -22
  87. package/server/services/runtime/docker-vm-manager.js +157 -266
  88. package/server/services/runtime/guest_bootstrap.js +17 -5
  89. package/server/services/runtime/guest_image.js +188 -0
  90. package/server/services/runtime/manager.js +0 -1
  91. package/server/services/runtime/validation.js +3 -8
  92. package/server/services/social_video/service.js +60 -10
  93. package/server/services/tasks/runtime.js +234 -9
  94. package/server/services/tasks/schedule_utils.js +5 -5
  95. package/server/services/tasks/task_repository.js +13 -0
  96. package/server/services/timeline/service.js +558 -0
  97. package/server/services/wearable/gateway.js +1 -1
  98. package/server/services/websocket.js +21 -3
  99. package/server/services/desktop/screenRecorder.js +0 -292
  100. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -1,19 +1,25 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawnSync } = require('child_process');
4
+ const crypto = require('crypto');
4
5
  const fs = require('fs');
5
6
  const path = require('path');
6
7
  const http = require('http');
7
8
  const net = require('net');
8
9
  const { AGENT_DATA_DIR } = require('../../../runtime/paths');
9
10
  const { sanitizeWorkspaceKey } = require('../workspace/manager');
11
+ const { GuestImageBuilder, dockerAvailable } = require('./guest_image');
10
12
 
11
- const CONTAINER_IMAGE = 'mcr.microsoft.com/playwright:v1.44.0-focal';
12
13
  const CONTAINER_LABEL = 'neoagent.managed=1';
13
14
  // The per-user host workspace is bind-mounted here so the shell (execute_command)
14
15
  // and the workspace file tools (read_file/write_file/list_directory/search_files)
15
- // operate on the SAME files. The guest agent defaults its shell cwd to this path.
16
+ // operate on the SAME files. The guest agent's shell defaults its cwd to this path.
16
17
  const GUEST_WORKSPACE = '/workspace';
18
+ // Where the guest agent keeps its own runtime files (screenshots, uploads) inside
19
+ // the container — separate from the user workspace bind mount.
20
+ const GUEST_RUNTIME_HOME = '/opt/neoagent/.runtime';
21
+
22
+ const IS_LINUX = process.platform === 'linux';
17
23
 
18
24
  // Host path of a user's workspace — must match WorkspaceManager's layout exactly
19
25
  // (AGENT_DATA_DIR/workspaces/<sanitized key>) so both sides see one directory.
@@ -21,233 +27,25 @@ function hostWorkspaceDir(key) {
21
27
  return path.join(AGENT_DATA_DIR, 'workspaces', sanitizeWorkspaceKey(key));
22
28
  }
23
29
 
24
- // ─── Guest agent ─────────────────────────────────────────────────────────────
25
- // Injected into every container. Pure Node.js only built-in modules + playwright
26
- // (installed at /tmp/pw after container start). Served on $AGENT_PORT.
27
- const GUEST_AGENT = `
28
- const http = require('http');
29
- const { spawn } = require('child_process');
30
- const fs = require('fs');
31
- const path = require('path');
32
-
33
- const PORT = parseInt(process.env.AGENT_PORT || '3000', 10);
34
- const SCREENSHOTS = '/tmp/screenshots';
35
- fs.mkdirSync(SCREENSHOTS, { recursive: true });
36
-
37
- const procs = new Map();
38
- let browser = null, page = null, pw = null;
39
-
40
- function loadPlaywright() {
41
- if (pw) return pw;
42
- try { pw = require('/tmp/pw/node_modules/playwright'); return pw; } catch { return null; }
43
- }
44
-
45
- function chromiumExec() {
46
- const base = '/ms-playwright';
47
- if (!fs.existsSync(base)) return null;
48
- for (const dir of fs.readdirSync(base)) {
49
- if (!dir.startsWith('chromium')) continue;
50
- const bin = base + '/' + dir + '/chrome-linux/chrome';
51
- if (fs.existsSync(bin)) return bin;
52
- }
53
- return null;
54
- }
55
-
56
- function json(res, data, status) {
57
- const body = JSON.stringify(data);
58
- res.writeHead(status || 200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
59
- res.end(body);
30
+ // Workspace bind mount. On SELinux-enforcing Linux hosts (Fedora/RHEL/Rocky) the
31
+ // `:Z` suffix relabels the directory to a container-private context so the guest
32
+ // can read/write it; it is a no-op elsewhere. On macOS, Docker Desktop maps file
33
+ // ownership automatically, so no suffix is needed.
34
+ function workspaceMount(hostDir) {
35
+ return IS_LINUX ? `${hostDir}:${GUEST_WORKSPACE}:Z` : `${hostDir}:${GUEST_WORKSPACE}`;
60
36
  }
61
37
 
62
- const MAX_BODY_BYTES = 1 * 1024 * 1024;
63
-
64
- function body(req) {
65
- return new Promise((resolve, reject) => {
66
- let s = '', size = 0;
67
- req.on('data', d => {
68
- size += d.length;
69
- if (size > MAX_BODY_BYTES) { req.destroy(); reject(Object.assign(new Error('Request body too large'), { status: 413 })); return; }
70
- s += d;
71
- });
72
- req.on('end', () => { try { resolve(JSON.parse(s)); } catch { resolve({}); } });
73
- req.on('error', err => reject(err));
74
- });
75
- }
76
-
77
- async function screenshot(label) {
78
- if (!page) return null;
79
- const p = path.join(SCREENSHOTS, label + '.png');
80
- await page.screenshot({ path: p, fullPage: false });
81
- return p;
82
- }
83
-
84
- async function ensureBrowser() {
85
- if (browser) return;
86
- const lib = loadPlaywright();
87
- if (!lib) throw new Error('Playwright not ready — container still installing dependencies. Retry in a moment.');
88
- const exec = chromiumExec();
89
- browser = await lib.chromium.launch({ headless: true, executablePath: exec || undefined, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
90
- try {
91
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
92
- page = await ctx.newPage();
93
- } catch (err) {
94
- await browser.close().catch(() => {});
95
- browser = null;
96
- page = null;
97
- throw err;
38
+ // On Linux the container shares the host kernel, so files the guest creates in the
39
+ // bind mount would be owned by the container user. Run as the host uid:gid so the
40
+ // NeoAgent process (and its file tools) can read and modify them. On macOS the
41
+ // Docker Desktop VM virtualizes ownership, so the default container user is fine.
42
+ function hostUserArgs() {
43
+ if (IS_LINUX && typeof process.getuid === 'function') {
44
+ return ['--user', `${process.getuid()}:${process.getgid()}`];
98
45
  }
46
+ return [];
99
47
  }
100
48
 
101
- const server = http.createServer(async (req, res) => {
102
- try {
103
- const url = req.url.split('?')[0];
104
-
105
- if (req.method === 'GET' && url === '/health') {
106
- return json(res, { status: 'ok' });
107
- }
108
-
109
- if (req.method === 'GET' && url === '/browser/status') {
110
- const info = page ? await page.evaluate(() => ({ url: location.href, title: document.title })).catch(() => ({})) : {};
111
- const pageInfo = page ? { url: info.url || null, title: info.title || null } : null;
112
- return json(res, { launched: !!browser, pageInfo, pageCount: page ? 1 : 0 });
113
- }
114
-
115
- const b = await body(req);
116
-
117
- // ── CLI execution ──────────────────────────────────────────────────────
118
- if (req.method === 'POST' && url === '/exec') {
119
- const timeoutMs = Math.min(Number(b.timeout) || 15 * 60 * 1000, 20 * 60 * 1000);
120
- const child = spawn('sh', ['-c', b.command || 'true'], {
121
- cwd: b.cwd || '/workspace',
122
- env: { ...process.env, ...b.env },
123
- });
124
- const pid = child.pid;
125
- let stdout = '', stderr = '', settled = false;
126
- procs.set(pid, child);
127
- child.stdout.on('data', d => { stdout += d; });
128
- child.stderr.on('data', d => { stderr += d; });
129
- const timer = setTimeout(() => {
130
- if (settled) return;
131
- settled = true;
132
- procs.delete(pid);
133
- try { child.kill('SIGKILL'); } catch {}
134
- json(res, { stdout, stderr, exitCode: null, code: null, pid, timedOut: true, killed: true });
135
- }, timeoutMs);
136
- child.on('close', code => {
137
- if (settled) return;
138
- settled = true;
139
- clearTimeout(timer);
140
- procs.delete(pid);
141
- const exitCode = code ?? 1;
142
- json(res, { stdout, stderr, exitCode, code: exitCode, pid, timedOut: false, killed: false });
143
- });
144
- child.on('error', err => {
145
- if (settled) return;
146
- settled = true;
147
- clearTimeout(timer);
148
- procs.delete(pid);
149
- json(res, { stdout, stderr, exitCode: 1, code: 1, pid, error: err.message, timedOut: false, killed: false });
150
- });
151
- return;
152
- }
153
-
154
- if (req.method === 'POST' && url === '/exec/kill') {
155
- const child = procs.get(b.pid);
156
- try { child?.kill('SIGKILL'); } catch {}
157
- return json(res, { success: true });
158
- }
159
-
160
- // ── File access ────────────────────────────────────────────────────────
161
- if (req.method === 'POST' && url === '/files/read') {
162
- try {
163
- const content = fs.readFileSync(b.path, 'base64');
164
- return json(res, { content });
165
- } catch (err) {
166
- return json(res, { error: err.message }, 404);
167
- }
168
- }
169
-
170
- // ── Browser ────────────────────────────────────────────────────────────
171
- if (req.method === 'POST' && url === '/browser/launch') {
172
- await ensureBrowser();
173
- return json(res, { success: true });
174
- }
175
-
176
- if (req.method === 'POST' && url === '/browser/close') {
177
- if (browser) { await browser.close().catch(() => {}); browser = null; page = null; }
178
- return json(res, { success: true });
179
- }
180
-
181
- if (req.method === 'POST' && url === '/browser/navigate') {
182
- await ensureBrowser();
183
- await page.goto(b.url, { waitUntil: b.waitUntil || 'domcontentloaded', timeout: b.timeout || 30000 });
184
- const info = await page.evaluate(() => ({ url: location.href, title: document.title }));
185
- const screenshotPath = b.screenshot !== false ? await screenshot('nav-' + Date.now()) : null;
186
- return json(res, { url: info.url, title: info.title, screenshotPath });
187
- }
188
-
189
- if (req.method === 'POST' && url === '/browser/screenshot') {
190
- await ensureBrowser();
191
- return json(res, { screenshotPath: await screenshot('ss-' + Date.now()) });
192
- }
193
-
194
- if (req.method === 'POST' && url === '/browser/click') {
195
- if (b.selector) await page.click(b.selector, { timeout: 10000 }).catch(() => {});
196
- const screenshotPath = b.screenshot !== false ? await screenshot('click-' + Date.now()) : null;
197
- return json(res, { screenshotPath });
198
- }
199
-
200
- if (req.method === 'POST' && url === '/browser/click-point') {
201
- await page.mouse.click(b.x, b.y);
202
- const screenshotPath = b.screenshot !== false ? await screenshot('clickpt-' + Date.now()) : null;
203
- return json(res, { screenshotPath });
204
- }
205
-
206
- if (req.method === 'POST' && url === '/browser/fill') {
207
- await page.fill(b.selector, b.value || b.text || '', { timeout: 10000 });
208
- const screenshotPath = b.screenshot !== false ? await screenshot('fill-' + Date.now()) : null;
209
- return json(res, { screenshotPath });
210
- }
211
-
212
- if (req.method === 'POST' && url === '/browser/type-text') {
213
- await page.keyboard.type(b.text || '');
214
- const screenshotPath = b.screenshot !== false ? await screenshot('type-' + Date.now()) : null;
215
- return json(res, { screenshotPath });
216
- }
217
-
218
- if (req.method === 'POST' && url === '/browser/press-key') {
219
- await page.keyboard.press(b.key || '');
220
- const screenshotPath = b.screenshot !== false ? await screenshot('key-' + Date.now()) : null;
221
- return json(res, { screenshotPath });
222
- }
223
-
224
- if (req.method === 'POST' && url === '/browser/scroll') {
225
- await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: b.deltaX || 0, y: b.deltaY || 0 });
226
- const screenshotPath = b.screenshot !== false ? await screenshot('scroll-' + Date.now()) : null;
227
- return json(res, { screenshotPath });
228
- }
229
-
230
- if (req.method === 'POST' && url === '/browser/extract') {
231
- const result = b.all
232
- ? await page.$$(b.selector).then(els => Promise.all(els.map(el => el.getAttribute(b.attribute).catch(() => null))))
233
- : await page.$(b.selector).then(el => el ? el.getAttribute(b.attribute) : null);
234
- return json(res, { result });
235
- }
236
-
237
- if (req.method === 'POST' && url === '/browser/execute') {
238
- const result = await page.evaluate(b.script || b.code || '').catch(err => ({ error: err.message }));
239
- return json(res, { result });
240
- }
241
-
242
- json(res, { error: 'Not found' }, 404);
243
- } catch (err) {
244
- json(res, { error: err.message }, err.status || 500);
245
- }
246
- });
247
-
248
- server.listen(PORT, '0.0.0.0', () => process.stdout.write('AGENT_READY\\n'));
249
- `.trim();
250
-
251
49
  // ─── Helpers ─────────────────────────────────────────────────────────────────
252
50
 
253
51
  function findAvailablePort() {
@@ -273,17 +71,25 @@ function isContainerRunning(containerId) {
273
71
  catch { return false; }
274
72
  }
275
73
 
74
+ function containerLogs(containerId, tailLines = 20) {
75
+ try { return docker(['logs', '--tail', String(tailLines), containerId], { timeout: 5000 }); }
76
+ catch { return ''; }
77
+ }
78
+
79
+ // Resolve once the guest agent's HTTP server is accepting connections. Any HTTP
80
+ // response (including 401 when a guest token is required) proves the process is
81
+ // listening; the authenticated health probe happens in the execution backend.
276
82
  function waitForAgent(port, timeoutMs) {
277
83
  return new Promise((resolve, reject) => {
278
- const deadline = Date.now() + (timeoutMs || 180000);
84
+ const deadline = Date.now() + (timeoutMs || 120000);
279
85
  function attempt() {
280
- if (Date.now() > deadline) return reject(new Error(`Agent on port ${port} not ready within ${Math.round((timeoutMs || 180000) / 1000)}s`));
281
- const req = http.get(`http://localhost:${port}/health`, res => {
282
- if (res.statusCode === 200) return resolve();
283
- setTimeout(attempt, 3000);
86
+ if (Date.now() > deadline) return reject(new Error(`Agent on port ${port} not ready within ${Math.round((timeoutMs || 120000) / 1000)}s`));
87
+ const req = http.get(`http://localhost:${port}/health`, (res) => {
88
+ res.resume();
89
+ return resolve();
284
90
  });
285
- req.on('error', () => setTimeout(attempt, 3000));
286
- req.setTimeout(2000, () => { req.destroy(); setTimeout(attempt, 3000); });
91
+ req.on('error', () => setTimeout(attempt, 1000));
92
+ req.setTimeout(2000, () => { req.destroy(); setTimeout(attempt, 1000); });
287
93
  }
288
94
  attempt();
289
95
  });
@@ -292,19 +98,91 @@ function waitForAgent(port, timeoutMs) {
292
98
  // ─── DockerVMManager ─────────────────────────────────────────────────────────
293
99
 
294
100
  class DockerVMManager {
295
- /** @type {Map<string, {baseUrl:string, guestToken:null, process:{pid:number}, getLastError:()=>null, containerId:string}>} */
101
+ /** @type {Map<string, {baseUrl:string, guestToken:string|null, process:{pid:number}, getLastError:()=>(string|null), containerId:string}>} */
296
102
  instances = new Map();
297
103
  #pending = new Map();
298
104
  #readiness = null;
299
105
  #readinessAt = 0;
300
106
 
301
107
  constructor(options = {}) {
302
- this.profile = options.runtimeProfile || 'default';
303
- this.image = options.image || CONTAINER_IMAGE;
108
+ this.profile = options.runtimeProfile || 'browser_cli';
304
109
  this.memoryMb = options.memoryMb || 2048;
305
110
  this.cpus = options.cpus || 2;
306
- this.guestToken = String(options.guestToken || process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
111
+ this.pidsLimit = Number(options.pidsLimit || process.env.NEOAGENT_VM_PIDS_LIMIT || 512);
112
+ // The guest agent authenticates every request with this token. Always have
113
+ // one — fall back to a per-process random secret if the operator didn't set
114
+ // NEOAGENT_VM_GUEST_TOKEN so the sandbox is never left unauthenticated.
115
+ this.guestToken = String(options.guestToken || process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim()
116
+ || crypto.randomBytes(32).toString('hex');
117
+ this.imageBuilder = options.imageBuilder || new GuestImageBuilder({ runtimeProfile: this.profile });
118
+ this.bootTimeoutMs = Number(options.bootTimeoutMs || process.env.NEOAGENT_VM_BOOT_TIMEOUT_MS || 120000);
119
+ this.network = null;
307
120
  this.#cleanupOrphans();
121
+ this.#setupNetwork();
122
+ }
123
+
124
+ // Put agent containers on a dedicated bridge network so their egress can be
125
+ // firewalled away from cloud instance-metadata endpoints.
126
+ #setupNetwork() {
127
+ if (String(process.env.NEOAGENT_VM_EGRESS_FIREWALL ?? '1') === '0') {
128
+ return;
129
+ }
130
+ const name = 'neoagent-agents';
131
+ try {
132
+ let subnet = '';
133
+ try {
134
+ subnet = docker(['network', 'inspect', '--format', '{{range .IPAM.Config}}{{.Subnet}}{{end}}', name]);
135
+ } catch {
136
+ docker(['network', 'create', name]);
137
+ subnet = docker(['network', 'inspect', '--format', '{{range .IPAM.Config}}{{.Subnet}}{{end}}', name]);
138
+ }
139
+ this.network = name;
140
+ if (subnet) {
141
+ this.#applyEgressFirewall(subnet.trim());
142
+ }
143
+ } catch (err) {
144
+ // Without the dedicated network we fall back to the default bridge; the
145
+ // dropped NET_RAW/NET_ADMIN capabilities still apply as defense in depth.
146
+ console.warn(`[DockerVM:${this.profile}] Could not set up isolated network — metadata egress not firewalled: ${err.message}`);
147
+ this.network = null;
148
+ }
149
+ }
150
+
151
+ #applyEgressFirewall(subnet) {
152
+ // The host egress firewall is enforced with iptables, which only exists on
153
+ // Linux hosts. On macOS/Windows the Docker daemon runs in a managed VM with
154
+ // no cloud metadata endpoint to protect, so the dedicated network alone
155
+ // (namespace isolation) is sufficient.
156
+ if (!IS_LINUX) {
157
+ return;
158
+ }
159
+ // Cloud instance-metadata endpoints across the major providers.
160
+ const blockedHosts = ['169.254.169.254/32', '169.254.170.2/32', '100.100.100.200/32'];
161
+ // Self-hosters keep LAN access by default (mirrors NEOAGENT_HTTP_ALLOW_PRIVATE);
162
+ // cloud operators can set it to false to also cut off RFC-1918 ranges.
163
+ const allowPrivate = String(process.env.NEOAGENT_HTTP_ALLOW_PRIVATE ?? 'true').toLowerCase() !== 'false';
164
+ const blockedNets = allowPrivate ? [] : ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
165
+ const targets = [...blockedHosts, ...blockedNets];
166
+
167
+ let applied = 0;
168
+ for (const dest of targets) {
169
+ const spec = ['DOCKER-USER', '-s', subnet, '-d', dest, '-j', 'DROP'];
170
+ try {
171
+ const check = spawnSync('iptables', ['-C', ...spec], { encoding: 'utf8' });
172
+ if (check.status !== 0) {
173
+ const insert = spawnSync('iptables', ['-I', ...spec], { encoding: 'utf8' });
174
+ if (insert.status !== 0) {
175
+ throw new Error((insert.stderr || `exit ${insert.status}`).trim());
176
+ }
177
+ }
178
+ applied += 1;
179
+ } catch (err) {
180
+ console.warn(`[DockerVM:${this.profile}] Unable to install egress firewall rule for ${dest} (need root/NET_ADMIN on host): ${err.message}. Sandbox containers may be able to reach ${dest}.`);
181
+ }
182
+ }
183
+ if (applied > 0) {
184
+ console.log(`[DockerVM:${this.profile}] Egress firewall active — blocking ${applied} destination(s) from sandbox subnet ${subnet}`);
185
+ }
308
186
  }
309
187
 
310
188
  // Remove containers left over from a previous server run.
@@ -326,6 +204,14 @@ class DockerVMManager {
326
204
  const existing = this.instances.get(key);
327
205
  if (existing && isContainerRunning(existing.containerId)) return existing;
328
206
 
207
+ // Stale entry: the guest agent runs as the container's main process, so if it
208
+ // crashed the container has stopped. Remove the dead container before starting
209
+ // a fresh one (automatic crash recovery).
210
+ if (existing) {
211
+ this.instances.delete(key);
212
+ try { docker(['rm', '-f', existing.containerId]); } catch { /* already gone */ }
213
+ }
214
+
329
215
  // Already starting for this user — share the in-flight promise.
330
216
  const inflight = this.#pending.get(key);
331
217
  if (inflight) return inflight;
@@ -336,6 +222,7 @@ class DockerVMManager {
336
222
  }
337
223
 
338
224
  async #startContainer(key) {
225
+ const image = await this.imageBuilder.ensure();
339
226
  const port = await findAvailablePort();
340
227
  console.log(`[DockerVM:${this.profile}] Starting container for user ${key} on port ${port}`);
341
228
 
@@ -347,49 +234,51 @@ class DockerVMManager {
347
234
  'run', '-d',
348
235
  '--memory', `${this.memoryMb}m`,
349
236
  '--cpus', String(this.cpus),
237
+ '--pids-limit', String(this.pidsLimit),
238
+ ...hostUserArgs(),
350
239
  '-p', `127.0.0.1:${port}:${port}`,
351
- '-e', `AGENT_PORT=${port}`,
352
- ...(this.guestToken ? ['-e', `NEOAGENT_VM_GUEST_TOKEN=${this.guestToken}`] : []),
240
+ ...(this.network ? ['--network', this.network] : []),
241
+ '-e', `NEOAGENT_GUEST_AGENT_PORT=${port}`,
242
+ '-e', `HOME=${GUEST_WORKSPACE}`,
243
+ '-e', `NEOAGENT_HOME=${GUEST_RUNTIME_HOME}`,
244
+ '-e', `NEOAGENT_VM_GUEST_TOKEN=${this.guestToken}`,
353
245
  '--shm-size=2g',
354
246
  '--security-opt', 'no-new-privileges',
355
- '-v', `${workspaceDir}:${GUEST_WORKSPACE}`,
356
- '-w', GUEST_WORKSPACE,
247
+ // Strip capabilities the sandbox never needs. Dropping NET_RAW/NET_ADMIN
248
+ // also prevents the container from crafting raw packets or rewriting its
249
+ // own routing/iptables to bypass the host egress firewall above.
250
+ '--cap-drop', 'NET_RAW',
251
+ '--cap-drop', 'NET_ADMIN',
252
+ '--cap-drop', 'SYS_ADMIN',
253
+ '--cap-drop', 'SYS_PTRACE',
254
+ '--cap-drop', 'SYS_MODULE',
255
+ '-v', workspaceMount(workspaceDir),
357
256
  '--label', CONTAINER_LABEL,
358
257
  '--label', `neoagent.profile=${this.profile}`,
359
258
  '--label', `neoagent.user=${key}`,
360
- this.image,
361
- 'sleep', 'infinity',
362
- ]);
363
- console.log(`[DockerVM:${this.profile}] Container ${containerId.slice(0, 12)} started`);
364
-
365
- // Inject agent source file
366
- spawnSync('docker', ['exec', '-i', containerId, 'sh', '-c', 'cat > /tmp/agent.js'], {
367
- input: GUEST_AGENT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
368
- });
369
-
370
- // Install playwright then start agent (detached so npm install doesn't block)
371
- docker(['exec', '-d', containerId, 'sh', '-c',
372
- 'npm install playwright --prefix /tmp/pw > /tmp/pw-install.log 2>&1 && node /tmp/agent.js',
259
+ image,
373
260
  ]);
261
+ console.log(`[DockerVM:${this.profile}] Container ${containerId.slice(0, 12)} started (${image})`);
374
262
 
375
263
  const session = {
376
264
  baseUrl: `http://localhost:${port}`,
377
- guestToken: null,
265
+ guestToken: this.guestToken || null,
378
266
  process: { pid: process.pid }, // server PID — always alive while server runs
379
- getLastError: () => null,
267
+ getLastError: () => containerLogs(containerId) || null,
380
268
  containerId,
381
269
  };
382
270
  this.instances.set(key, session);
383
271
 
384
- console.log(`[DockerVM:${this.profile}] Waiting for agent on port ${port}…`);
272
+ console.log(`[DockerVM:${this.profile}] Waiting for guest agent on port ${port}…`);
385
273
  try {
386
- await waitForAgent(port, 180000);
274
+ await waitForAgent(port, this.bootTimeoutMs);
387
275
  } catch (err) {
276
+ const logs = containerLogs(containerId);
388
277
  this.instances.delete(key);
389
- try { docker(['rm', '-f', containerId]); } catch {}
390
- throw err;
278
+ try { docker(['rm', '-f', containerId]); } catch { /* best effort */ }
279
+ throw new Error(logs ? `${err.message}\n${logs}` : err.message);
391
280
  }
392
- console.log(`[DockerVM:${this.profile}] Agent ready — ${session.baseUrl}`);
281
+ console.log(`[DockerVM:${this.profile}] Guest agent ready — ${session.baseUrl}`);
393
282
  return session;
394
283
  }
395
284
 
@@ -408,7 +297,7 @@ class DockerVMManager {
408
297
 
409
298
  async shutdown() {
410
299
  await Promise.allSettled([...this.#pending.values()]);
411
- await Promise.allSettled([...this.instances.keys()].map(k => this.killVm(k)));
300
+ await Promise.allSettled([...this.instances.keys()].map((k) => this.killVm(k)));
412
301
  }
413
302
 
414
303
  hasVm(userId) {
@@ -417,15 +306,17 @@ class DockerVMManager {
417
306
  return Boolean(session && isContainerRunning(session.containerId));
418
307
  }
419
308
 
420
- // Used by validation.js — cached to avoid a docker call on every status poll.
309
+ // Used by validation.js — cached to avoid docker calls on every status poll.
421
310
  getReadiness() {
422
311
  const now = Date.now();
423
312
  if (this.#readiness && now - this.#readinessAt < 30000) return this.#readiness;
424
- try {
425
- docker(['info'], { timeout: 5000 });
426
- this.#readiness = { ready: true, dockerAvailable: true };
427
- } catch {
428
- this.#readiness = { ready: false, dockerAvailable: false };
313
+ if (!dockerAvailable()) {
314
+ this.#readiness = { ready: false, dockerAvailable: false, imageBuilt: false, image: null };
315
+ } else {
316
+ const state = this.imageBuilder.getState();
317
+ // Docker is the hard requirement; the image is built automatically on first
318
+ // use, so readiness does not block on it.
319
+ this.#readiness = { ready: true, dockerAvailable: true, imageBuilt: state.imageBuilt, image: state.image };
429
320
  }
430
321
  this.#readinessAt = now;
431
322
  return this.#readiness;
@@ -35,13 +35,12 @@ function normalizeRuntimeProfile(runtimeProfile) {
35
35
  return runtimeProfile === 'android' ? 'android' : 'browser_cli';
36
36
  }
37
37
 
38
- function createGuestPayloadArchive(seedDir, runtimeProfile = 'browser_cli') {
39
- const seedRoot = path.dirname(seedDir);
40
- const stagingRoot = path.join(seedRoot, 'guest-payload');
41
- const archivePath = path.join(seedRoot, 'guest-payload.tar.gz');
38
+ // Copy the guest runtime source files for a profile into `stagingRoot`, producing
39
+ // a self-contained directory (package.json + runtime/ + server/) that can be
40
+ // archived for the QEMU path or used as a Docker build context.
41
+ function stageGuestPayload(stagingRoot, runtimeProfile = 'browser_cli') {
42
42
  const payloadEntries = GUEST_PAYLOAD_PROFILES[normalizeRuntimeProfile(runtimeProfile)];
43
43
  fs.rmSync(stagingRoot, { recursive: true, force: true });
44
- fs.rmSync(archivePath, { force: true });
45
44
  fs.mkdirSync(stagingRoot, { recursive: true });
46
45
 
47
46
  for (const entry of payloadEntries) {
@@ -54,6 +53,15 @@ function createGuestPayloadArchive(seedDir, runtimeProfile = 'browser_cli') {
54
53
  fs.copyFileSync(sourcePath, targetPath);
55
54
  }
56
55
  }
56
+ return stagingRoot;
57
+ }
58
+
59
+ function createGuestPayloadArchive(seedDir, runtimeProfile = 'browser_cli') {
60
+ const seedRoot = path.dirname(seedDir);
61
+ const stagingRoot = path.join(seedRoot, 'guest-payload');
62
+ const archivePath = path.join(seedRoot, 'guest-payload.tar.gz');
63
+ fs.rmSync(archivePath, { force: true });
64
+ stageGuestPayload(stagingRoot, runtimeProfile);
57
65
 
58
66
  const tarResult = spawnSync('tar', ['-czf', archivePath, '-C', stagingRoot, '.'], {
59
67
  encoding: 'utf8',
@@ -651,4 +659,8 @@ module.exports = {
651
659
  createSeedIso,
652
660
  ensureGuestBootstrapSeed,
653
661
  GUEST_BOOTSTRAP_ROOT,
662
+ GUEST_PAYLOAD_PROFILES,
663
+ REPO_ROOT,
664
+ normalizeRuntimeProfile,
665
+ stageGuestPayload,
654
666
  };