neoagent 2.4.0 → 2.4.1-beta.6
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/LICENSE +619 -21
- package/README.md +1 -1
- package/extensions/chrome-browser/icons/icon128.png +0 -0
- package/extensions/chrome-browser/icons/icon16.png +0 -0
- package/extensions/chrome-browser/icons/icon48.png +0 -0
- package/extensions/chrome-browser/icons/logo.svg +12 -0
- package/extensions/chrome-browser/manifest.json +11 -1
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +2 -4
- package/extensions/chrome-browser/popup.js +1 -5
- package/flutter_app/lib/main_controller.dart +16 -0
- package/flutter_app/lib/main_devices.dart +70 -1
- package/flutter_app/lib/main_settings.dart +215 -31
- package/flutter_app/lib/src/backend_client.dart +12 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
- package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
- package/package.json +2 -2
- package/server/guest_agent.js +12 -1
- package/server/http/routes.js +190 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +51613 -51262
- package/server/services/ai/tools.js +39 -28
- package/server/services/desktop/protocol.js +1 -0
- package/server/services/desktop/provider.js +11 -0
- package/server/services/desktop/registry.js +51 -10
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +25 -2
|
@@ -292,7 +292,7 @@ function getAvailableTools(app, options = {}) {
|
|
|
292
292
|
const tools = [
|
|
293
293
|
{
|
|
294
294
|
name: 'execute_command',
|
|
295
|
-
description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, and
|
|
295
|
+
description: 'Execute a terminal/shell command as a normal recoverable agent step. Waits for the process to exit, supports PTY for interactive programs, and returns stdout, stderr, exit code, timeout state, duration, and a backend field ("vm" or "desktop-companion") indicating where the command ran. Commands run inside the isolated VM unless the cli_backend setting is set to "desktop" and a companion app is connected, in which case the command runs on the companion desktop machine.',
|
|
296
296
|
parameters: {
|
|
297
297
|
type: 'object',
|
|
298
298
|
properties: {
|
|
@@ -308,7 +308,7 @@ function getAvailableTools(app, options = {}) {
|
|
|
308
308
|
},
|
|
309
309
|
{
|
|
310
310
|
name: 'browser_navigate',
|
|
311
|
-
description: 'Navigate the browser to a URL and return page content/screenshot',
|
|
311
|
+
description: 'Navigate the browser to a URL and return page content/screenshot. The result includes a backend field ("vm" or "extension") indicating whether the VM browser or the paired browser extension handled the request.',
|
|
312
312
|
parameters: {
|
|
313
313
|
type: 'object',
|
|
314
314
|
properties: {
|
|
@@ -1401,10 +1401,13 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
1401
1401
|
allowMultipleProactiveMessages = false
|
|
1402
1402
|
} = context;
|
|
1403
1403
|
const runtime = () => app?.locals?.runtimeManager || engine.runtimeManager || null;
|
|
1404
|
-
const bc = () => {
|
|
1404
|
+
const bc = async () => {
|
|
1405
1405
|
const manager = runtime();
|
|
1406
1406
|
if (manager && typeof manager.getBrowserProviderForUser === 'function') {
|
|
1407
|
-
|
|
1407
|
+
const backend = typeof manager.getActiveBrowserBackend === 'function'
|
|
1408
|
+
? manager.getActiveBrowserBackend(userId)
|
|
1409
|
+
: 'vm';
|
|
1410
|
+
return { provider: await manager.getBrowserProviderForUser(userId), backend };
|
|
1408
1411
|
}
|
|
1409
1412
|
throw new Error('Browser provider is unavailable. VM runtime is required.');
|
|
1410
1413
|
};
|
|
@@ -1478,59 +1481,67 @@ async function executeTool(toolName, args, context, engine) {
|
|
|
1478
1481
|
|
|
1479
1482
|
case 'execute_command': {
|
|
1480
1483
|
const runtimeManager = runtime();
|
|
1481
|
-
if (!runtimeManager
|
|
1482
|
-
return { error: 'Command execution is unavailable.
|
|
1484
|
+
if (!runtimeManager) {
|
|
1485
|
+
return { error: 'Command execution is unavailable. No runtime manager found.' };
|
|
1483
1486
|
}
|
|
1484
|
-
|
|
1487
|
+
const execOptions = {
|
|
1485
1488
|
cwd: args.cwd,
|
|
1486
1489
|
timeout: args.timeout || (args.pty ? 20 * 60 * 1000 : 15 * 60 * 1000),
|
|
1487
1490
|
stdinInput: args.stdin_input,
|
|
1488
1491
|
pty: args.pty === true,
|
|
1489
1492
|
inputs: args.inputs || [],
|
|
1490
|
-
}
|
|
1493
|
+
};
|
|
1494
|
+
if (typeof runtimeManager.executeCliCommand === 'function') {
|
|
1495
|
+
return await runtimeManager.executeCliCommand(userId, args.command, execOptions);
|
|
1496
|
+
}
|
|
1497
|
+
// Legacy fallback — older runtime manager without CLI routing.
|
|
1498
|
+
if (typeof runtimeManager.executeCommand !== 'function') {
|
|
1499
|
+
return { error: 'Command execution is unavailable. VM runtime is required.' };
|
|
1500
|
+
}
|
|
1501
|
+
return { ...await runtimeManager.executeCommand(userId, args.command, execOptions), backend: 'vm' };
|
|
1491
1502
|
}
|
|
1492
1503
|
|
|
1493
1504
|
case 'browser_navigate': {
|
|
1494
|
-
const
|
|
1495
|
-
if (!
|
|
1496
|
-
return await
|
|
1505
|
+
const { provider, backend } = await bc();
|
|
1506
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1507
|
+
return { ...await provider.navigate(args.url, {
|
|
1497
1508
|
screenshot: args.screenshot !== false,
|
|
1498
1509
|
waitFor: args.waitFor,
|
|
1499
1510
|
fullPage: args.fullPage
|
|
1500
|
-
});
|
|
1511
|
+
}), backend };
|
|
1501
1512
|
}
|
|
1502
1513
|
|
|
1503
1514
|
case 'browser_click': {
|
|
1504
|
-
const
|
|
1505
|
-
if (!
|
|
1506
|
-
return await
|
|
1515
|
+
const { provider, backend } = await bc();
|
|
1516
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1517
|
+
return { ...await provider.click(args.selector, args.text, args.screenshot !== false), backend };
|
|
1507
1518
|
}
|
|
1508
1519
|
|
|
1509
1520
|
case 'browser_type': {
|
|
1510
|
-
const
|
|
1511
|
-
if (!
|
|
1512
|
-
return await
|
|
1521
|
+
const { provider, backend } = await bc();
|
|
1522
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1523
|
+
return { ...await provider.type(args.selector, args.text, {
|
|
1513
1524
|
clear: args.clear !== false,
|
|
1514
1525
|
pressEnter: args.pressEnter
|
|
1515
|
-
});
|
|
1526
|
+
}), backend };
|
|
1516
1527
|
}
|
|
1517
1528
|
|
|
1518
1529
|
case 'browser_extract': {
|
|
1519
|
-
const
|
|
1520
|
-
if (!
|
|
1521
|
-
return await
|
|
1530
|
+
const { provider, backend } = await bc();
|
|
1531
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1532
|
+
return { ...await provider.extract(args.selector, args.attribute, args.all), backend };
|
|
1522
1533
|
}
|
|
1523
1534
|
|
|
1524
1535
|
case 'browser_screenshot': {
|
|
1525
|
-
const
|
|
1526
|
-
if (!
|
|
1527
|
-
return await
|
|
1536
|
+
const { provider, backend } = await bc();
|
|
1537
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1538
|
+
return { ...await provider.screenshot({ fullPage: args.fullPage, selector: args.selector }), backend };
|
|
1528
1539
|
}
|
|
1529
1540
|
|
|
1530
1541
|
case 'browser_evaluate': {
|
|
1531
|
-
const
|
|
1532
|
-
if (!
|
|
1533
|
-
return await
|
|
1542
|
+
const { provider, backend } = await bc();
|
|
1543
|
+
if (!provider) return { error: 'Browser controller not available' };
|
|
1544
|
+
return { ...await provider.evaluate(args.script), backend };
|
|
1534
1545
|
}
|
|
1535
1546
|
|
|
1536
1547
|
case 'android_start_emulator': {
|
|
@@ -169,6 +169,17 @@ class DesktopProvider {
|
|
|
169
169
|
getAccessibilityTree(options = {}) {
|
|
170
170
|
return this._dispatch(DESKTOP_COMMANDS.GET_TREE, options);
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
executeCommand(command, options = {}) {
|
|
174
|
+
return this._dispatch(DESKTOP_COMMANDS.EXECUTE_COMMAND, {
|
|
175
|
+
command,
|
|
176
|
+
cwd: options.cwd || null,
|
|
177
|
+
timeout: options.timeout || null,
|
|
178
|
+
stdin_input: options.stdinInput || null,
|
|
179
|
+
pty: options.pty === true,
|
|
180
|
+
inputs: options.inputs || [],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
module.exports = {
|
|
@@ -127,6 +127,16 @@ class DesktopCompanionRegistry {
|
|
|
127
127
|
now,
|
|
128
128
|
);
|
|
129
129
|
|
|
130
|
+
// Remove stale offline entries for the same machine (e.g. after a re-install
|
|
131
|
+
// that generated a new device_id but kept the same hostname).
|
|
132
|
+
const hostname = hello.hostname ? String(hello.hostname).trim() : null;
|
|
133
|
+
if (hostname) {
|
|
134
|
+
this.db.prepare(
|
|
135
|
+
`DELETE FROM desktop_companion_devices
|
|
136
|
+
WHERE user_id = ? AND hostname = ? AND device_id != ? AND status = 'offline'`
|
|
137
|
+
).run(userId, hostname, hello.deviceId);
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
return this.getDeviceRecordByDeviceId(userId, hello.deviceId);
|
|
131
141
|
}
|
|
132
142
|
|
|
@@ -172,9 +182,6 @@ class DesktopCompanionRegistry {
|
|
|
172
182
|
const record = this._upsertDeviceRecord(userId, hello, sessionId);
|
|
173
183
|
const userMap = this._getUserMap(userId, true);
|
|
174
184
|
const existing = userMap.get(record.deviceId);
|
|
175
|
-
if (existing && existing.ws !== ws) {
|
|
176
|
-
existing.close('replaced by a newer desktop companion connection');
|
|
177
|
-
}
|
|
178
185
|
|
|
179
186
|
const connection = new DesktopCompanionConnection({
|
|
180
187
|
registry: this,
|
|
@@ -192,7 +199,16 @@ class DesktopCompanionRegistry {
|
|
|
192
199
|
},
|
|
193
200
|
timeoutMs: this.commandTimeoutMs,
|
|
194
201
|
});
|
|
202
|
+
// Install the new connection in the map BEFORE closing the old one.
|
|
203
|
+
// This ensures that when the old socket's async 'close' event fires and
|
|
204
|
+
// calls unregisterConnection, it sees the new connection as the owner and
|
|
205
|
+
// skips the DB status='offline' write — preventing a false offline report.
|
|
195
206
|
userMap.set(record.deviceId, connection);
|
|
207
|
+
|
|
208
|
+
if (existing && existing.ws !== ws) {
|
|
209
|
+
existing.close('replaced by a newer desktop companion connection');
|
|
210
|
+
}
|
|
211
|
+
|
|
196
212
|
return {
|
|
197
213
|
connection,
|
|
198
214
|
device: this.getDeviceRecordByDeviceId(userId, record.deviceId),
|
|
@@ -201,17 +217,21 @@ class DesktopCompanionRegistry {
|
|
|
201
217
|
|
|
202
218
|
unregisterConnection(connection) {
|
|
203
219
|
const userMap = this._getUserMap(connection.userId);
|
|
204
|
-
|
|
220
|
+
const isOwner = userMap != null && userMap.get(connection.deviceId) === connection;
|
|
221
|
+
if (isOwner) {
|
|
205
222
|
userMap.delete(connection.deviceId);
|
|
206
223
|
if (userMap.size === 0) {
|
|
207
224
|
this.connectionsByUser.delete(String(connection.userId));
|
|
208
225
|
}
|
|
226
|
+
// Only mark offline in the DB when this connection is still the active owner.
|
|
227
|
+
// If a newer connection has already taken over (reconnect race), its
|
|
228
|
+
// _upsertDeviceRecord already wrote status='online' and we must not clobber it.
|
|
229
|
+
this.db.prepare(
|
|
230
|
+
`UPDATE desktop_companion_devices
|
|
231
|
+
SET status = 'offline', updated_at = datetime('now')
|
|
232
|
+
WHERE user_id = ? AND device_id = ?`
|
|
233
|
+
).run(connection.userId, connection.deviceId);
|
|
209
234
|
}
|
|
210
|
-
this.db.prepare(
|
|
211
|
-
`UPDATE desktop_companion_devices
|
|
212
|
-
SET status = 'offline', updated_at = datetime('now')
|
|
213
|
-
WHERE user_id = ? AND device_id = ?`
|
|
214
|
-
).run(connection.userId, connection.deviceId);
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
touchConnection(userId, deviceId, patch = {}) {
|
|
@@ -253,6 +273,11 @@ class DesktopCompanionRegistry {
|
|
|
253
273
|
return this.getDeviceRecordByDeviceId(userId, deviceId);
|
|
254
274
|
}
|
|
255
275
|
|
|
276
|
+
isConnected(userId) {
|
|
277
|
+
const userMap = this._getUserMap(userId);
|
|
278
|
+
return userMap != null && userMap.size > 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
256
281
|
getConnection(userId, deviceId) {
|
|
257
282
|
const userMap = this._getUserMap(userId);
|
|
258
283
|
if (!userMap) return null;
|
|
@@ -351,9 +376,19 @@ class DesktopCompanionRegistry {
|
|
|
351
376
|
getStatus(userId) {
|
|
352
377
|
const devices = this.listDevices(userId);
|
|
353
378
|
const onlineDevices = devices.filter((device) => device.online);
|
|
379
|
+
let selectedDeviceId = this.getSelectedDeviceId(userId);
|
|
380
|
+
|
|
381
|
+
// Auto-select the most-recently-online device when there is no valid selection.
|
|
382
|
+
// listDevices returns online devices first, ordered by last_seen_at DESC.
|
|
383
|
+
const selectionIsOnline = selectedDeviceId && onlineDevices.some((d) => d.deviceId === selectedDeviceId);
|
|
384
|
+
if (!selectionIsOnline && onlineDevices.length > 0) {
|
|
385
|
+
selectedDeviceId = onlineDevices[0].deviceId;
|
|
386
|
+
this.setSelectedDeviceId(userId, selectedDeviceId);
|
|
387
|
+
}
|
|
388
|
+
|
|
354
389
|
return {
|
|
355
390
|
connected: onlineDevices.length > 0,
|
|
356
|
-
selectedDeviceId
|
|
391
|
+
selectedDeviceId,
|
|
357
392
|
onlineCount: onlineDevices.length,
|
|
358
393
|
devices,
|
|
359
394
|
};
|
|
@@ -446,6 +481,8 @@ class DesktopCompanionConnection {
|
|
|
446
481
|
this.ws.close(1000, String(reason || 'closing').slice(0, 120));
|
|
447
482
|
}
|
|
448
483
|
} catch {}
|
|
484
|
+
// unregisterConnection is intentionally called here (not inside _closePending)
|
|
485
|
+
// so it runs synchronously before any async ws 'close' event can fire.
|
|
449
486
|
this.registry.unregisterConnection(this);
|
|
450
487
|
this._closePending(new DesktopCompanionUnavailableError('Desktop companion disconnected.'));
|
|
451
488
|
}
|
|
@@ -512,6 +549,10 @@ class DesktopCompanionConnection {
|
|
|
512
549
|
pending.reject(error);
|
|
513
550
|
}
|
|
514
551
|
this.pending.clear();
|
|
552
|
+
// unregisterConnection is idempotent (ownership-checked) so calling it
|
|
553
|
+
// here is safe whether we arrived via close(), the ws 'close' event, or
|
|
554
|
+
// both. It ensures natural socket drops (no explicit close() call) still
|
|
555
|
+
// mark the device offline.
|
|
515
556
|
this.registry.unregisterConnection(this);
|
|
516
557
|
}
|
|
517
558
|
}
|
|
@@ -102,17 +102,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
102
102
|
|
|
103
103
|
// ── CLI execution ──────────────────────────────────────────────────────
|
|
104
104
|
if (req.method === 'POST' && url === '/exec') {
|
|
105
|
+
const timeoutMs = Math.min(Number(b.timeout) || 15 * 60 * 1000, 20 * 60 * 1000);
|
|
105
106
|
const child = spawn('sh', ['-c', b.command || 'true'], {
|
|
106
107
|
cwd: b.cwd || '/tmp',
|
|
107
108
|
env: { ...process.env, ...b.env },
|
|
108
109
|
});
|
|
109
110
|
const pid = child.pid;
|
|
110
|
-
let stdout = '', stderr = '';
|
|
111
|
+
let stdout = '', stderr = '', settled = false;
|
|
111
112
|
procs.set(pid, child);
|
|
112
113
|
child.stdout.on('data', d => { stdout += d; });
|
|
113
114
|
child.stderr.on('data', d => { stderr += d; });
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
procs.delete(pid);
|
|
119
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
120
|
+
json(res, { stdout, stderr, exitCode: null, code: null, pid, timedOut: true, killed: true });
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
child.on('close', code => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
procs.delete(pid);
|
|
127
|
+
const exitCode = code ?? 1;
|
|
128
|
+
json(res, { stdout, stderr, exitCode, code: exitCode, pid, timedOut: false, killed: false });
|
|
129
|
+
});
|
|
130
|
+
child.on('error', err => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
procs.delete(pid);
|
|
135
|
+
json(res, { stdout, stderr, exitCode: 1, code: 1, pid, error: err.message, timedOut: false, killed: false });
|
|
136
|
+
});
|
|
116
137
|
return;
|
|
117
138
|
}
|
|
118
139
|
|
|
@@ -268,6 +289,7 @@ class DockerVMManager {
|
|
|
268
289
|
this.image = options.image || CONTAINER_IMAGE;
|
|
269
290
|
this.memoryMb = options.memoryMb || 2048;
|
|
270
291
|
this.cpus = options.cpus || 2;
|
|
292
|
+
this.guestToken = String(options.guestToken || process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
|
|
271
293
|
this.#cleanupOrphans();
|
|
272
294
|
}
|
|
273
295
|
|
|
@@ -309,6 +331,7 @@ class DockerVMManager {
|
|
|
309
331
|
'--cpus', String(this.cpus),
|
|
310
332
|
'-p', `127.0.0.1:${port}:${port}`,
|
|
311
333
|
'-e', `AGENT_PORT=${port}`,
|
|
334
|
+
...(this.guestToken ? ['-e', `NEOAGENT_VM_GUEST_TOKEN=${this.guestToken}`] : []),
|
|
312
335
|
'--shm-size=2g',
|
|
313
336
|
'--security-opt', 'no-new-privileges',
|
|
314
337
|
'--label', CONTAINER_LABEL,
|
|
@@ -100,9 +100,32 @@ class RuntimeManager {
|
|
|
100
100
|
async getCliProviderForUser(userId) {
|
|
101
101
|
const settings = this.getSettings(userId);
|
|
102
102
|
if (settings.cli_backend === 'desktop' && this.hasActiveDesktopCompanion(userId)) {
|
|
103
|
-
|
|
103
|
+
const desktopProvider = this.getDesktopCliProvider(userId);
|
|
104
|
+
return {
|
|
105
|
+
backend: 'desktop-companion',
|
|
106
|
+
execute: (command, options = {}) => desktopProvider.executeCommand(command, options),
|
|
107
|
+
executeInteractive: (command, inputs = [], options = {}) => desktopProvider.executeCommand(command, { ...options, inputs }),
|
|
108
|
+
kill: () => Promise.resolve(false),
|
|
109
|
+
};
|
|
104
110
|
}
|
|
105
|
-
|
|
111
|
+
const executor = await this.browserBackend.getCommandExecutorForUser(userId);
|
|
112
|
+
return { ...executor, backend: 'vm' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async executeCliCommand(userId, command, options = {}) {
|
|
116
|
+
const provider = await this.getCliProviderForUser(userId);
|
|
117
|
+
const result = await (options.pty === true && provider.executeInteractive
|
|
118
|
+
? provider.executeInteractive(command, options.inputs || [], options)
|
|
119
|
+
: provider.execute(command, options));
|
|
120
|
+
return { ...result, backend: provider.backend };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getActiveBrowserBackend(userId) {
|
|
124
|
+
const settings = this.getSettings(userId);
|
|
125
|
+
if (settings.browser_backend === 'extension' && this.hasActiveExtensionBrowser(userId)) {
|
|
126
|
+
return 'extension';
|
|
127
|
+
}
|
|
128
|
+
return 'vm';
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
async getAndroidProviderForUser(userId) {
|