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.
- package/.env.example +2 -10
- package/README.md +12 -3
- package/docs/automation.md +37 -0
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +34 -8
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +38 -13
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1612 -214
- package/flutter_app/lib/main_controller.dart +398 -26
- package/flutter_app/lib/main_devices.dart +293 -207
- package/flutter_app/lib/main_models.dart +142 -0
- package/flutter_app/lib/main_navigation.dart +19 -1
- package/flutter_app/lib/main_operations.dart +288 -9
- package/flutter_app/lib/main_settings.dart +510 -300
- package/flutter_app/lib/main_shared.dart +2 -0
- package/flutter_app/lib/main_timeline.dart +1378 -0
- package/flutter_app/lib/src/backend_client.dart +149 -19
- package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
- package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
- package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
- package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
- package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
- package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
- package/flutter_app/lib/src/recording_payloads.dart +9 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
- package/flutter_app/windows/runner/flutter_window.cpp +75 -0
- package/landing/index.html +3 -3
- package/lib/manager.js +184 -66
- package/package.json +4 -1
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +436 -10
- package/server/admin/billing.js +165 -42
- package/server/admin/index.html +80 -5
- package/server/admin/users.js +15 -15
- package/server/db/database.js +125 -20
- package/server/http/routes.js +1 -0
- package/server/http/static.js +4 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.js.symbols +8475 -8467
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +91077 -87037
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +345 -64
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +5 -0
- package/server/routes/browser.js +8 -1
- package/server/routes/recordings.js +96 -6
- package/server/routes/screenHistory.js +140 -2
- package/server/routes/timeline.js +43 -0
- package/server/services/account/erasure.js +263 -0
- package/server/services/ai/hooks.js +4 -1
- package/server/services/ai/loop/agent_engine_core.js +8 -1
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/conversation_loop.js +166 -33
- package/server/services/ai/messagingFallback.js +22 -0
- package/server/services/ai/rate_limits.js +28 -5
- package/server/services/ai/systemPrompt.js +6 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolEvidence.js +15 -0
- package/server/services/ai/toolResult.js +40 -0
- package/server/services/ai/tools.js +163 -4
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/plans.js +2 -1
- package/server/services/browser/anti_detection.js +192 -0
- package/server/services/browser/controller.js +180 -54
- package/server/services/desktop/auth.js +3 -0
- package/server/services/desktop/registry.js +50 -2
- package/server/services/integrations/google/calendar.js +22 -14
- package/server/services/manager.js +12 -42
- package/server/services/memory/ingestion_chunking.js +268 -0
- package/server/services/messaging/telnyx.js +9 -8
- package/server/services/recordings/manager.js +60 -27
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +188 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/social_video/service.js +60 -10
- package/server/services/tasks/runtime.js +234 -9
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/tasks/task_repository.js +13 -0
- package/server/services/timeline/service.js +558 -0
- package/server/services/wearable/gateway.js +1 -1
- package/server/services/websocket.js +21 -3
- package/server/services/desktop/screenRecorder.js +0 -292
- 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
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 ||
|
|
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 ||
|
|
281
|
-
const req = http.get(`http://localhost:${port}/health`, res => {
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
286
|
-
req.setTimeout(2000, () => { req.destroy(); setTimeout(attempt,
|
|
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 || '
|
|
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.
|
|
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
|
-
'
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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,
|
|
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}]
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
};
|