neoagent 3.2.1-beta.2 → 3.2.1-beta.4
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/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +511 -89
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +29 -20
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +67 -13
- package/package.json +20 -13
- package/runtime/paths.js +49 -5
- package/server/guest_agent.js +52 -30
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +4 -6
- 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 +30803 -30805
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +5 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +44 -5
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +347 -162
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +12 -0
- package/server/services/ai/loop/conversation_loop.js +348 -242
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +8 -41
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +24 -19
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +67 -162
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +111 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +15 -28
- package/server/services/ai/providers/githubCopilot.js +36 -16
- package/server/services/ai/providers/google.js +23 -5
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +13 -22
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +102 -82
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/taskAnalysis.js +5 -1
- package/server/services/ai/terminal_reply.js +45 -0
- package/server/services/ai/toolEvidence.js +58 -29
- package/server/services/ai/tools.js +124 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +736 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +12 -1
- package/server/services/browser/extension/provider.js +59 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +158 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +87 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +84 -9
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/messaging/whatsapp.js +22 -14
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +214 -66
- package/server/services/runtime/manager.js +17 -12
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/retry.js +107 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
const fs = require('fs');
|
|
2
4
|
const path = require('path');
|
|
3
5
|
const { DATA_DIR } = require('../../../../runtime/paths');
|
|
6
|
+
const { fetchResponseText, waitForAbortableResult } = require('../../network/http');
|
|
7
|
+
const { createAbortError } = require('../../../utils/abort');
|
|
4
8
|
|
|
5
9
|
const APK_UPLOAD_ROOT = path.resolve(
|
|
6
10
|
process.env.NEOAGENT_ANDROID_APK_BASE_DIR
|
|
@@ -13,6 +17,25 @@ function sleep(ms) {
|
|
|
13
17
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
function abortError(signal, fallback = 'Operation aborted.') {
|
|
21
|
+
return createAbortError(signal, fallback);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delayWithSignal(ms, signal) {
|
|
25
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
signal?.removeEventListener('abort', onAbort);
|
|
29
|
+
resolve();
|
|
30
|
+
}, ms);
|
|
31
|
+
const onAbort = () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
reject(abortError(signal));
|
|
34
|
+
};
|
|
35
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
function assertPathInside(baseDir, candidatePath, label) {
|
|
17
40
|
const resolvedBase = path.resolve(baseDir);
|
|
18
41
|
const resolvedCandidate = path.resolve(candidatePath);
|
|
@@ -54,12 +77,16 @@ class RuntimeHttpClient {
|
|
|
54
77
|
let lastError = null;
|
|
55
78
|
|
|
56
79
|
while (Date.now() - startedAt < timeoutMs) {
|
|
80
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
57
81
|
if (!checkLiveness()) {
|
|
58
82
|
throw new Error('Guest runtime process exited unexpectedly during bootstrap.');
|
|
59
83
|
}
|
|
60
84
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
61
85
|
try {
|
|
62
|
-
const health = await this.request('GET', '/health', undefined, {
|
|
86
|
+
const health = await this.request('GET', '/health', undefined, {
|
|
87
|
+
timeoutMs: 2000,
|
|
88
|
+
signal: options.signal,
|
|
89
|
+
});
|
|
63
90
|
if (health?.status === 'ok') {
|
|
64
91
|
console.log(`[Runtime] Guest agent ready after ${elapsed}s`);
|
|
65
92
|
return health;
|
|
@@ -71,7 +98,7 @@ class RuntimeHttpClient {
|
|
|
71
98
|
console.log(`[Runtime] Waiting for guest agent health... (${elapsed}s elapsed, last error: ${error.message})`);
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
|
-
await
|
|
101
|
+
await delayWithSignal(intervalMs, options.signal);
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
if (lastError) {
|
|
@@ -81,49 +108,59 @@ class RuntimeHttpClient {
|
|
|
81
108
|
}
|
|
82
109
|
|
|
83
110
|
async request(method, pathname, body, options = {}) {
|
|
84
|
-
const
|
|
111
|
+
const normalizedMethod = String(method || 'GET').toUpperCase();
|
|
112
|
+
const safeToRetry = ['GET', 'HEAD', 'OPTIONS'].includes(normalizedMethod);
|
|
113
|
+
const retryCount = Math.max(0, Number(options.retryCount ?? (safeToRetry ? 6 : 0)));
|
|
85
114
|
const retryDelayMs = Math.max(100, Number(options.retryDelayMs ?? 1000));
|
|
86
115
|
let lastError = null;
|
|
87
116
|
|
|
88
117
|
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
|
|
89
|
-
|
|
90
|
-
const timer = controller ? setTimeout(() => controller.abort(new Error(`Request timed out after ${options.timeoutMs} ms.`)), options.timeoutMs) : null;
|
|
118
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
91
119
|
try {
|
|
92
|
-
const response = await
|
|
93
|
-
method,
|
|
120
|
+
const { response, text } = await fetchResponseText(`${this.baseUrl}${pathname}`, {
|
|
121
|
+
method: normalizedMethod,
|
|
94
122
|
headers: {
|
|
95
123
|
'content-type': 'application/json',
|
|
96
124
|
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
|
|
97
125
|
},
|
|
98
126
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
99
|
-
signal:
|
|
127
|
+
signal: options.signal,
|
|
128
|
+
timeoutMs: Number(options.timeoutMs || 30000),
|
|
129
|
+
maxResponseBytes: Number(options.maxResponseBytes || 32 * 1024 * 1024),
|
|
130
|
+
serviceName: 'Guest runtime',
|
|
131
|
+
timeoutCode: 'RUNTIME_HTTP_TIMEOUT',
|
|
132
|
+
tooLargeCode: 'RUNTIME_HTTP_RESPONSE_TOO_LARGE',
|
|
100
133
|
});
|
|
101
134
|
|
|
102
135
|
const contentType = response.headers.get('content-type') || '';
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
let payload = { text };
|
|
137
|
+
if (contentType.includes('application/json')) {
|
|
138
|
+
try {
|
|
139
|
+
payload = JSON.parse(text);
|
|
140
|
+
} catch {
|
|
141
|
+
payload = {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
106
144
|
|
|
107
145
|
if (!response.ok) {
|
|
108
146
|
const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
|
|
109
|
-
|
|
147
|
+
const error = new Error(errorMessage);
|
|
148
|
+
error.status = response.status;
|
|
149
|
+
throw error;
|
|
110
150
|
}
|
|
111
151
|
if (typeof this.onActivity === 'function') {
|
|
112
152
|
this.onActivity();
|
|
113
153
|
}
|
|
114
154
|
return payload;
|
|
115
155
|
} catch (error) {
|
|
156
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
116
157
|
lastError = error;
|
|
117
158
|
const message = String(error?.message || error);
|
|
118
159
|
const retryable = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|timed out/i.test(message);
|
|
119
160
|
if (!retryable || attempt === retryCount) {
|
|
120
161
|
throw error;
|
|
121
162
|
}
|
|
122
|
-
await
|
|
123
|
-
} finally {
|
|
124
|
-
if (timer) {
|
|
125
|
-
clearTimeout(timer);
|
|
126
|
-
}
|
|
163
|
+
await delayWithSignal(retryDelayMs, options.signal);
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
|
|
@@ -131,13 +168,14 @@ class RuntimeHttpClient {
|
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
async requestStream(method, pathname, stream, options = {}) {
|
|
134
|
-
const retryCount =
|
|
171
|
+
const retryCount = 0;
|
|
135
172
|
const retryDelayMs = Math.max(100, Number(options.retryDelayMs ?? 1000));
|
|
136
173
|
let lastError = null;
|
|
137
174
|
|
|
138
175
|
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
|
|
176
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
139
177
|
try {
|
|
140
|
-
const response = await
|
|
178
|
+
const { response, text } = await fetchResponseText(`${this.baseUrl}${pathname}`, {
|
|
141
179
|
method,
|
|
142
180
|
headers: {
|
|
143
181
|
...(options.contentType ? { 'content-type': options.contentType } : {}),
|
|
@@ -147,6 +185,12 @@ class RuntimeHttpClient {
|
|
|
147
185
|
},
|
|
148
186
|
body: stream,
|
|
149
187
|
duplex: 'half',
|
|
188
|
+
signal: options.signal,
|
|
189
|
+
timeoutMs: Number(options.timeoutMs || 60000),
|
|
190
|
+
maxResponseBytes: Number(options.maxResponseBytes || 2 * 1024 * 1024),
|
|
191
|
+
serviceName: 'Guest runtime upload',
|
|
192
|
+
timeoutCode: 'RUNTIME_HTTP_TIMEOUT',
|
|
193
|
+
tooLargeCode: 'RUNTIME_HTTP_RESPONSE_TOO_LARGE',
|
|
150
194
|
});
|
|
151
195
|
|
|
152
196
|
if (response.ok && typeof this.onActivity === 'function') {
|
|
@@ -154,23 +198,31 @@ class RuntimeHttpClient {
|
|
|
154
198
|
}
|
|
155
199
|
|
|
156
200
|
const contentType = response.headers.get('content-type') || '';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
201
|
+
let payload = { text };
|
|
202
|
+
if (contentType.includes('application/json')) {
|
|
203
|
+
try {
|
|
204
|
+
payload = JSON.parse(text);
|
|
205
|
+
} catch {
|
|
206
|
+
payload = {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
160
209
|
|
|
161
210
|
if (!response.ok) {
|
|
162
211
|
const errorMessage = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
|
|
163
|
-
|
|
212
|
+
const error = new Error(errorMessage);
|
|
213
|
+
error.status = response.status;
|
|
214
|
+
throw error;
|
|
164
215
|
}
|
|
165
216
|
return payload;
|
|
166
217
|
} catch (error) {
|
|
218
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
167
219
|
lastError = error;
|
|
168
220
|
const message = String(error?.message || error);
|
|
169
221
|
const retryable = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|timed out/i.test(message);
|
|
170
222
|
if (!retryable || attempt === retryCount) {
|
|
171
223
|
throw error;
|
|
172
224
|
}
|
|
173
|
-
await
|
|
225
|
+
await delayWithSignal(retryDelayMs, options.signal);
|
|
174
226
|
}
|
|
175
227
|
}
|
|
176
228
|
|
|
@@ -186,7 +238,17 @@ class VmBrowserProvider {
|
|
|
186
238
|
this.headless = true;
|
|
187
239
|
}
|
|
188
240
|
|
|
189
|
-
async #
|
|
241
|
+
async #request(method, pathname, body, options = {}) {
|
|
242
|
+
const payload = body && typeof body === 'object' ? { ...body } : body;
|
|
243
|
+
const signal = options.signal || payload?.signal || null;
|
|
244
|
+
if (payload && typeof payload === 'object') delete payload.signal;
|
|
245
|
+
return this.client.request(method, pathname, payload, {
|
|
246
|
+
timeoutMs: Number(options.timeoutMs || 120_000),
|
|
247
|
+
signal,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async #materialize(result, options = {}) {
|
|
190
252
|
if (!result || !this.artifactStore || this.userId == null) {
|
|
191
253
|
return result;
|
|
192
254
|
}
|
|
@@ -205,16 +267,18 @@ class VmBrowserProvider {
|
|
|
205
267
|
let file = null;
|
|
206
268
|
const maxAttempts = 20;
|
|
207
269
|
for (let attempt = 0; attempt < maxAttempts && !file?.content; attempt += 1) {
|
|
270
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
208
271
|
for (const candidate of readablePathCandidates) {
|
|
209
272
|
try {
|
|
210
|
-
file = await this
|
|
273
|
+
file = await this.#request('POST', '/files/read', {
|
|
211
274
|
path: candidate,
|
|
212
275
|
encoding: 'base64',
|
|
213
|
-
});
|
|
276
|
+
}, { signal: options.signal, timeoutMs: 10_000 });
|
|
214
277
|
if (file?.content) {
|
|
215
278
|
break;
|
|
216
279
|
}
|
|
217
280
|
} catch (error) {
|
|
281
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
218
282
|
if (attempt === maxAttempts - 1) {
|
|
219
283
|
console.warn('[Runtime:browser_vm] screenshot materialization read failed', {
|
|
220
284
|
userId: this.userId,
|
|
@@ -225,7 +289,7 @@ class VmBrowserProvider {
|
|
|
225
289
|
}
|
|
226
290
|
}
|
|
227
291
|
if (!file?.content) {
|
|
228
|
-
await
|
|
292
|
+
await delayWithSignal(250, options.signal);
|
|
229
293
|
}
|
|
230
294
|
}
|
|
231
295
|
if (!file?.content) {
|
|
@@ -243,6 +307,7 @@ class VmBrowserProvider {
|
|
|
243
307
|
}
|
|
244
308
|
return result;
|
|
245
309
|
}
|
|
310
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
246
311
|
|
|
247
312
|
const allocation = this.artifactStore.allocateFile(this.userId, {
|
|
248
313
|
kind: 'browser-screenshot',
|
|
@@ -261,43 +326,87 @@ class VmBrowserProvider {
|
|
|
261
326
|
};
|
|
262
327
|
}
|
|
263
328
|
|
|
264
|
-
async navigate(url, options = {}) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
async
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
329
|
+
async navigate(url, options = {}) {
|
|
330
|
+
return this.#materialize(
|
|
331
|
+
await this.#request('POST', '/browser/navigate', { url, ...options }, options),
|
|
332
|
+
options,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
async click(selector, text, screenshot = true, options = {}) {
|
|
336
|
+
return this.#materialize(
|
|
337
|
+
await this.#request('POST', '/browser/click', { selector, text, screenshot }, options),
|
|
338
|
+
options,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
async clickPoint(x, y, screenshot = true, options = {}) {
|
|
342
|
+
return this.#materialize(
|
|
343
|
+
await this.#request('POST', '/browser/click-point', { x, y, screenshot }, options),
|
|
344
|
+
options,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
async type(selector, text, options = {}) {
|
|
348
|
+
return this.#materialize(
|
|
349
|
+
await this.#request('POST', '/browser/fill', { selector, value: text, ...options }, options),
|
|
350
|
+
options,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
async typeText(text, options = {}) {
|
|
354
|
+
return this.#materialize(
|
|
355
|
+
await this.#request('POST', '/browser/type-text', { text, ...options }, options),
|
|
356
|
+
options,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
async pressKey(key, screenshot = true, options = {}) {
|
|
360
|
+
return this.#materialize(
|
|
361
|
+
await this.#request('POST', '/browser/press-key', { key, screenshot }, options),
|
|
362
|
+
options,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
async scroll(deltaX, deltaY, screenshot = true, options = {}) {
|
|
366
|
+
return this.#materialize(
|
|
367
|
+
await this.#request('POST', '/browser/scroll', { deltaX, deltaY, screenshot }, options),
|
|
368
|
+
options,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
async extract(selector, attribute, all = false, options = {}) {
|
|
372
|
+
return this.#request('POST', '/browser/extract', { selector, attribute, all }, options);
|
|
373
|
+
}
|
|
374
|
+
async evaluate(script, options = {}) {
|
|
375
|
+
return this.#request('POST', '/browser/execute', { code: script }, options);
|
|
376
|
+
}
|
|
377
|
+
async screenshot(options = {}) {
|
|
378
|
+
return this.#materialize(
|
|
379
|
+
await this.#request('POST', '/browser/screenshot', options, options),
|
|
380
|
+
options,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
274
383
|
async screenshotJpeg(quality = 80, options = {}) {
|
|
275
|
-
const result = await this
|
|
384
|
+
const result = await this.#request('POST', '/browser/screenshot-jpeg', { ...options, quality }, options);
|
|
276
385
|
const content = String(result?.contentBase64 || '');
|
|
277
386
|
if (!content) throw new Error('VM browser screenshot-jpeg returned no data.');
|
|
278
387
|
return Buffer.from(content, 'base64');
|
|
279
388
|
}
|
|
280
|
-
async launch(options = {}) { return this
|
|
281
|
-
async closeBrowser() { return this
|
|
282
|
-
async fill(selector, value) { return this.type(selector, value); }
|
|
283
|
-
async extractContent(options = {}) { return this
|
|
284
|
-
async executeJS(code) { return this.evaluate(code); }
|
|
285
|
-
async getPageInfo() {
|
|
286
|
-
const status = await this.client.request('GET', '/browser/status');
|
|
389
|
+
async launch(options = {}) { return this.#request('POST', '/browser/launch', options, options); }
|
|
390
|
+
async closeBrowser(options = {}) { return this.#request('POST', '/browser/close', undefined, options); }
|
|
391
|
+
async fill(selector, value, options = {}) { return this.type(selector, value, options); }
|
|
392
|
+
async extractContent(options = {}) { return this.#request('POST', '/browser/extract', options, options); }
|
|
393
|
+
async executeJS(code, options = {}) { return this.evaluate(code, options); }
|
|
394
|
+
async getPageInfo(options = {}) {
|
|
395
|
+
const status = await this.client.request('GET', '/browser/status', undefined, options);
|
|
287
396
|
this.headless = status?.headless !== false;
|
|
288
397
|
return status?.pageInfo || null;
|
|
289
398
|
}
|
|
290
|
-
async isLaunched() {
|
|
291
|
-
const status = await this.client.request('GET', '/browser/status');
|
|
399
|
+
async isLaunched(options = {}) {
|
|
400
|
+
const status = await this.client.request('GET', '/browser/status', undefined, options);
|
|
292
401
|
this.headless = status?.headless !== false;
|
|
293
402
|
return status?.launched === true;
|
|
294
403
|
}
|
|
295
|
-
async getPageCount() {
|
|
296
|
-
const status = await this.client.request('GET', '/browser/status');
|
|
404
|
+
async getPageCount(options = {}) {
|
|
405
|
+
const status = await this.client.request('GET', '/browser/status', undefined, options);
|
|
297
406
|
return Number(status?.pages || 0);
|
|
298
407
|
}
|
|
299
|
-
async getCookies() {
|
|
300
|
-
return this.client.request('GET', '/browser/cookies');
|
|
408
|
+
async getCookies(options = {}) {
|
|
409
|
+
return this.client.request('GET', '/browser/cookies', undefined, options);
|
|
301
410
|
}
|
|
302
411
|
async setHeadless(value) {
|
|
303
412
|
this.headless = true;
|
|
@@ -313,6 +422,9 @@ class LocalVmExecutionBackend {
|
|
|
313
422
|
this.artifactStore = options.artifactStore || null;
|
|
314
423
|
this.lastActivity = new Map();
|
|
315
424
|
this.reaperInterval = null;
|
|
425
|
+
this.reaperInFlight = false;
|
|
426
|
+
this.shuttingDown = false;
|
|
427
|
+
this.shutdownPromise = null;
|
|
316
428
|
|
|
317
429
|
if (IDLE_TIMEOUT_MS > 0) {
|
|
318
430
|
this.#startIdleReaper();
|
|
@@ -329,26 +441,48 @@ class LocalVmExecutionBackend {
|
|
|
329
441
|
#startIdleReaper() {
|
|
330
442
|
if (this.reaperInterval) return;
|
|
331
443
|
this.reaperInterval = setInterval(async () => {
|
|
444
|
+
if (this.reaperInFlight || this.shuttingDown) return;
|
|
445
|
+
this.reaperInFlight = true;
|
|
332
446
|
const now = Date.now();
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
447
|
+
try {
|
|
448
|
+
for (const [userId, lastUsed] of this.lastActivity.entries()) {
|
|
449
|
+
if (now - lastUsed > IDLE_TIMEOUT_MS) {
|
|
450
|
+
console.log(`[Runtime:${this.runtimeProfile}] User ${userId} runtime idle for ${Math.round((now - lastUsed) / 1000)}s, shutting down VM.`);
|
|
451
|
+
this.lastActivity.delete(userId);
|
|
452
|
+
try {
|
|
453
|
+
await this.vmManager?.killVm?.(userId);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error(`[Runtime:${this.runtimeProfile}] Failed to shut down idle VM for user ${userId}:`, err.message);
|
|
456
|
+
}
|
|
341
457
|
}
|
|
342
458
|
}
|
|
459
|
+
} finally {
|
|
460
|
+
this.reaperInFlight = false;
|
|
343
461
|
}
|
|
344
462
|
}, Math.min(IDLE_TIMEOUT_MS, 60 * 1000));
|
|
463
|
+
this.reaperInterval.unref?.();
|
|
345
464
|
}
|
|
346
465
|
|
|
347
|
-
async #clientForUser(userId) {
|
|
466
|
+
async #clientForUser(userId, options = {}) {
|
|
467
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
468
|
+
if (this.shuttingDown) {
|
|
469
|
+
const error = new Error('Local VM runtime is shutting down.');
|
|
470
|
+
error.code = 'RUNTIME_SHUTTING_DOWN';
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
348
473
|
if (!this.vmManager) {
|
|
349
474
|
throw new Error('Local VM manager is not available.');
|
|
350
475
|
}
|
|
351
|
-
const session = await
|
|
476
|
+
const session = await waitForAbortableResult(
|
|
477
|
+
Promise.resolve(this.vmManager.ensureVm(userId, { signal: options.signal })),
|
|
478
|
+
options.signal,
|
|
479
|
+
'VM startup aborted.',
|
|
480
|
+
);
|
|
481
|
+
if (this.shuttingDown) {
|
|
482
|
+
const error = new Error('Local VM runtime is shutting down.');
|
|
483
|
+
error.code = 'RUNTIME_SHUTTING_DOWN';
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
352
486
|
this.#touch(userId);
|
|
353
487
|
const client = new RuntimeHttpClient(session.baseUrl, session.guestToken || this.token, {
|
|
354
488
|
onActivity: () => this.#touch(userId),
|
|
@@ -356,6 +490,7 @@ class LocalVmExecutionBackend {
|
|
|
356
490
|
try {
|
|
357
491
|
await client.waitForHealth({
|
|
358
492
|
timeoutMs: Number(process.env.NEOAGENT_VM_BOOT_TIMEOUT_MS || 20 * 60 * 1000),
|
|
493
|
+
signal: options.signal,
|
|
359
494
|
checkLiveness: () => {
|
|
360
495
|
const key = String(userId || '').trim();
|
|
361
496
|
const session = this.vmManager.instances.get(key);
|
|
@@ -363,6 +498,7 @@ class LocalVmExecutionBackend {
|
|
|
363
498
|
},
|
|
364
499
|
});
|
|
365
500
|
} catch (error) {
|
|
501
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
366
502
|
const runtimeError = typeof session.getLastError === 'function' ? session.getLastError() : '';
|
|
367
503
|
const detail = runtimeError ? ` ${runtimeError}` : '';
|
|
368
504
|
throw new Error(`${error.message}${detail}`.trim());
|
|
@@ -371,7 +507,11 @@ class LocalVmExecutionBackend {
|
|
|
371
507
|
}
|
|
372
508
|
|
|
373
509
|
async executeCommand(userId, command, options = {}) {
|
|
374
|
-
const client = await this.#clientForUser(userId);
|
|
510
|
+
const client = await this.#clientForUser(userId, options);
|
|
511
|
+
const requestedCommandTimeout = Number(options.timeout || 0);
|
|
512
|
+
const transportTimeout = requestedCommandTimeout > 0
|
|
513
|
+
? Math.min(30 * 60 * 1000, requestedCommandTimeout + 30_000)
|
|
514
|
+
: 16 * 60 * 1000;
|
|
375
515
|
return client.request('POST', '/exec', {
|
|
376
516
|
command,
|
|
377
517
|
cwd: options.cwd,
|
|
@@ -379,6 +519,10 @@ class LocalVmExecutionBackend {
|
|
|
379
519
|
stdin_input: options.stdinInput,
|
|
380
520
|
pty: options.pty === true,
|
|
381
521
|
inputs: options.inputs || [],
|
|
522
|
+
}, {
|
|
523
|
+
timeoutMs: transportTimeout,
|
|
524
|
+
retryCount: 0,
|
|
525
|
+
signal: options.signal,
|
|
382
526
|
});
|
|
383
527
|
}
|
|
384
528
|
|
|
@@ -390,8 +534,8 @@ class LocalVmExecutionBackend {
|
|
|
390
534
|
});
|
|
391
535
|
}
|
|
392
536
|
|
|
393
|
-
async getBrowserProviderForUser(userId) {
|
|
394
|
-
return new VmBrowserProvider(await this.#clientForUser(userId), {
|
|
537
|
+
async getBrowserProviderForUser(userId, options = {}) {
|
|
538
|
+
return new VmBrowserProvider(await this.#clientForUser(userId, options), {
|
|
395
539
|
userId,
|
|
396
540
|
artifactStore: this.artifactStore,
|
|
397
541
|
});
|
|
@@ -431,11 +575,15 @@ class LocalVmExecutionBackend {
|
|
|
431
575
|
}
|
|
432
576
|
|
|
433
577
|
async shutdown() {
|
|
578
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
579
|
+
this.shuttingDown = true;
|
|
434
580
|
if (this.reaperInterval) {
|
|
435
581
|
clearInterval(this.reaperInterval);
|
|
436
582
|
this.reaperInterval = null;
|
|
437
583
|
}
|
|
438
|
-
|
|
584
|
+
this.shutdownPromise = Promise.resolve(this.vmManager?.shutdown?.());
|
|
585
|
+
await this.shutdownPromise;
|
|
586
|
+
return { state: 'stopped' };
|
|
439
587
|
}
|
|
440
588
|
}
|
|
441
589
|
|
|
@@ -16,6 +16,7 @@ class RuntimeManager {
|
|
|
16
16
|
this.browserExtensionRegistry = options.browserExtensionRegistry || null;
|
|
17
17
|
this.desktopCompanionRegistry = options.desktopCompanionRegistry || null;
|
|
18
18
|
this.shellWorkerPool = options.shellWorkerPool || null;
|
|
19
|
+
this.androidControllers = new Map();
|
|
19
20
|
|
|
20
21
|
const browserVmManager = options.browserVmManager || new DockerVMManager({
|
|
21
22
|
runtimeProfile: 'browser_cli',
|
|
@@ -44,6 +45,12 @@ class RuntimeManager {
|
|
|
44
45
|
artifactStore: options.artifactStore,
|
|
45
46
|
userId,
|
|
46
47
|
}));
|
|
48
|
+
|
|
49
|
+
this.createAndroidController = options.createAndroidController
|
|
50
|
+
|| ((userId) => new AndroidController({
|
|
51
|
+
userId,
|
|
52
|
+
artifactStore: this.artifactStore,
|
|
53
|
+
}));
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
|
|
@@ -91,14 +98,15 @@ class RuntimeManager {
|
|
|
91
98
|
return this.browserBackend.getCommandExecutorForUser(userId);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
async getBrowserProviderForUser(userId) {
|
|
101
|
+
async getBrowserProviderForUser(userId, options = {}) {
|
|
95
102
|
const settings = this.getSettings(userId);
|
|
96
103
|
if (settings.browser_backend === 'extension' && this.hasActiveExtensionBrowser(userId)) {
|
|
97
104
|
return this.getExtensionBrowserProvider(userId, {
|
|
98
105
|
tokenId: settings.browser_extension_token_id,
|
|
106
|
+
signal: options.signal,
|
|
99
107
|
});
|
|
100
108
|
}
|
|
101
|
-
return this.browserBackend.getBrowserProviderForUser(userId);
|
|
109
|
+
return this.browserBackend.getBrowserProviderForUser(userId, options);
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
async getCliProviderForUser(userId) {
|
|
@@ -122,12 +130,6 @@ class RuntimeManager {
|
|
|
122
130
|
|
|
123
131
|
async executeCliCommand(userId, command, options = {}) {
|
|
124
132
|
const provider = await this.getCliProviderForUser(userId);
|
|
125
|
-
// Route desktop-companion shell commands through the isolated worker pool
|
|
126
|
-
// when available. The VM (Docker) path is already isolated.
|
|
127
|
-
if (provider.backend === 'desktop-companion' && this.shellWorkerPool && options.pty !== true) {
|
|
128
|
-
const result = await this.shellWorkerPool.execute(command, options);
|
|
129
|
-
return { ...result, backend: 'desktop-companion-worker' };
|
|
130
|
-
}
|
|
131
133
|
const result = await (options.pty === true && provider.executeInteractive
|
|
132
134
|
? provider.executeInteractive(command, options.inputs || [], options)
|
|
133
135
|
: provider.execute(command, options));
|
|
@@ -146,10 +148,11 @@ class RuntimeManager {
|
|
|
146
148
|
if (userId == null || String(userId).trim() === '') {
|
|
147
149
|
throw new Error('Android provider requires a user ID.');
|
|
148
150
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
151
|
+
const key = String(userId).trim();
|
|
152
|
+
if (!this.androidControllers.has(key)) {
|
|
153
|
+
this.androidControllers.set(key, this.createAndroidController(key));
|
|
154
|
+
}
|
|
155
|
+
return this.androidControllers.get(key);
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
async isGuestAgentReadyForUser(userId, timeoutMs = 1000, capability = 'browser') {
|
|
@@ -162,7 +165,9 @@ class RuntimeManager {
|
|
|
162
165
|
async shutdown() {
|
|
163
166
|
const tasks = [
|
|
164
167
|
this.browserBackend?.shutdown?.(),
|
|
168
|
+
...Array.from(this.androidControllers.values(), (controller) => controller?.close?.()),
|
|
165
169
|
];
|
|
170
|
+
this.androidControllers.clear();
|
|
166
171
|
if (typeof this.shellWorkerPool?.shutdown === 'function') {
|
|
167
172
|
tasks.push(Promise.resolve().then(() => this.shellWorkerPool.shutdown()));
|
|
168
173
|
}
|
|
@@ -28,9 +28,12 @@ class GithubChannel extends SocialReachChannel {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async read({ url }) {
|
|
31
|
+
async read({ url, signal }) {
|
|
32
32
|
const { owner, repo } = parseRepoUrl(url);
|
|
33
|
-
const data = await fetchJson(
|
|
33
|
+
const data = await fetchJson(
|
|
34
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
|
|
35
|
+
{ signal },
|
|
36
|
+
);
|
|
34
37
|
return {
|
|
35
38
|
platform: this.id,
|
|
36
39
|
owner,
|
|
@@ -47,14 +50,17 @@ class GithubChannel extends SocialReachChannel {
|
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
async search({ query, limit }) {
|
|
53
|
+
async search({ query, limit, signal }) {
|
|
51
54
|
const q = String(query || '').trim();
|
|
52
55
|
if (!q) {
|
|
53
56
|
const error = new Error('query is required.');
|
|
54
57
|
error.status = 400;
|
|
55
58
|
throw error;
|
|
56
59
|
}
|
|
57
|
-
const data = await fetchJson(
|
|
60
|
+
const data = await fetchJson(
|
|
61
|
+
`https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&per_page=${normalizeLimit(limit, 10, 30)}`,
|
|
62
|
+
{ signal },
|
|
63
|
+
);
|
|
58
64
|
return {
|
|
59
65
|
platform: this.id,
|
|
60
66
|
query: q,
|
|
@@ -67,8 +67,8 @@ class RedditChannel extends SocialReachChannel {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
async read({ url, limit }) {
|
|
71
|
-
const data = await fetchJson(postUrlForJson(url));
|
|
70
|
+
async read({ url, limit, signal }) {
|
|
71
|
+
const data = await fetchJson(postUrlForJson(url), { signal });
|
|
72
72
|
const post = shapePost(data?.[0]?.data?.children?.[0]);
|
|
73
73
|
const comments = (data?.[1]?.data?.children || [])
|
|
74
74
|
.filter((item) => item?.kind === 't1')
|
|
@@ -82,7 +82,7 @@ class RedditChannel extends SocialReachChannel {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
async search({ query, limit }) {
|
|
85
|
+
async search({ query, limit, signal }) {
|
|
86
86
|
const q = String(query || '').trim();
|
|
87
87
|
if (!q) {
|
|
88
88
|
const error = new Error('query is required.');
|
|
@@ -90,7 +90,7 @@ class RedditChannel extends SocialReachChannel {
|
|
|
90
90
|
throw error;
|
|
91
91
|
}
|
|
92
92
|
const url = `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=${normalizeLimit(limit, 10, 50)}&raw_json=1`;
|
|
93
|
-
const data = await fetchJson(url);
|
|
93
|
+
const data = await fetchJson(url, { signal });
|
|
94
94
|
return {
|
|
95
95
|
platform: this.id,
|
|
96
96
|
query: q,
|
|
@@ -31,9 +31,9 @@ class RssChannel extends SocialReachChannel {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async read({ url, limit }) {
|
|
34
|
+
async read({ url, limit, signal }) {
|
|
35
35
|
const parsed = assertHttpUrl(url);
|
|
36
|
-
const xml = await fetchText(parsed.toString());
|
|
36
|
+
const xml = await fetchText(parsed.toString(), { publicOnly: true, signal });
|
|
37
37
|
const $ = cheerio.load(xml, { xmlMode: true });
|
|
38
38
|
const entries = $('item, entry').toArray().slice(0, normalizeLimit(limit, 20, 100));
|
|
39
39
|
const title = $('channel > title, feed > title').first().text().trim() || null;
|