neoagent 3.2.1-beta.2 → 3.2.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +347 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +8 -41
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. package/server/utils/retry.js +107 -0
@@ -1,3 +1,12 @@
1
+ export const EXTENSION_PROTOCOL_VERSION = 1;
2
+
3
+ export const MESSAGE_TYPES = Object.freeze({
4
+ COMMAND: 'command',
5
+ RESULT: 'result',
6
+ URL_VALIDATION_REQUEST: 'urlValidationRequest',
7
+ URL_VALIDATION_RESULT: 'urlValidationResult',
8
+ });
9
+
1
10
  export const COMMANDS = Object.freeze({
2
11
  LAUNCH: 'launch',
3
12
  NAVIGATE: 'navigate',
@@ -13,8 +22,21 @@ export const COMMANDS = Object.freeze({
13
22
  CLOSE: 'close',
14
23
  GET_PAGE_INFO: 'getPageInfo',
15
24
  GET_COOKIES: 'getCookies',
25
+ CANCEL_COMMAND: 'cancelCommand',
16
26
  });
17
27
 
28
+ function abortError(signal) {
29
+ if (signal?.reason instanceof Error) return signal.reason;
30
+ const error = new Error(String(signal?.reason || 'Browser extension command aborted.'));
31
+ error.name = 'AbortError';
32
+ error.code = 'ABORT_ERR';
33
+ return error;
34
+ }
35
+
36
+ function throwIfAborted(signal) {
37
+ if (signal?.aborted) throw abortError(signal);
38
+ }
39
+
18
40
  function chromeCall(chromeApi, namespace, method, ...args) {
19
41
  return new Promise((resolve, reject) => {
20
42
  chromeApi[namespace][method](...args, (result) => {
@@ -28,8 +50,50 @@ function chromeCall(chromeApi, namespace, method, ...args) {
28
50
  });
29
51
  }
30
52
 
31
- function delay(ms) {
32
- return new Promise((resolve) => setTimeout(resolve, ms));
53
+ function waitForAbortable(promise, signal = null) {
54
+ throwIfAborted(signal);
55
+ if (!signal) return promise;
56
+ return new Promise((resolve, reject) => {
57
+ let settled = false;
58
+ const finish = (callback, value) => {
59
+ if (settled) return;
60
+ settled = true;
61
+ signal.removeEventListener('abort', onAbort);
62
+ callback(value);
63
+ };
64
+ const onAbort = () => finish(reject, abortError(signal));
65
+ signal.addEventListener('abort', onAbort, { once: true });
66
+ if (signal.aborted) onAbort();
67
+ Promise.resolve(promise).then(
68
+ (value) => finish(resolve, value),
69
+ (error) => finish(reject, error),
70
+ );
71
+ });
72
+ }
73
+
74
+ async function optionalResult(promise, fallback, signal = null) {
75
+ try {
76
+ return await promise;
77
+ } catch {
78
+ throwIfAborted(signal);
79
+ return fallback;
80
+ }
81
+ }
82
+
83
+ function delay(ms, signal = null) {
84
+ throwIfAborted(signal);
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => {
87
+ signal?.removeEventListener('abort', onAbort);
88
+ resolve();
89
+ }, ms);
90
+ const onAbort = () => {
91
+ clearTimeout(timer);
92
+ reject(abortError(signal));
93
+ };
94
+ signal?.addEventListener('abort', onAbort, { once: true });
95
+ if (signal?.aborted) onAbort();
96
+ });
33
97
  }
34
98
 
35
99
  function jsString(value) {
@@ -58,113 +122,410 @@ function keyCodeFor(key) {
58
122
  return map[normalized] || (normalized.length === 1 ? normalized.toUpperCase().charCodeAt(0) : 0);
59
123
  }
60
124
 
61
- export function createBrowserProtocol(chromeApi) {
125
+ function normalizePointCoordinate(value, label) {
126
+ if (value == null || value === '') {
127
+ throw new Error(`${label} coordinate is required.`);
128
+ }
129
+ const normalized = Number(value);
130
+ if (!Number.isFinite(normalized)) {
131
+ throw new Error(`${label} coordinate must be a finite number.`);
132
+ }
133
+ return Math.round(normalized);
134
+ }
135
+
136
+ function ipv4Octets(hostname) {
137
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) return null;
138
+ const parts = hostname.split('.').map(Number);
139
+ return parts.every((part) => part >= 0 && part <= 255) ? parts : null;
140
+ }
141
+
142
+ export function isPrivateNetworkHostname(hostname) {
143
+ const normalized = String(hostname || '')
144
+ .toLowerCase()
145
+ .replace(/^\[|\]$/g, '')
146
+ .replace(/\.$/, '');
147
+ if (!normalized) return true;
148
+ if (
149
+ normalized === 'localhost'
150
+ || normalized === 'localhost.localdomain'
151
+ || normalized.endsWith('.localhost')
152
+ || normalized.endsWith('.local')
153
+ || normalized.endsWith('.internal')
154
+ ) {
155
+ return true;
156
+ }
157
+
158
+ const octets = ipv4Octets(normalized);
159
+ if (octets) {
160
+ const [a, b, c] = octets;
161
+ return a === 0
162
+ || a === 10
163
+ || a === 127
164
+ || a >= 224
165
+ || (a === 100 && b >= 64 && b <= 127)
166
+ || (a === 169 && b === 254)
167
+ || (a === 172 && b >= 16 && b <= 31)
168
+ || (a === 192 && b === 168)
169
+ || (a === 192 && b === 0 && c === 0)
170
+ || (a === 192 && b === 0 && c === 2)
171
+ || (a === 198 && (b === 18 || b === 19))
172
+ || (a === 198 && b === 51 && c === 100)
173
+ || (a === 203 && b === 0 && c === 113);
174
+ }
175
+
176
+ if (normalized.includes(':')) {
177
+ if (normalized === '::' || normalized === '::1') return true;
178
+ if (normalized.startsWith('::ffff:')) return true;
179
+ const first = Number.parseInt(normalized.split(':').find(Boolean) || '0', 16);
180
+ if (!Number.isFinite(first)) return true;
181
+ if ((first & 0xfe00) === 0xfc00) return true;
182
+ if ((first & 0xffc0) === 0xfe80) return true;
183
+ if ((first & 0xffc0) === 0xfec0) return true;
184
+ if ((first & 0xff00) === 0xff00) return true;
185
+ if (normalized.startsWith('2001:db8:')) return true;
186
+ }
187
+
188
+ return false;
189
+ }
190
+
191
+ export function normalizeNetworkValidationUrl(value) {
192
+ const raw = String(value || '');
193
+ if (!raw || raw.length > 8192) return null;
194
+ let parsed;
195
+ try {
196
+ parsed = new URL(raw);
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) return null;
201
+ if (parsed.username || parsed.password || isPrivateNetworkHostname(parsed.hostname)) return null;
202
+ const protocol = parsed.protocol === 'ws:'
203
+ ? 'http:'
204
+ : (parsed.protocol === 'wss:' ? 'https:' : parsed.protocol);
205
+ return `${protocol}//${parsed.host}/`;
206
+ }
207
+
208
+ const PAGE_ACCESS_COMMANDS = new Set([
209
+ COMMANDS.LAUNCH,
210
+ COMMANDS.CLICK,
211
+ COMMANDS.CLICK_POINT,
212
+ COMMANDS.TYPE,
213
+ COMMANDS.TYPE_TEXT,
214
+ COMMANDS.PRESS_KEY,
215
+ COMMANDS.SCROLL,
216
+ COMMANDS.EXTRACT,
217
+ COMMANDS.EVALUATE,
218
+ COMMANDS.SCREENSHOT,
219
+ COMMANDS.GET_PAGE_INFO,
220
+ ]);
221
+
222
+ export function createBrowserProtocol(chromeApi, options = {}) {
62
223
  let attachedTabId = null;
63
224
  let activeTabId = null;
225
+ const pausedRequests = new Map();
226
+ const maxPausedRequests = Math.max(1, Math.min(Number(options.maxPausedRequests) || 256, 1024));
227
+ const validateUrl = typeof options.validateUrl === 'function'
228
+ ? options.validateUrl
229
+ : async () => false;
64
230
 
65
231
  const debuggee = () => ({ tabId: activeTabId });
66
232
 
67
- async function ensureTab() {
233
+ const call = (signal, namespace, method, ...args) => waitForAbortable(
234
+ chromeCall(chromeApi, namespace, method, ...args),
235
+ signal,
236
+ );
237
+
238
+ function abortPausedRequests(tabId, reason) {
239
+ for (const entry of pausedRequests.values()) {
240
+ if (entry.tabId === tabId && !entry.controller.signal.aborted) {
241
+ entry.controller.abort(reason);
242
+ }
243
+ }
244
+ }
245
+
246
+ async function sendToTab(tabId, method, params = {}, signal = null) {
247
+ try {
248
+ return await call(signal, 'debugger', 'sendCommand', { tabId }, method, params);
249
+ } catch (error) {
250
+ if (
251
+ tabId === attachedTabId
252
+ && /not attached|no tab with given id|target closed/i.test(String(error?.message || ''))
253
+ ) {
254
+ attachedTabId = null;
255
+ }
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ async function isNetworkUrlAllowed(url, signal = null) {
261
+ throwIfAborted(signal);
262
+ const normalized = normalizeNetworkValidationUrl(url);
263
+ if (!normalized) return false;
264
+ const result = await validateUrl(normalized, { signal });
265
+ throwIfAborted(signal);
266
+ return result === true || result?.allowed === true;
267
+ }
268
+
269
+ async function handlePausedRequest(source, params) {
270
+ const tabId = source?.tabId;
271
+ const requestId = String(params?.requestId || '');
272
+ if (tabId == null || tabId !== attachedTabId || !requestId) return;
273
+ if (pausedRequests.size >= maxPausedRequests) {
274
+ await sendToTab(tabId, 'Fetch.failRequest', {
275
+ requestId,
276
+ errorReason: 'BlockedByClient',
277
+ }).catch(() => {});
278
+ return;
279
+ }
280
+
281
+ const key = `${tabId}:${requestId}`;
282
+ const previous = pausedRequests.get(key);
283
+ previous?.controller.abort(new Error('Browser request interception was superseded.'));
284
+ const controller = new AbortController();
285
+ pausedRequests.set(key, { tabId, controller });
286
+ let allowed = false;
287
+ try {
288
+ allowed = await isNetworkUrlAllowed(params?.request?.url, controller.signal);
289
+ } catch {
290
+ allowed = false;
291
+ }
292
+ try {
293
+ await sendToTab(
294
+ tabId,
295
+ allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest',
296
+ allowed
297
+ ? { requestId }
298
+ : { requestId, errorReason: 'BlockedByClient' },
299
+ );
300
+ } catch {
301
+ // Detach and tab-close races make the paused request disappear on their own.
302
+ } finally {
303
+ if (pausedRequests.get(key)?.controller === controller) pausedRequests.delete(key);
304
+ }
305
+ }
306
+
307
+ chromeApi.debugger?.onEvent?.addListener?.((source, method, params) => {
308
+ if (method !== 'Fetch.requestPaused') return;
309
+ handlePausedRequest(source, params).catch(() => {});
310
+ });
311
+ chromeApi.debugger?.onDetach?.addListener?.((source) => {
312
+ if (source?.tabId === attachedTabId) {
313
+ abortPausedRequests(source.tabId, new Error('Browser debugger detached.'));
314
+ attachedTabId = null;
315
+ }
316
+ });
317
+ chromeApi.tabs?.onRemoved?.addListener?.((tabId) => {
318
+ if (tabId === activeTabId) activeTabId = null;
319
+ if (tabId === attachedTabId) {
320
+ abortPausedRequests(tabId, new Error('Browser tab closed.'));
321
+ attachedTabId = null;
322
+ }
323
+ });
324
+
325
+ async function ensureTab(signal = null) {
326
+ throwIfAborted(signal);
68
327
  if (activeTabId != null) {
69
328
  try {
70
- await chromeCall(chromeApi, 'tabs', 'get', activeTabId);
329
+ await call(signal, 'tabs', 'get', activeTabId);
71
330
  return activeTabId;
72
331
  } catch {
332
+ throwIfAborted(signal);
73
333
  activeTabId = null;
74
334
  }
75
335
  }
76
336
 
77
- const tabs = await chromeCall(chromeApi, 'tabs', 'query', { active: true, currentWindow: true });
337
+ const tabs = await call(signal, 'tabs', 'query', { active: true, currentWindow: true });
78
338
  if (tabs && tabs[0]?.id != null) {
79
339
  activeTabId = tabs[0].id;
80
340
  return activeTabId;
81
341
  }
82
- const tab = await chromeCall(chromeApi, 'tabs', 'create', { url: 'about:blank', active: true });
342
+ const tab = await call(signal, 'tabs', 'create', { url: 'about:blank', active: true });
83
343
  activeTabId = tab.id;
84
344
  return activeTabId;
85
345
  }
86
346
 
87
- async function attach() {
88
- await ensureTab();
347
+ async function attach(signal = null) {
348
+ await ensureTab(signal);
89
349
  if (attachedTabId === activeTabId) return;
90
350
  if (attachedTabId != null) {
91
- await chromeCall(chromeApi, 'debugger', 'detach', { tabId: attachedTabId }).catch(() => {});
351
+ abortPausedRequests(attachedTabId, new Error('Browser control moved to another tab.'));
352
+ await optionalResult(
353
+ call(signal, 'debugger', 'detach', { tabId: attachedTabId }),
354
+ undefined,
355
+ signal,
356
+ );
92
357
  }
93
358
  try {
94
- await chromeCall(chromeApi, 'debugger', 'attach', debuggee(), '1.3');
359
+ await call(signal, 'debugger', 'attach', debuggee(), '1.3');
95
360
  } catch (error) {
361
+ throwIfAborted(signal);
96
362
  if (!/another debugger|already attached|debugger is already attached/i.test(error.message)) {
97
363
  throw error;
98
364
  }
99
365
  }
100
366
  attachedTabId = activeTabId;
101
- await send('Page.enable').catch(() => {});
102
- await send('Runtime.enable').catch(() => {});
103
- await send('DOM.enable').catch(() => {});
367
+ try {
368
+ await sendToTab(attachedTabId, 'Fetch.enable', {
369
+ patterns: [
370
+ { urlPattern: 'http://*/*', requestStage: 'Request' },
371
+ { urlPattern: 'https://*/*', requestStage: 'Request' },
372
+ { urlPattern: 'ws://*/*', requestStage: 'Request' },
373
+ { urlPattern: 'wss://*/*', requestStage: 'Request' },
374
+ ],
375
+ }, signal);
376
+ await optionalResult(send('Page.enable', {}, signal), undefined, signal);
377
+ await optionalResult(send('Runtime.enable', {}, signal), undefined, signal);
378
+ await optionalResult(send('DOM.enable', {}, signal), undefined, signal);
379
+ } catch (error) {
380
+ const failedTabId = attachedTabId;
381
+ abortPausedRequests(failedTabId, error);
382
+ attachedTabId = null;
383
+ if (failedTabId != null) {
384
+ await chromeCall(chromeApi, 'debugger', 'detach', { tabId: failedTabId }).catch(() => {});
385
+ }
386
+ throw error;
387
+ }
104
388
  }
105
389
 
106
- async function send(method, params = {}) {
107
- return chromeCall(chromeApi, 'debugger', 'sendCommand', debuggee(), method, params);
390
+ async function send(method, params = {}, signal = null) {
391
+ const tabId = attachedTabId ?? activeTabId;
392
+ if (tabId == null) throw new Error('No browser tab is available.');
393
+ return sendToTab(tabId, method, params, signal);
108
394
  }
109
395
 
110
- async function evalJs(expression, options = {}) {
111
- await attach();
396
+ async function evalJs(expression, options = {}, signal = null) {
397
+ await attach(signal);
112
398
  const response = await send('Runtime.evaluate', {
113
399
  expression,
114
400
  awaitPromise: options.awaitPromise !== false,
115
401
  returnByValue: true,
116
- });
402
+ }, signal);
117
403
  if (response?.exceptionDetails) {
118
404
  throw new Error(response.exceptionDetails.text || 'JavaScript evaluation failed.');
119
405
  }
120
406
  return response?.result?.value;
121
407
  }
122
408
 
123
- async function waitForLoad(timeoutMs = 30000) {
409
+ async function waitForLoad(timeoutMs = 30000, signal = null) {
124
410
  const started = Date.now();
125
411
  while (Date.now() - started < timeoutMs) {
126
- const ready = await evalJs('document.readyState === "complete" || document.readyState === "interactive"', { awaitPromise: false })
127
- .catch(() => false);
412
+ throwIfAborted(signal);
413
+ const ready = await optionalResult(
414
+ evalJs(
415
+ 'document.readyState === "complete" || document.readyState === "interactive"',
416
+ { awaitPromise: false },
417
+ signal,
418
+ ),
419
+ false,
420
+ signal,
421
+ );
128
422
  if (ready) return;
129
- await delay(250);
423
+ await delay(250, signal);
130
424
  }
425
+ throw new Error(`Page did not finish loading within ${timeoutMs}ms.`);
131
426
  }
132
427
 
133
- async function waitForSelector(selector, timeoutMs = 10000) {
428
+ async function markCurrentDocument(signal = null) {
429
+ const key = `__neoagent_document_${crypto.randomUUID().replace(/-/g, '')}`;
430
+ const token = crypto.randomUUID();
431
+ const marked = await optionalResult(
432
+ evalJs(
433
+ `globalThis[${jsString(key)}] = ${jsString(token)}; true`,
434
+ { awaitPromise: false },
435
+ signal,
436
+ ),
437
+ false,
438
+ signal,
439
+ );
440
+ return marked ? { key, token } : null;
441
+ }
442
+
443
+ async function waitForDocumentReplacement(marker, timeoutMs = 30000, signal = null) {
444
+ if (!marker) {
445
+ await delay(250, signal);
446
+ return;
447
+ }
448
+ const started = Date.now();
449
+ const expression = `globalThis[${jsString(marker.key)}] !== ${jsString(marker.token)}`;
450
+ while (Date.now() - started < timeoutMs) {
451
+ throwIfAborted(signal);
452
+ const replaced = await optionalResult(
453
+ evalJs(expression, { awaitPromise: false }, signal),
454
+ false,
455
+ signal,
456
+ );
457
+ if (replaced) return;
458
+ await delay(100, signal);
459
+ }
460
+ throw new Error(`Page navigation did not commit within ${timeoutMs}ms.`);
461
+ }
462
+
463
+ async function assertNavigationSucceeded(signal = null) {
464
+ const frameTree = await send('Page.getFrameTree', {}, signal);
465
+ const unreachableUrl = String(frameTree?.frameTree?.frame?.unreachableUrl || '');
466
+ if (unreachableUrl) {
467
+ throw new Error('Browser navigation failed before the destination loaded.');
468
+ }
469
+ await assertCurrentPageAllowed(signal);
470
+ }
471
+
472
+ async function waitForSelector(selector, timeoutMs = 10000, signal = null) {
134
473
  if (!selector) return;
135
474
  const expression = `Boolean(document.querySelector(${jsString(selector)}))`;
136
475
  const started = Date.now();
137
476
  while (Date.now() - started < timeoutMs) {
138
- if (await evalJs(expression, { awaitPromise: false }).catch(() => false)) return;
139
- await delay(200);
477
+ throwIfAborted(signal);
478
+ if (await optionalResult(evalJs(expression, { awaitPromise: false }, signal), false, signal)) return;
479
+ await delay(200, signal);
140
480
  }
481
+ throw new Error(`Element not found within ${timeoutMs}ms: ${selector}`);
141
482
  }
142
483
 
143
- async function currentTab() {
144
- await ensureTab();
145
- return chromeCall(chromeApi, 'tabs', 'get', activeTabId);
484
+ async function currentTab(signal = null) {
485
+ await ensureTab(signal);
486
+ return call(signal, 'tabs', 'get', activeTabId);
146
487
  }
147
488
 
148
- async function screenshotDataUrl(options = {}) {
149
- await attach();
489
+ async function assertCurrentPageAllowed(signal = null) {
490
+ const tab = await currentTab(signal);
491
+ const url = String(tab?.url || '');
492
+ if (url === 'about:blank') return tab;
493
+ if (!await isNetworkUrlAllowed(url, signal)) {
494
+ throw new Error('The current browser page is not permitted.');
495
+ }
496
+ return tab;
497
+ }
498
+
499
+ async function screenshotDataUrl(options = {}, signal = null) {
500
+ throwIfAborted(signal);
501
+ await attach(signal);
150
502
  const capture = await send('Page.captureScreenshot', {
151
503
  format: 'png',
152
504
  captureBeyondViewport: options.fullPage === true,
153
505
  fromSurface: true,
154
- });
155
- return `data:image/png;base64,${capture?.data || ''}`;
506
+ }, signal);
507
+ const encoded = String(capture?.data || '');
508
+ if (encoded.length > 24 * 1024 * 1024) {
509
+ throw new Error('Browser screenshot is too large to transfer. Try a viewport screenshot instead.');
510
+ }
511
+ return `data:image/png;base64,${encoded}`;
156
512
  }
157
513
 
158
- async function pageSnapshot(options = {}) {
159
- const tab = await currentTab();
160
- const title = await evalJs('document.title || ""', { awaitPromise: false }).catch(() => tab.title || '');
161
- const bodyText = await evalJs(`(() => {
514
+ async function pageSnapshot(options = {}, signal = null) {
515
+ throwIfAborted(signal);
516
+ const tab = await currentTab(signal);
517
+ const title = await optionalResult(
518
+ evalJs('document.title || ""', { awaitPromise: false }, signal),
519
+ tab.title || '',
520
+ signal,
521
+ );
522
+ const bodyText = await optionalResult(evalJs(`(() => {
162
523
  const body = document.body;
163
524
  if (!body) return '';
164
525
  const clone = body.cloneNode(true);
165
526
  clone.querySelectorAll('script, style, noscript').forEach((node) => node.remove());
166
527
  return String(clone.innerText || '').slice(0, 10000);
167
- })()`).catch(() => '');
528
+ })()`, {}, signal), '', signal);
168
529
  const result = {
169
530
  title: title || tab.title || '',
170
531
  url: tab.url || '',
@@ -172,12 +533,13 @@ export function createBrowserProtocol(chromeApi) {
172
533
  bodyText,
173
534
  };
174
535
  if (options.screenshot !== false) {
175
- result.screenshotDataUrl = await screenshotDataUrl(options);
536
+ result.screenshotDataUrl = await screenshotDataUrl(options, signal);
176
537
  }
177
538
  return result;
178
539
  }
179
540
 
180
- async function locateTarget(payload = {}) {
541
+ async function locateTarget(payload = {}, signal = null) {
542
+ throwIfAborted(signal);
181
543
  const selector = String(payload.selector || '').trim();
182
544
  const text = String(payload.text || '').trim().toLowerCase();
183
545
  const expression = selector
@@ -194,42 +556,50 @@ export function createBrowserProtocol(chromeApi) {
194
556
  const rect = target.getBoundingClientRect();
195
557
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
196
558
  })()`;
197
- const point = await evalJs(expression);
559
+ const point = await evalJs(expression, {}, signal);
198
560
  if (!point || !Number.isFinite(Number(point.x)) || !Number.isFinite(Number(point.y))) {
199
561
  throw new Error(selector ? `Element not found: ${selector}` : `No clickable element found with text: ${payload.text}`);
200
562
  }
201
563
  return { x: Math.round(point.x), y: Math.round(point.y) };
202
564
  }
203
565
 
204
- async function clickPoint(x, y) {
205
- await attach();
206
- const px = Math.max(0, Math.round(Number(x) || 0));
207
- const py = Math.max(0, Math.round(Number(y) || 0));
208
- await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: px, y: py });
209
- await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: px, y: py, button: 'left', clickCount: 1 });
210
- await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: px, y: py, button: 'left', clickCount: 1 });
211
- await delay(500);
566
+ async function clickPoint(x, y, signal = null) {
567
+ throwIfAborted(signal);
568
+ await attach(signal);
569
+ const px = Math.max(0, normalizePointCoordinate(x, 'x'));
570
+ const py = Math.max(0, normalizePointCoordinate(y, 'y'));
571
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: px, y: py }, signal);
572
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: px, y: py, button: 'left', clickCount: 1 }, signal);
573
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: px, y: py, button: 'left', clickCount: 1 }, signal);
574
+ await delay(500, signal);
212
575
  return { x: px, y: py };
213
576
  }
214
577
 
215
- async function typeKey(key) {
216
- await attach();
578
+ async function typeKey(key, signal = null) {
579
+ await attach(signal);
217
580
  const normalized = String(key || '').trim();
218
581
  const code = keyCodeFor(normalized);
219
- await send('Input.dispatchKeyEvent', { type: 'keyDown', key: normalized, windowsVirtualKeyCode: code });
220
- await send('Input.dispatchKeyEvent', { type: 'keyUp', key: normalized, windowsVirtualKeyCode: code });
582
+ await send('Input.dispatchKeyEvent', { type: 'keyDown', key: normalized, windowsVirtualKeyCode: code }, signal);
583
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', key: normalized, windowsVirtualKeyCode: code }, signal);
221
584
  }
222
585
 
223
- async function run(command, payload = {}) {
586
+ async function run(command, payload = {}, options = {}) {
587
+ const signal = options.signal || null;
588
+ throwIfAborted(signal);
589
+ if (PAGE_ACCESS_COMMANDS.has(command)) {
590
+ await assertCurrentPageAllowed(signal);
591
+ }
224
592
  switch (command) {
225
593
  case COMMANDS.GET_COOKIES: {
226
594
  const domains = Array.isArray(payload.domains)
227
595
  ? payload.domains.map((item) => String(item || '').replace(/^\./, '').toLowerCase()).filter(Boolean)
228
596
  : [];
229
597
  if (domains.length === 0) throw new Error('At least one cookie domain is required.');
598
+ if (domains.length > 50) throw new Error('At most 50 cookie domains may be requested.');
230
599
  const all = [];
231
600
  for (const domain of domains) {
232
- const cookies = await chromeCall(chromeApi, 'cookies', 'getAll', { domain });
601
+ throwIfAborted(signal);
602
+ const cookies = await call(signal, 'cookies', 'getAll', { domain });
233
603
  all.push(...(Array.isArray(cookies) ? cookies : []));
234
604
  }
235
605
  const seen = new Set();
@@ -258,50 +628,84 @@ export function createBrowserProtocol(chromeApi) {
258
628
  };
259
629
  }
260
630
  case COMMANDS.LAUNCH:
261
- await attach();
262
- return pageSnapshot({ screenshot: false });
631
+ await attach(signal);
632
+ return pageSnapshot({ screenshot: false }, signal);
263
633
  case COMMANDS.NAVIGATE:
264
- await attach();
265
634
  if (!payload.url) throw new Error('url required');
266
- await send('Page.navigate', { url: String(payload.url) });
267
- await waitForLoad();
268
- await waitForSelector(payload.waitFor);
269
- return pageSnapshot(payload);
635
+ if (!await isNetworkUrlAllowed(payload.url, signal)) {
636
+ throw new Error('This browser URL is not permitted.');
637
+ }
638
+ await attach(signal);
639
+ {
640
+ const marker = await markCurrentDocument(signal);
641
+ let completed = false;
642
+ try {
643
+ const navigation = await send('Page.navigate', { url: String(payload.url) }, signal);
644
+ if (navigation?.errorText) {
645
+ throw new Error(`Browser navigation failed: ${navigation.errorText}`);
646
+ }
647
+ if (navigation?.isDownload === true) {
648
+ throw new Error('Browser navigation started a download instead of loading a page.');
649
+ }
650
+ if (navigation?.loaderId) {
651
+ await waitForDocumentReplacement(marker, 30000, signal);
652
+ } else {
653
+ await delay(100, signal);
654
+ }
655
+ await waitForLoad(30000, signal);
656
+ await assertNavigationSucceeded(signal);
657
+ await waitForSelector(payload.waitFor, 10000, signal);
658
+ throwIfAborted(signal);
659
+ completed = true;
660
+ return pageSnapshot(payload, signal);
661
+ } finally {
662
+ if (!completed) await send('Page.stopLoading').catch(() => {});
663
+ }
664
+ }
270
665
  case COMMANDS.CLICK: {
271
- const point = await locateTarget(payload);
272
- await clickPoint(point.x, point.y);
273
- return pageSnapshot({ screenshot: payload.screenshot !== false });
666
+ const point = await locateTarget(payload, signal);
667
+ await clickPoint(point.x, point.y, signal);
668
+ throwIfAborted(signal);
669
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
274
670
  }
275
671
  case COMMANDS.CLICK_POINT:
276
- await clickPoint(payload.x, payload.y);
277
- return pageSnapshot({ screenshot: payload.screenshot !== false });
672
+ await clickPoint(payload.x, payload.y, signal);
673
+ throwIfAborted(signal);
674
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
278
675
  case COMMANDS.TYPE:
279
676
  if (!payload.selector) throw new Error('selector required');
280
677
  if (payload.clear !== false) {
281
678
  await evalJs(`(() => {
282
679
  const el = document.querySelector(${jsString(payload.selector)});
283
- if (!el) throw new Error('Element not found: ${String(payload.selector).replace(/'/g, "\\'")}');
680
+ if (!el) throw new Error(${jsString(`Element not found: ${payload.selector}`)});
284
681
  el.focus();
285
682
  if ('value' in el) el.value = '';
286
- })()`);
683
+ })()`, {}, signal);
287
684
  } else {
288
- await evalJs(`document.querySelector(${jsString(payload.selector)})?.focus()`);
685
+ await evalJs(`document.querySelector(${jsString(payload.selector)})?.focus()`, {}, signal);
289
686
  }
290
- await send('Input.insertText', { text: String(payload.text || '') });
291
- if (payload.pressEnter) await typeKey('Enter');
292
- return pageSnapshot({ screenshot: payload.screenshot !== false });
687
+ await send('Input.insertText', { text: String(payload.text || '') }, signal);
688
+ throwIfAborted(signal);
689
+ if (payload.pressEnter) await typeKey('Enter', signal);
690
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
293
691
  case COMMANDS.TYPE_TEXT:
294
- await attach();
295
- await send('Input.insertText', { text: String(payload.text || '') });
296
- if (payload.pressEnter) await typeKey('Enter');
297
- return pageSnapshot({ screenshot: payload.screenshot !== false });
692
+ await attach(signal);
693
+ await send('Input.insertText', { text: String(payload.text || '') }, signal);
694
+ throwIfAborted(signal);
695
+ if (payload.pressEnter) await typeKey('Enter', signal);
696
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
298
697
  case COMMANDS.PRESS_KEY:
299
- await typeKey(payload.key);
300
- return pageSnapshot({ screenshot: payload.screenshot !== false });
698
+ await typeKey(payload.key, signal);
699
+ throwIfAborted(signal);
700
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
301
701
  case COMMANDS.SCROLL:
302
- await evalJs(`window.scrollBy(${Math.round(Number(payload.deltaX) || 0)}, ${Math.round(Number(payload.deltaY) || 0)})`);
303
- await delay(250);
304
- return pageSnapshot({ screenshot: payload.screenshot !== false });
702
+ await evalJs(
703
+ `window.scrollBy(${Math.round(Number(payload.deltaX) || 0)}, ${Math.round(Number(payload.deltaY) || 0)})`,
704
+ {},
705
+ signal,
706
+ );
707
+ await delay(250, signal);
708
+ return pageSnapshot({ screenshot: payload.screenshot !== false }, signal);
305
709
  case COMMANDS.EXTRACT: {
306
710
  const selector = payload.selector || 'body';
307
711
  const attribute = payload.attribute || '';
@@ -314,24 +718,40 @@ export function createBrowserProtocol(chromeApi) {
314
718
  return el.innerText || '';
315
719
  };
316
720
  const els = Array.from(document.querySelectorAll(${jsString(selector)}));
317
- if (${payload.all === true}) return { results: els.slice(0, 100).map(read) };
721
+ if (${payload.all === true}) {
722
+ return { results: els.slice(0, 100).map((el) => String(read(el)).slice(0, 50000)) };
723
+ }
318
724
  return { result: els[0] ? String(read(els[0])).slice(0, 50000) : '' };
319
725
  })()`;
320
- return evalJs(expression);
726
+ return evalJs(expression, {}, signal);
321
727
  }
322
728
  case COMMANDS.EVALUATE: {
323
- const value = await evalJs(buildIsolatedEvaluationExpression(payload.script));
324
- return { result: typeof value === 'object' ? JSON.stringify(value) : String(value) };
729
+ if (String(payload.script || '').length > 10000) {
730
+ throw new Error('script exceeds maximum length (10000)');
731
+ }
732
+ const value = await evalJs(buildIsolatedEvaluationExpression(payload.script), {}, signal);
733
+ throwIfAborted(signal);
734
+ const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value);
735
+ const maxChars = 1024 * 1024;
736
+ return {
737
+ result: String(serialized ?? '').slice(0, maxChars),
738
+ truncated: String(serialized ?? '').length > maxChars,
739
+ };
325
740
  }
326
741
  case COMMANDS.SCREENSHOT:
327
- return { screenshotDataUrl: await screenshotDataUrl(payload), fullPage: payload.fullPage === true };
742
+ return { screenshotDataUrl: await screenshotDataUrl(payload, signal), fullPage: payload.fullPage === true };
328
743
  case COMMANDS.GET_PAGE_INFO: {
329
- const tab = await currentTab();
744
+ const tab = await currentTab(signal);
330
745
  return { url: tab.url || null, title: tab.title || null };
331
746
  }
332
747
  case COMMANDS.CLOSE:
333
748
  if (attachedTabId != null) {
334
- await chromeCall(chromeApi, 'debugger', 'detach', { tabId: attachedTabId }).catch(() => {});
749
+ abortPausedRequests(attachedTabId, new Error('Browser control closed.'));
750
+ await optionalResult(
751
+ call(signal, 'debugger', 'detach', { tabId: attachedTabId }),
752
+ undefined,
753
+ signal,
754
+ );
335
755
  }
336
756
  attachedTabId = null;
337
757
  return { success: true };
@@ -347,6 +767,8 @@ export function createBrowserProtocol(chromeApi) {
347
767
  attach,
348
768
  send,
349
769
  evalJs,
770
+ handlePausedRequest,
771
+ isNetworkUrlAllowed,
350
772
  },
351
773
  };
352
774
  }