neoagent 2.4.0 → 2.4.1-beta.11
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/background.mjs +19 -7
- 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 +13 -2
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +7 -5
- package/extensions/chrome-browser/popup.js +16 -7
- package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +721 -0
- package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
- package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_controller.dart +156 -3
- package/flutter_app/lib/main_devices.dart +485 -119
- package/flutter_app/lib/main_settings.dart +289 -30
- package/flutter_app/lib/src/backend_client.dart +89 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +153 -3
- package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
- package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
- package/flutter_app/lib/src/stream_renderer.dart +286 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +56 -1
- package/package.json +2 -2
- package/server/guest_agent.js +19 -1
- package/server/http/routes.js +191 -0
- package/server/http/socket.js +1 -1
- package/server/index.js +4 -1
- 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 +75438 -74005
- package/server/routes/browser.js +14 -0
- package/server/routes/browser_extension.js +21 -4
- package/server/routes/desktop.js +10 -0
- package/server/routes/settings.js +4 -0
- package/server/routes/stream.js +187 -0
- package/server/services/ai/tools.js +40 -29
- package/server/services/android/controller.js +41 -2
- package/server/services/browser/controller.js +34 -0
- package/server/services/browser/extension/manifest.js +33 -0
- package/server/services/browser/extension/provider.js +12 -6
- package/server/services/browser/extension/registry.js +188 -18
- package/server/services/desktop/gateway.js +28 -3
- package/server/services/desktop/protocol.js +34 -0
- package/server/services/desktop/provider.js +25 -0
- package/server/services/desktop/registry.js +92 -10
- package/server/services/manager.js +19 -2
- package/server/services/runtime/backends/local-vm.js +6 -0
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +36 -5
- package/server/services/runtime/settings.js +17 -0
- package/server/services/streaming/android-stream.js +298 -0
- package/server/services/streaming/browser-stream.js +87 -0
- package/server/services/streaming/stream-hub.js +231 -0
- package/server/services/websocket.js +73 -0
package/server/routes/browser.js
CHANGED
|
@@ -141,6 +141,20 @@ router.post('/click-point', async (req, res) => {
|
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
router.post('/mouse-move', async (req, res) => {
|
|
145
|
+
try {
|
|
146
|
+
const { x, y } = req.body || {};
|
|
147
|
+
if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) {
|
|
148
|
+
return res.status(400).json({ error: 'x and y required' });
|
|
149
|
+
}
|
|
150
|
+
const bc = await getBrowserController(req);
|
|
151
|
+
const result = await bc.hoverPoint(x, y, req.body || {});
|
|
152
|
+
res.json(result);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
res.status(500).json({ error: sanitizeError(err) });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
144
158
|
// Fill form field
|
|
145
159
|
router.post('/fill', async (req, res) => {
|
|
146
160
|
try {
|
|
@@ -4,10 +4,10 @@ const rateLimit = require('express-rate-limit');
|
|
|
4
4
|
const { requireAuth } = require('../middleware/auth');
|
|
5
5
|
const { sanitizeError } = require('../utils/security');
|
|
6
6
|
const { createZipFromDirectory } = require('../services/browser/extension/zip');
|
|
7
|
+
const { getExtensionManifest } = require('../services/browser/extension/manifest');
|
|
7
8
|
|
|
8
9
|
const router = express.Router();
|
|
9
10
|
const EXTENSION_DIR = path.join(__dirname, '..', '..', 'extensions', 'chrome-browser');
|
|
10
|
-
const EXTENSION_MANIFEST = require('../../extensions/chrome-browser/manifest.json');
|
|
11
11
|
const pairingLimiter = rateLimit({
|
|
12
12
|
windowMs: 15 * 60 * 1000,
|
|
13
13
|
max: 30,
|
|
@@ -55,10 +55,12 @@ router.post('/pairing/request', pairingLimiter, (req, res) => {
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
router.get('/latest', (req, res) => {
|
|
58
|
+
const manifest = getExtensionManifest();
|
|
58
59
|
res.json({
|
|
59
|
-
name:
|
|
60
|
-
version:
|
|
61
|
-
|
|
60
|
+
name: manifest.name,
|
|
61
|
+
version: manifest.version,
|
|
62
|
+
versionName: manifest.version_name,
|
|
63
|
+
minimumChromeVersion: manifest.minimum_chrome_version,
|
|
62
64
|
downloadUrl: `${baseUrlFor(req)}/api/browser-extension/download`,
|
|
63
65
|
});
|
|
64
66
|
});
|
|
@@ -123,6 +125,19 @@ router.get('/status', requireAuth, (req, res) => {
|
|
|
123
125
|
}
|
|
124
126
|
});
|
|
125
127
|
|
|
128
|
+
router.post('/select-token', requireAuth, (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const registry = getRegistry(req);
|
|
131
|
+
const result = registry.setSelectedTokenId(req.session.userId, req.body?.tokenId);
|
|
132
|
+
res.json({
|
|
133
|
+
...result,
|
|
134
|
+
status: registry.getStatus(req.session.userId),
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
res.status(err.status || 500).json({ error: sanitizeError(err) });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
126
141
|
router.post('/revoke', requireAuth, (req, res) => {
|
|
127
142
|
try {
|
|
128
143
|
res.json(getRegistry(req).revoke(req.session.userId, req.body?.tokenId || null));
|
|
@@ -133,8 +148,10 @@ router.post('/revoke', requireAuth, (req, res) => {
|
|
|
133
148
|
|
|
134
149
|
router.get('/download', requireAuth, (req, res) => {
|
|
135
150
|
try {
|
|
151
|
+
const manifest = getExtensionManifest();
|
|
136
152
|
const zip = createZipFromDirectory(EXTENSION_DIR, {
|
|
137
153
|
overrides: {
|
|
154
|
+
'manifest.json': `${JSON.stringify(manifest, null, 2)}\n`,
|
|
138
155
|
'config.mjs': extensionConfigFor(req),
|
|
139
156
|
},
|
|
140
157
|
});
|
package/server/routes/desktop.js
CHANGED
|
@@ -163,6 +163,16 @@ router.post('/click', (req, res) =>
|
|
|
163
163
|
},
|
|
164
164
|
)));
|
|
165
165
|
|
|
166
|
+
router.post('/mouse-move', (req, res) =>
|
|
167
|
+
handleDesktopAction(req, res, (provider, request) => provider.mouseMove(
|
|
168
|
+
parseFiniteNumber(request.body?.x, 'x', { min: -100000, max: 100000 }),
|
|
169
|
+
parseFiniteNumber(request.body?.y, 'y', { min: -100000, max: 100000 }),
|
|
170
|
+
{
|
|
171
|
+
...(request.body || {}),
|
|
172
|
+
deviceId: parseDeviceId(request.body?.deviceId),
|
|
173
|
+
},
|
|
174
|
+
)));
|
|
175
|
+
|
|
166
176
|
router.post('/drag', (req, res) =>
|
|
167
177
|
handleDesktopAction(req, res, (provider, request) => provider.drag({
|
|
168
178
|
...(request.body || {}),
|
|
@@ -297,7 +297,11 @@ router.put('/', async (req, res) => {
|
|
|
297
297
|
'runtime_profile' in normalizedBody
|
|
298
298
|
|| 'runtime_backend' in normalizedBody
|
|
299
299
|
|| 'browser_backend' in normalizedBody
|
|
300
|
+
|| 'browser_extension_token_id' in normalizedBody
|
|
301
|
+
|| 'selected_browser_extension_token_id' in normalizedBody
|
|
300
302
|
|| 'android_backend' in normalizedBody
|
|
303
|
+
|| 'cli_backend' in normalizedBody
|
|
304
|
+
|| 'cli_desktop_device_id' in normalizedBody
|
|
301
305
|
|| 'mcp_backend' in normalizedBody
|
|
302
306
|
) {
|
|
303
307
|
const validation = validateRuntimeSettings({
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { requireAuth } = require('../middleware/auth');
|
|
5
|
+
const { sanitizeError } = require('../utils/security');
|
|
6
|
+
const { AndroidStream } = require('../services/streaming/android-stream');
|
|
7
|
+
const { BrowserStream } = require('../services/streaming/browser-stream');
|
|
8
|
+
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
router.use(requireAuth);
|
|
11
|
+
|
|
12
|
+
function boundedInt(value, fallback, min, max) {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
15
|
+
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function streamHub(req) {
|
|
19
|
+
const hub = req.app?.locals?.streamHub;
|
|
20
|
+
if (!hub) throw new Error('Stream hub is unavailable.');
|
|
21
|
+
return hub;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function desktopProvider(req) {
|
|
25
|
+
const factory = req.app?.locals?.getDesktopProviderForUser;
|
|
26
|
+
if (typeof factory !== 'function') {
|
|
27
|
+
throw new Error('Desktop provider is unavailable.');
|
|
28
|
+
}
|
|
29
|
+
return factory(req.session?.userId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function androidController(req) {
|
|
33
|
+
const runtimeManager = req.app?.locals?.runtimeManager;
|
|
34
|
+
if (runtimeManager && typeof runtimeManager.getAndroidProviderForUser === 'function') {
|
|
35
|
+
return runtimeManager.getAndroidProviderForUser(req.session?.userId);
|
|
36
|
+
}
|
|
37
|
+
throw new Error('Android controller is unavailable.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function browserController(req) {
|
|
41
|
+
const runtimeManager = req.app?.locals?.runtimeManager;
|
|
42
|
+
if (runtimeManager && typeof runtimeManager.getBrowserProviderForUser === 'function') {
|
|
43
|
+
return runtimeManager.getBrowserProviderForUser(req.session?.userId);
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Browser controller is unavailable.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePlatform(value) {
|
|
49
|
+
const platform = String(value || '').trim().toLowerCase();
|
|
50
|
+
if (platform === 'desktop' || platform === 'android' || platform === 'browser') return platform;
|
|
51
|
+
const error = new Error('platform must be desktop, android, or browser.');
|
|
52
|
+
error.status = 400;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeDeviceId(platform, value) {
|
|
57
|
+
const deviceId = String(value || '').trim();
|
|
58
|
+
if (deviceId) return deviceId;
|
|
59
|
+
if (platform === 'browser') return 'browser';
|
|
60
|
+
const error = new Error('deviceId is required.');
|
|
61
|
+
error.status = 400;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function resolveStartDeviceId(platform, req) {
|
|
66
|
+
const rawDeviceId = String(req.body?.deviceId || '').trim();
|
|
67
|
+
if (platform === 'browser') return { deviceId: 'browser', controller: null };
|
|
68
|
+
if (platform === 'android') {
|
|
69
|
+
const controller = await androidController(req);
|
|
70
|
+
const status = typeof controller.getStatus === 'function'
|
|
71
|
+
? await controller.getStatus().catch(() => ({}))
|
|
72
|
+
: {};
|
|
73
|
+
const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
|
|
74
|
+
if (rawDeviceId && adbSerial && rawDeviceId !== adbSerial) {
|
|
75
|
+
const error = new Error('Android deviceId does not match the active emulator serial.');
|
|
76
|
+
error.status = 400;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (rawDeviceId) return { deviceId: rawDeviceId, controller };
|
|
80
|
+
if (!adbSerial) throw new Error('Android deviceId is required when no emulator serial is available.');
|
|
81
|
+
return { deviceId: adbSerial, controller };
|
|
82
|
+
}
|
|
83
|
+
if (rawDeviceId) return { deviceId: rawDeviceId, controller: null };
|
|
84
|
+
return { deviceId: normalizeDeviceId(platform, rawDeviceId), controller: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveStopDeviceId(platform, req) {
|
|
88
|
+
const rawDeviceId = String(req.body?.deviceId || '').trim();
|
|
89
|
+
if (rawDeviceId) return rawDeviceId;
|
|
90
|
+
if (platform === 'browser') return 'browser';
|
|
91
|
+
if (platform === 'android') {
|
|
92
|
+
const controller = await androidController(req);
|
|
93
|
+
const status = typeof controller.getStatus === 'function'
|
|
94
|
+
? await controller.getStatus().catch(() => ({}))
|
|
95
|
+
: {};
|
|
96
|
+
const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
|
|
97
|
+
if (adbSerial) return adbSerial;
|
|
98
|
+
}
|
|
99
|
+
const error = new Error('deviceId is required.');
|
|
100
|
+
error.status = 400;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendError(res, err) {
|
|
105
|
+
res.status(Number(err?.status || 500)).json({ error: sanitizeError(err) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
router.post('/start', async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const userId = req.session?.userId;
|
|
111
|
+
const platform = normalizePlatform(req.body?.platform);
|
|
112
|
+
const resolved = await resolveStartDeviceId(platform, req);
|
|
113
|
+
const deviceId = resolved.deviceId;
|
|
114
|
+
const fps = boundedInt(req.body?.fps, platform === 'android' ? 12 : 15, 1, platform === 'android' ? 30 : 20);
|
|
115
|
+
const quality = boundedInt(req.body?.quality, platform === 'android' ? 75 : 80, 30, 95);
|
|
116
|
+
const hub = streamHub(req);
|
|
117
|
+
await hub.stopStream(userId, platform, deviceId, 'restart');
|
|
118
|
+
|
|
119
|
+
if (platform === 'desktop') {
|
|
120
|
+
const provider = await desktopProvider(req);
|
|
121
|
+
const result = await provider.startStream({
|
|
122
|
+
deviceId,
|
|
123
|
+
fps,
|
|
124
|
+
quality,
|
|
125
|
+
displayId: req.body?.displayId || null,
|
|
126
|
+
});
|
|
127
|
+
const resolvedDeviceId = result?.deviceId || result?.device?.deviceId || deviceId;
|
|
128
|
+
hub.markStarted(userId, resolvedDeviceId, platform, { fps, quality }, async () => {
|
|
129
|
+
try {
|
|
130
|
+
await provider.stopStream({ deviceId: resolvedDeviceId });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error('[StreamRoute] failed to stop desktop stream', {
|
|
133
|
+
userId,
|
|
134
|
+
deviceId: resolvedDeviceId,
|
|
135
|
+
error: String(error?.message || error),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return res.json({ ok: true, platform, deviceId: resolvedDeviceId, fps, quality });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (platform === 'android') {
|
|
143
|
+
const controller = resolved.controller || await androidController(req);
|
|
144
|
+
const stream = new AndroidStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
|
|
145
|
+
stream.start();
|
|
146
|
+
hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
|
|
147
|
+
return res.json({ ok: true, platform, deviceId, fps, quality });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const controller = await browserController(req);
|
|
151
|
+
const stream = new BrowserStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
|
|
152
|
+
stream.start();
|
|
153
|
+
hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
|
|
154
|
+
return res.json({ ok: true, platform, deviceId, fps, quality });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
sendError(res, err);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
router.post('/stop', async (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const platform = normalizePlatform(req.body?.platform);
|
|
163
|
+
const deviceId = await resolveStopDeviceId(platform, req);
|
|
164
|
+
const stopped = await streamHub(req).stopStream(req.session?.userId, platform, deviceId, 'api_stop');
|
|
165
|
+
res.json({ ok: true, stopped, platform, deviceId });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
sendError(res, err);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
router.get('/status', async (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const platform = req.query?.platform ? normalizePlatform(req.query.platform) : null;
|
|
174
|
+
const deviceId = req.query?.deviceId
|
|
175
|
+
? normalizeDeviceId(platform || 'desktop', req.query.deviceId)
|
|
176
|
+
: null;
|
|
177
|
+
const hub = streamHub(req);
|
|
178
|
+
if (deviceId) {
|
|
179
|
+
return res.json(hub.status(req.session?.userId, platform || 'desktop', deviceId));
|
|
180
|
+
}
|
|
181
|
+
return res.json({ streams: hub.listStatus(req.session?.userId), platform });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
sendError(res, err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
module.exports = router;
|
|
@@ -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
|
+
? await Promise.resolve(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
|
-
inputs: args.inputs
|
|
1490
|
-
}
|
|
1492
|
+
inputs: Array.isArray(args.inputs) ? args.inputs : [],
|
|
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': {
|
|
@@ -347,12 +347,16 @@ class AndroidController {
|
|
|
347
347
|
// ── Actions ───────────────────────────────────────────────────────────────
|
|
348
348
|
|
|
349
349
|
async screenshot(_opts = {}) {
|
|
350
|
-
const
|
|
351
|
-
const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
|
|
350
|
+
const r = await this.capturePng();
|
|
352
351
|
if (!r?.length) throw new Error('screencap returned no data');
|
|
353
352
|
return { screenshotPath: this.#saveArtifact(r) };
|
|
354
353
|
}
|
|
355
354
|
|
|
355
|
+
async capturePng(_opts = {}) {
|
|
356
|
+
const serial = this.#requireSerial();
|
|
357
|
+
return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p']);
|
|
358
|
+
}
|
|
359
|
+
|
|
356
360
|
async observe(_opts = {}) { return this.screenshot(); }
|
|
357
361
|
|
|
358
362
|
async tap({ x, y } = {}) {
|
|
@@ -469,6 +473,41 @@ class AndroidController {
|
|
|
469
473
|
return (r.status === 0 && r.stdout?.length) ? r.stdout : null;
|
|
470
474
|
}
|
|
471
475
|
|
|
476
|
+
#adbCaptureAsync(serial, args) {
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
const proc = spawn(adbBin(this.sdkDir), ['-s', serial, ...args]);
|
|
479
|
+
const chunks = [];
|
|
480
|
+
const errors = [];
|
|
481
|
+
let settled = false;
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
if (settled) return;
|
|
484
|
+
settled = true;
|
|
485
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
486
|
+
reject(new Error('adb capture timed out'));
|
|
487
|
+
}, 15000);
|
|
488
|
+
timer.unref?.();
|
|
489
|
+
proc.stdout?.on('data', (chunk) => chunks.push(chunk));
|
|
490
|
+
proc.stderr?.on('data', (chunk) => errors.push(chunk));
|
|
491
|
+
proc.on('error', (error) => {
|
|
492
|
+
if (settled) return;
|
|
493
|
+
settled = true;
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
reject(error);
|
|
496
|
+
});
|
|
497
|
+
proc.on('close', (code) => {
|
|
498
|
+
if (settled) return;
|
|
499
|
+
settled = true;
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
if (code === 0 && chunks.length) {
|
|
502
|
+
resolve(Buffer.concat(chunks));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const message = Buffer.concat(errors).toString('utf8').trim();
|
|
506
|
+
reject(new Error(message || `adb capture exit ${code}`));
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
472
511
|
#saveArtifact(data) {
|
|
473
512
|
if (!data || !this.artifactStore) return null;
|
|
474
513
|
const alloc = this.artifactStore.allocateFile(this.userId, { kind: 'screenshot', extension: 'png', contentType: 'image/png' });
|
|
@@ -453,6 +453,22 @@ class BrowserController {
|
|
|
453
453
|
};
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
async screenshotJpeg(quality = 80, options = {}) {
|
|
457
|
+
const page = await this.ensurePage();
|
|
458
|
+
const screenshotOptions = {
|
|
459
|
+
type: 'jpeg',
|
|
460
|
+
quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
|
|
461
|
+
fullPage: options.fullPage === true,
|
|
462
|
+
};
|
|
463
|
+
if (options.selector) {
|
|
464
|
+
const element = await page.$(options.selector);
|
|
465
|
+
if (element) {
|
|
466
|
+
return element.screenshot(screenshotOptions);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return page.screenshot(screenshotOptions);
|
|
470
|
+
}
|
|
471
|
+
|
|
456
472
|
async navigate(url, options = {}) {
|
|
457
473
|
const page = await this.ensurePage();
|
|
458
474
|
|
|
@@ -584,6 +600,24 @@ class BrowserController {
|
|
|
584
600
|
}
|
|
585
601
|
}
|
|
586
602
|
|
|
603
|
+
async hoverPoint(x, y, options = {}) {
|
|
604
|
+
const page = await this.ensurePage();
|
|
605
|
+
try {
|
|
606
|
+
const px = Math.max(0, Math.round(Number(x) || 0));
|
|
607
|
+
const py = Math.max(0, Math.round(Number(y) || 0));
|
|
608
|
+
await page.mouse.move(px, py, { steps: options.steps || 1 });
|
|
609
|
+
return {
|
|
610
|
+
success: true,
|
|
611
|
+
x: px,
|
|
612
|
+
y: py,
|
|
613
|
+
url: page.url(),
|
|
614
|
+
title: await page.title()
|
|
615
|
+
};
|
|
616
|
+
} catch (err) {
|
|
617
|
+
return { error: err.message };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
587
621
|
async scroll(deltaX = 0, deltaY = 0, screenshot = true) {
|
|
588
622
|
const page = await this.ensurePage();
|
|
589
623
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BASE_MANIFEST = require('../../../../extensions/chrome-browser/manifest.json');
|
|
4
|
+
const { getVersionInfo } = require('../../../utils/version');
|
|
5
|
+
|
|
6
|
+
function chromeVersionFor(packageVersion) {
|
|
7
|
+
const parts = String(packageVersion || '')
|
|
8
|
+
.match(/\d+/g)
|
|
9
|
+
?.slice(0, 4)
|
|
10
|
+
.map((part) => Math.min(Number(part) || 0, 65535)) || [];
|
|
11
|
+
|
|
12
|
+
while (parts.length < 3) {
|
|
13
|
+
parts.push(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return parts.join('.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getExtensionManifest() {
|
|
20
|
+
const { packageVersion } = getVersionInfo();
|
|
21
|
+
const displayVersion = packageVersion || BASE_MANIFEST.version_name || BASE_MANIFEST.version;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
...BASE_MANIFEST,
|
|
25
|
+
version: chromeVersionFor(displayVersion),
|
|
26
|
+
version_name: displayVersion,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
chromeVersionFor,
|
|
32
|
+
getExtensionManifest,
|
|
33
|
+
};
|
|
@@ -17,6 +17,7 @@ class ExtensionBrowserProvider {
|
|
|
17
17
|
constructor(options = {}) {
|
|
18
18
|
this.registry = options.registry;
|
|
19
19
|
this.userId = options.userId != null ? String(options.userId) : null;
|
|
20
|
+
this.tokenId = options.tokenId ? String(options.tokenId) : null;
|
|
20
21
|
this.artifactStore = options.artifactStore || null;
|
|
21
22
|
this.headless = false;
|
|
22
23
|
}
|
|
@@ -29,13 +30,16 @@ class ExtensionBrowserProvider {
|
|
|
29
30
|
|
|
30
31
|
async #dispatch(command, payload = {}, options = {}) {
|
|
31
32
|
this.#assertReady();
|
|
32
|
-
const result = await this.registry.dispatch(this.userId, command, payload,
|
|
33
|
+
const result = await this.registry.dispatch(this.userId, command, payload, {
|
|
34
|
+
...options,
|
|
35
|
+
tokenId: options.tokenId || this.tokenId,
|
|
36
|
+
});
|
|
33
37
|
return this.#materialize(result);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
#disconnect() {
|
|
37
41
|
if (!this.registry || this.userId == null) return;
|
|
38
|
-
const connection = this.registry.getConnection(this.userId);
|
|
42
|
+
const connection = this.registry.getConnection(this.userId, this.tokenId);
|
|
39
43
|
if (connection) {
|
|
40
44
|
connection.close('browser extension provider closed');
|
|
41
45
|
}
|
|
@@ -131,7 +135,7 @@ class ExtensionBrowserProvider {
|
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
async closeBrowser() {
|
|
134
|
-
if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId)) {
|
|
138
|
+
if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId, this.tokenId)) {
|
|
135
139
|
return { success: true, extensionConnected: false };
|
|
136
140
|
}
|
|
137
141
|
const result = await this.#dispatch(EXTENSION_COMMANDS.CLOSE, {});
|
|
@@ -152,14 +156,16 @@ class ExtensionBrowserProvider {
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
async getPageInfo() {
|
|
155
|
-
if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId)) {
|
|
159
|
+
if (!this.registry || this.userId == null || !this.registry.isConnected(this.userId, this.tokenId)) {
|
|
156
160
|
return { url: null, title: null, extensionConnected: false };
|
|
157
161
|
}
|
|
158
|
-
return this.registry.dispatch(this.userId, EXTENSION_COMMANDS.GET_PAGE_INFO, {}
|
|
162
|
+
return this.registry.dispatch(this.userId, EXTENSION_COMMANDS.GET_PAGE_INFO, {}, {
|
|
163
|
+
tokenId: this.tokenId,
|
|
164
|
+
});
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
isLaunched() {
|
|
162
|
-
return Boolean(this.registry && this.userId != null && this.registry.isConnected(this.userId));
|
|
168
|
+
return Boolean(this.registry && this.userId != null && this.registry.isConnected(this.userId, this.tokenId));
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
getPageCount() {
|