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.
Files changed (145) 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/messaging/whatsapp.js +22 -14
  116. package/server/services/network/http.js +210 -0
  117. package/server/services/network/safe_request.js +307 -0
  118. package/server/services/runtime/backends/local-vm.js +214 -66
  119. package/server/services/runtime/manager.js +17 -12
  120. package/server/services/social_reach/channels/github.js +10 -4
  121. package/server/services/social_reach/channels/reddit.js +4 -4
  122. package/server/services/social_reach/channels/rss.js +2 -2
  123. package/server/services/social_reach/channels/social_video.js +12 -7
  124. package/server/services/social_reach/channels/v2ex.js +21 -8
  125. package/server/services/social_reach/channels/x.js +2 -2
  126. package/server/services/social_reach/channels/xueqiu.js +5 -5
  127. package/server/services/social_reach/service.js +9 -6
  128. package/server/services/social_reach/utils.js +65 -14
  129. package/server/services/social_video/service.js +160 -50
  130. package/server/services/tasks/integration_runtime.js +18 -8
  131. package/server/services/tasks/runtime.js +39 -4
  132. package/server/services/voice/agentBridge.js +17 -4
  133. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  134. package/server/services/voice/liveSession.js +31 -0
  135. package/server/services/voice/openaiSpeech.js +33 -8
  136. package/server/services/voice/providers.js +233 -151
  137. package/server/services/voice/runtimeManager.js +118 -20
  138. package/server/services/voice/turnRunner.js +6 -0
  139. package/server/services/wearable/firmware_manifest.js +51 -13
  140. package/server/services/wearable/service.js +1 -0
  141. package/server/utils/abort.js +96 -0
  142. package/server/utils/cloud-security.js +110 -3
  143. package/server/utils/files.js +31 -0
  144. package/server/utils/image_payload.js +95 -0
  145. package/server/utils/retry.js +107 -0
@@ -1,16 +1,31 @@
1
- import { createBrowserProtocol } from './protocol.mjs';
1
+ import {
2
+ EXTENSION_PROTOCOL_VERSION,
3
+ MESSAGE_TYPES,
4
+ createBrowserProtocol,
5
+ } from './protocol.mjs';
2
6
  import { DEFAULT_SERVER_URL } from './config.mjs';
7
+ import { fetchJsonWithTimeout } from './http.mjs';
3
8
 
4
9
  const STORAGE_KEYS = ['serverUrl', 'configuredServerUrl', 'token', 'pairingId', 'pairingSecret', 'approvalUrl', 'status', 'extensionName'];
5
- const protocol = createBrowserProtocol(chrome);
6
10
  let socket = null;
7
11
  let reconnectTimer = null;
8
- let suppressSocketClose = false;
9
- const DEFAULT_FETCH_TIMEOUT_MS = 10000;
12
+ let connectPromise = null;
13
+ let connectionEpoch = 0;
14
+ let reconnectAttempt = 0;
15
+ let statusUpdateQueue = Promise.resolve();
16
+ let commandQueue = Promise.resolve();
17
+ const activeCommandControllers = new Map();
18
+ const pendingUrlValidations = new Map();
10
19
  const DEFAULT_WS_CONNECT_TIMEOUT_MS = 10000;
20
+ const DEFAULT_URL_VALIDATION_TIMEOUT_MS = 6500;
21
+ const MAX_PENDING_URL_VALIDATIONS = 256;
22
+ const RECONNECT_BASE_DELAY_MS = 1000;
23
+ const RECONNECT_MAX_DELAY_MS = 60 * 1000;
11
24
  const KEEPALIVE_ALARM_NAME = 'neoagent-extension-keepalive';
12
25
  const KEEPALIVE_ALARM_MINUTES = 1;
13
- const EXTENSION_PROTOCOL_VERSION = 1;
26
+ const protocol = createBrowserProtocol(chrome, {
27
+ validateUrl: requestUrlValidation,
28
+ });
14
29
 
15
30
  function getStorage(keys = STORAGE_KEYS) {
16
31
  return chrome.storage.local.get(keys);
@@ -25,7 +40,24 @@ function removeStorage(keys) {
25
40
  }
26
41
 
27
42
  function normalizeServerUrl(value) {
28
- return String(value || '').trim().replace(/\/+$/, '');
43
+ const raw = String(value || '').trim();
44
+ if (!raw) return '';
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(raw);
48
+ } catch {
49
+ throw new Error('NeoAgent server URL is invalid.');
50
+ }
51
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
52
+ throw new Error('NeoAgent server URL must use http or https.');
53
+ }
54
+ if (parsed.username || parsed.password) {
55
+ throw new Error('NeoAgent server URL must not contain credentials.');
56
+ }
57
+ parsed.search = '';
58
+ parsed.hash = '';
59
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
60
+ return parsed.toString().replace(/\/+$/, '');
29
61
  }
30
62
 
31
63
  function configuredServerUrl() {
@@ -40,7 +72,7 @@ async function resolveServerUrl(preferred) {
40
72
  }
41
73
 
42
74
  function websocketUrl(serverUrl, token) {
43
- const url = new URL('/api/browser-extension/ws', serverUrl);
75
+ const url = new URL('api/browser-extension/ws', `${serverUrl}/`);
44
76
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
45
77
  // Token in the URL is required for the HTTP upgrade handshake; the browser
46
78
  // WebSocket API does not support custom headers. Ensure the server's access
@@ -49,19 +81,6 @@ function websocketUrl(serverUrl, token) {
49
81
  return url.toString();
50
82
  }
51
83
 
52
- async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
53
- const controller = new AbortController();
54
- const timer = setTimeout(() => controller.abort(), timeoutMs);
55
- try {
56
- return await fetch(url, {
57
- ...options,
58
- signal: controller.signal,
59
- });
60
- } finally {
61
- clearTimeout(timer);
62
- }
63
- }
64
-
65
84
  function compareVersions(a, b) {
66
85
  const left = String(a || '0').split('.').map((part) => Number(part) || 0);
67
86
  const right = String(b || '0').split('.').map((part) => Number(part) || 0);
@@ -73,9 +92,14 @@ function compareVersions(a, b) {
73
92
  return 0;
74
93
  }
75
94
 
76
- async function updateStatus(status) {
77
- await setStorage({ status });
78
- chrome.runtime.sendMessage({ type: 'status', status }).catch(() => {});
95
+ async function updateStatus(status, expectedEpoch = null) {
96
+ const task = statusUpdateQueue.catch(() => {}).then(async () => {
97
+ if (expectedEpoch != null && expectedEpoch !== connectionEpoch) return;
98
+ await setStorage({ status });
99
+ await Promise.resolve(chrome.runtime.sendMessage({ type: 'status', status })).catch(() => {});
100
+ });
101
+ statusUpdateQueue = task.catch(() => {});
102
+ return task;
79
103
  }
80
104
 
81
105
  function clearReconnectTimer() {
@@ -83,113 +107,298 @@ function clearReconnectTimer() {
83
107
  reconnectTimer = null;
84
108
  }
85
109
 
110
+ function scheduleReconnect() {
111
+ clearReconnectTimer();
112
+ const exponentialDelay = Math.min(
113
+ RECONNECT_MAX_DELAY_MS,
114
+ RECONNECT_BASE_DELAY_MS * (2 ** Math.min(reconnectAttempt, 6)),
115
+ );
116
+ reconnectAttempt += 1;
117
+ const jitterMs = Math.floor(exponentialDelay * Math.random() * 0.25);
118
+ reconnectTimer = setTimeout(() => connect().catch(() => {}), exponentialDelay + jitterMs);
119
+ }
120
+
86
121
  function ensureKeepaliveAlarm() {
87
122
  chrome.alarms?.create?.(KEEPALIVE_ALARM_NAME, {
88
123
  periodInMinutes: KEEPALIVE_ALARM_MINUTES,
89
124
  });
90
125
  }
91
126
 
92
- async function handleSocketDisconnected(ws) {
93
- if (socket !== ws) {
94
- return;
127
+ function rejectPendingUrlValidations(error, expectedSocket = null) {
128
+ for (const entry of Array.from(pendingUrlValidations.values())) {
129
+ if (!expectedSocket || entry.socket === expectedSocket) entry.reject(error);
130
+ }
131
+ }
132
+
133
+ function requestUrlValidation(url, options = {}) {
134
+ const ws = socket;
135
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
136
+ return Promise.reject(new Error('NeoAgent browser security validation is unavailable.'));
137
+ }
138
+ if (pendingUrlValidations.size >= MAX_PENDING_URL_VALIDATIONS) {
139
+ return Promise.reject(new Error('Too many browser URL validations are pending.'));
95
140
  }
141
+
142
+ const id = crypto.randomUUID();
143
+ const signal = options.signal || null;
144
+ return new Promise((resolve, reject) => {
145
+ let settled = false;
146
+ let timer = null;
147
+ const cleanup = () => {
148
+ clearTimeout(timer);
149
+ signal?.removeEventListener('abort', onAbort);
150
+ pendingUrlValidations.delete(id);
151
+ };
152
+ const finish = (callback, value) => {
153
+ if (settled) return;
154
+ settled = true;
155
+ cleanup();
156
+ callback(value);
157
+ };
158
+ const onAbort = () => finish(
159
+ reject,
160
+ signal.reason instanceof Error
161
+ ? signal.reason
162
+ : new Error('Browser URL validation was aborted.'),
163
+ );
164
+ const entry = {
165
+ socket: ws,
166
+ resolve: (allowed) => finish(resolve, allowed === true),
167
+ reject: (error) => finish(reject, error),
168
+ };
169
+ timer = setTimeout(() => {
170
+ entry.reject(new Error('Browser URL validation timed out.'));
171
+ }, DEFAULT_URL_VALIDATION_TIMEOUT_MS);
172
+ if (signal?.aborted) {
173
+ onAbort();
174
+ return;
175
+ }
176
+ signal?.addEventListener('abort', onAbort, { once: true });
177
+ pendingUrlValidations.set(id, entry);
178
+ if (!sendSocketMessage(ws, {
179
+ type: MESSAGE_TYPES.URL_VALIDATION_REQUEST,
180
+ version: EXTENSION_PROTOCOL_VERSION,
181
+ id,
182
+ url: String(url || ''),
183
+ })) {
184
+ entry.reject(new Error('Browser URL validation could not be sent.'));
185
+ }
186
+ });
187
+ }
188
+
189
+ async function handleSocketDisconnected(ws, socketEpoch) {
190
+ if (socket !== ws || socketEpoch !== connectionEpoch) return;
96
191
  socket = null;
97
- if (suppressSocketClose) {
98
- suppressSocketClose = false;
99
- return;
192
+ const disconnectedEpoch = ++connectionEpoch;
193
+ try {
194
+ if (ws.readyState !== WebSocket.CLOSED) ws.close();
195
+ } catch {}
196
+ for (const controller of activeCommandControllers.values()) {
197
+ controller.abort(new Error('NeoAgent browser connection closed.'));
100
198
  }
199
+ activeCommandControllers.clear();
200
+ rejectPendingUrlValidations(new Error('NeoAgent browser connection closed.'), ws);
101
201
  const { token } = await getStorage(['token']);
202
+ if (connectionEpoch !== disconnectedEpoch || socket) return;
102
203
  if (!token) {
103
- await updateStatus('not_paired');
204
+ await updateStatus('not_paired', disconnectedEpoch);
104
205
  return;
105
206
  }
106
- await updateStatus('disconnected');
107
- clearReconnectTimer();
108
- reconnectTimer = setTimeout(() => connect().catch(() => {}), 5000);
207
+ await updateStatus('disconnected', disconnectedEpoch);
208
+ if (connectionEpoch === disconnectedEpoch && !socket) scheduleReconnect();
109
209
  }
110
210
 
111
- async function connect() {
112
- const { token } = await getStorage(['token']);
113
- const serverUrl = await resolveServerUrl();
211
+ async function connectOnce() {
212
+ const startingEpoch = connectionEpoch;
213
+ const { token, serverUrl: storedServerUrl } = await getStorage(['token', 'serverUrl']);
214
+ if (startingEpoch !== connectionEpoch) return { connected: false, cancelled: true };
215
+ const serverUrl = normalizeServerUrl(storedServerUrl) || configuredServerUrl();
114
216
  if (!serverUrl || !token) {
115
- await updateStatus('not_paired');
217
+ await updateStatus('not_paired', startingEpoch);
116
218
  return { connected: false };
117
219
  }
118
220
  if (socket && socket.readyState === WebSocket.OPEN) {
119
221
  return { connected: true };
120
222
  }
121
- if (socket) {
122
- try { socket.close(); } catch {}
223
+ if (socket && socket.readyState === WebSocket.CONNECTING) {
224
+ return { connected: false, connecting: true };
225
+ }
226
+ const staleSocket = socket;
227
+ socket = null;
228
+ if (staleSocket) {
229
+ rejectPendingUrlValidations(new Error('NeoAgent browser connection was replaced.'), staleSocket);
230
+ try { staleSocket.close(); } catch {}
123
231
  }
124
232
  clearReconnectTimer();
125
- suppressSocketClose = false;
126
233
 
127
- const ws = new WebSocket(websocketUrl(serverUrl, token));
234
+ const socketEpoch = ++connectionEpoch;
235
+ let ws;
236
+ try {
237
+ ws = new WebSocket(websocketUrl(serverUrl, token));
238
+ } catch (error) {
239
+ await updateStatus('disconnected', socketEpoch);
240
+ scheduleReconnect();
241
+ throw error;
242
+ }
128
243
  socket = ws;
129
- await updateStatus('connecting');
130
- const connectTimeout = setTimeout(() => {
131
- if (socket === ws && ws.readyState !== WebSocket.OPEN) {
132
- try { ws.close(); } catch {}
133
- }
134
- }, DEFAULT_WS_CONNECT_TIMEOUT_MS);
244
+ const ready = new Promise((resolve, reject) => {
245
+ let settled = false;
246
+ const finish = (callback, value) => {
247
+ if (settled) return;
248
+ settled = true;
249
+ clearTimeout(connectTimeout);
250
+ callback(value);
251
+ };
252
+ const disconnect = (error, logLabel) => {
253
+ finish(reject, error);
254
+ handleSocketDisconnected(ws, socketEpoch).catch((disconnectError) => {
255
+ console.error(logLabel, disconnectError);
256
+ });
257
+ };
258
+ const connectTimeout = setTimeout(() => {
259
+ disconnect(
260
+ new Error(`NeoAgent browser connection timed out after ${DEFAULT_WS_CONNECT_TIMEOUT_MS}ms.`),
261
+ 'NeoAgent connection timeout handling failed',
262
+ );
263
+ }, DEFAULT_WS_CONNECT_TIMEOUT_MS);
135
264
 
136
- ws.addEventListener('open', () => {
137
- if (socket !== ws) return;
138
- clearTimeout(connectTimeout);
139
- updateStatus('connected');
140
- });
141
- ws.addEventListener('close', () => {
142
- clearTimeout(connectTimeout);
143
- handleSocketDisconnected(ws).catch((error) => {
144
- console.error('NeoAgent disconnect handling failed', error);
265
+ ws.addEventListener('open', () => {
266
+ if (socket !== ws || socketEpoch !== connectionEpoch) {
267
+ try { ws.close(); } catch {}
268
+ finish(reject, new Error('NeoAgent browser connection was superseded.'));
269
+ return;
270
+ }
271
+ reconnectAttempt = 0;
272
+ updateStatus('connected', socketEpoch).catch((error) => {
273
+ console.error('NeoAgent connected status update failed', error);
274
+ });
275
+ finish(resolve, { connected: true });
145
276
  });
146
- });
147
- ws.addEventListener('error', () => {
148
- clearTimeout(connectTimeout);
149
- handleSocketDisconnected(ws).catch((error) => {
150
- console.error('NeoAgent socket error handling failed', error);
277
+ ws.addEventListener('close', () => {
278
+ disconnect(
279
+ new Error('NeoAgent browser connection closed before it was ready.'),
280
+ 'NeoAgent disconnect handling failed',
281
+ );
151
282
  });
152
- });
153
- ws.addEventListener('message', (event) => {
154
- handleSocketMessage(event.data).catch((error) => {
155
- console.error('NeoAgent command handling failed', error);
283
+ ws.addEventListener('error', () => {
284
+ disconnect(
285
+ new Error('NeoAgent browser connection failed.'),
286
+ 'NeoAgent socket error handling failed',
287
+ );
288
+ });
289
+ ws.addEventListener('message', (event) => {
290
+ handleSocketMessage(ws, event.data).catch((error) => {
291
+ console.error('NeoAgent command handling failed', error);
292
+ });
156
293
  });
157
294
  });
295
+ // The socket can fail while the storage status write is still pending. Attach
296
+ // a handler immediately; returning `ready` below still preserves rejection.
297
+ ready.catch(() => {});
158
298
 
159
- return { connected: false };
299
+ await updateStatus('connecting', socketEpoch).catch((error) => {
300
+ console.error('NeoAgent connecting status update failed', error);
301
+ });
302
+ return ready;
160
303
  }
161
304
 
162
- async function handleSocketMessage(raw) {
305
+ function connect() {
306
+ if (socket?.readyState === WebSocket.OPEN) {
307
+ return Promise.resolve({ connected: true });
308
+ }
309
+ if (connectPromise) return connectPromise;
310
+ const pending = connectOnce();
311
+ connectPromise = pending;
312
+ pending.then(
313
+ () => { if (connectPromise === pending) connectPromise = null; },
314
+ () => { if (connectPromise === pending) connectPromise = null; },
315
+ );
316
+ return pending;
317
+ }
318
+
319
+ function sendSocketMessage(ws, message) {
320
+ if (socket !== ws || ws.readyState !== WebSocket.OPEN) return false;
321
+ try {
322
+ ws.send(JSON.stringify(message));
323
+ return true;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ async function handleSocketMessage(ws, raw) {
163
330
  let message;
164
331
  try {
165
332
  message = JSON.parse(raw);
166
333
  } catch {
167
334
  return;
168
335
  }
169
- if (!message || message.type !== 'command' || !message.id) {
336
+ if (!message || !message.id) {
170
337
  return;
171
338
  }
339
+ if (message.type === MESSAGE_TYPES.URL_VALIDATION_RESULT) {
340
+ const pending = pendingUrlValidations.get(String(message.id));
341
+ if (!pending || pending.socket !== ws) return;
342
+ pending.resolve(
343
+ Number(message.version) === EXTENSION_PROTOCOL_VERSION
344
+ && message.allowed === true,
345
+ );
346
+ return;
347
+ }
348
+ if (message.type !== MESSAGE_TYPES.COMMAND) return;
172
349
  if (message.version != null && Number(message.version) !== EXTENSION_PROTOCOL_VERSION) {
173
- socket?.send(JSON.stringify({
174
- type: 'result',
350
+ sendSocketMessage(ws, {
351
+ type: MESSAGE_TYPES.RESULT,
175
352
  version: EXTENSION_PROTOCOL_VERSION,
176
353
  id: message.id,
177
354
  ok: false,
178
355
  error: `Unsupported protocol version: ${message.version}`,
179
- }));
356
+ });
180
357
  return;
181
358
  }
359
+
360
+ if (message.command === 'cancelCommand') {
361
+ const commandId = String(message.payload?.commandId || '');
362
+ const controller = activeCommandControllers.get(commandId);
363
+ controller?.abort(new Error('Browser command cancelled by NeoAgent.'));
364
+ sendSocketMessage(ws, {
365
+ type: MESSAGE_TYPES.RESULT,
366
+ version: EXTENSION_PROTOCOL_VERSION,
367
+ id: message.id,
368
+ ok: true,
369
+ result: { success: Boolean(controller), commandId },
370
+ });
371
+ return;
372
+ }
373
+
374
+ const controller = new AbortController();
375
+ activeCommandControllers.set(message.id, controller);
376
+ const execute = () => protocol.run(message.command, message.payload || {}, {
377
+ signal: controller.signal,
378
+ });
379
+ const queued = commandQueue.then(execute, execute);
380
+ commandQueue = queued.catch(() => {});
182
381
  try {
183
- const result = await protocol.run(message.command, message.payload || {});
184
- socket?.send(JSON.stringify({ type: 'result', version: EXTENSION_PROTOCOL_VERSION, id: message.id, ok: true, result }));
382
+ const result = await queued;
383
+ sendSocketMessage(ws, {
384
+ type: MESSAGE_TYPES.RESULT,
385
+ version: EXTENSION_PROTOCOL_VERSION,
386
+ id: message.id,
387
+ ok: true,
388
+ result,
389
+ });
185
390
  } catch (error) {
186
- socket?.send(JSON.stringify({
187
- type: 'result',
391
+ sendSocketMessage(ws, {
392
+ type: MESSAGE_TYPES.RESULT,
188
393
  version: EXTENSION_PROTOCOL_VERSION,
189
394
  id: message.id,
190
395
  ok: false,
191
396
  error: error?.message || String(error),
192
- }));
397
+ });
398
+ } finally {
399
+ if (activeCommandControllers.get(message.id) === controller) {
400
+ activeCommandControllers.delete(message.id);
401
+ }
193
402
  }
194
403
  }
195
404
 
@@ -198,16 +407,25 @@ async function startPairing(serverUrl) {
198
407
  if (!normalized) throw new Error('NeoAgent server URL required.');
199
408
  const { extensionName } = await getStorage(['extensionName']);
200
409
  const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
201
- const response = await fetchWithTimeout(`${normalized}/api/browser-extension/pairing/request`, {
410
+ const { response, payload } = await fetchJsonWithTimeout(`${normalized}/api/browser-extension/pairing/request`, {
202
411
  method: 'POST',
203
412
  headers: { 'content-type': 'application/json' },
204
413
  body: JSON.stringify({ extensionName: nameToUse }),
205
414
  });
206
- const payload = await response.json().catch(() => ({}));
207
415
  if (!response.ok) throw new Error(payload.error || `Pairing failed: ${response.status}`);
416
+ if (!payload.pairingId || !payload.pairingSecret) {
417
+ throw new Error('NeoAgent server returned an incomplete pairing response.');
418
+ }
208
419
  const approvalUrl = String(payload.approvalUrl || '');
209
420
  const approvalParsed = (() => { try { return new URL(approvalUrl); } catch { return null; } })();
210
- if (!approvalParsed || !['http:', 'https:'].includes(approvalParsed.protocol)) {
421
+ const serverParsed = new URL(normalized);
422
+ if (
423
+ !approvalParsed
424
+ || !['http:', 'https:'].includes(approvalParsed.protocol)
425
+ || approvalParsed.origin !== serverParsed.origin
426
+ || approvalParsed.username
427
+ || approvalParsed.password
428
+ ) {
211
429
  throw new Error('Invalid approval URL returned by server.');
212
430
  }
213
431
  await setStorage({
@@ -226,14 +444,17 @@ async function claimPairing() {
226
444
  if (!serverUrl || !pairingId || !pairingSecret) {
227
445
  throw new Error('No pending pairing request.');
228
446
  }
447
+ const normalizedServerUrl = normalizeServerUrl(serverUrl);
229
448
  const nameToUse = String(extensionName || 'Chrome Extension').trim() || 'Chrome Extension';
230
- const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/pairing/${encodeURIComponent(pairingId)}/claim`, {
449
+ const { response, payload } = await fetchJsonWithTimeout(`${normalizedServerUrl}/api/browser-extension/pairing/${encodeURIComponent(pairingId)}/claim`, {
231
450
  method: 'POST',
232
451
  headers: { 'content-type': 'application/json' },
233
452
  body: JSON.stringify({ pairingSecret, extensionName: nameToUse }),
234
453
  });
235
- const payload = await response.json().catch(() => ({}));
236
454
  if (!response.ok) throw new Error(payload.error || `Claim failed: ${response.status}`);
455
+ if (!payload.token || !payload.tokenId) {
456
+ throw new Error('NeoAgent server returned an incomplete pairing claim.');
457
+ }
237
458
  await setStorage({
238
459
  token: payload.token,
239
460
  tokenId: payload.tokenId,
@@ -246,20 +467,29 @@ async function claimPairing() {
246
467
 
247
468
  async function disconnect() {
248
469
  clearReconnectTimer();
249
- suppressSocketClose = true;
250
- if (socket) {
251
- try { socket.close(); } catch {}
252
- }
470
+ reconnectAttempt = 0;
471
+ const disconnectedEpoch = ++connectionEpoch;
472
+ connectPromise = null;
473
+ const previousSocket = socket;
253
474
  socket = null;
475
+ if (previousSocket) {
476
+ try { previousSocket.close(); } catch {}
477
+ }
478
+ for (const controller of activeCommandControllers.values()) {
479
+ controller.abort(new Error('NeoAgent browser connection was disconnected.'));
480
+ }
481
+ activeCommandControllers.clear();
482
+ rejectPendingUrlValidations(new Error('NeoAgent browser connection was disconnected.'));
254
483
  await removeStorage(['token', 'tokenId', 'pairingId', 'pairingSecret', 'approvalUrl']);
255
- await updateStatus('not_paired');
484
+ await updateStatus('not_paired', disconnectedEpoch);
256
485
  }
257
486
 
258
487
  async function checkForUpdates(preferredServerUrl) {
259
488
  const serverUrl = await resolveServerUrl(preferredServerUrl);
260
489
  if (!serverUrl) throw new Error('NeoAgent server URL required.');
261
- const response = await fetchWithTimeout(`${serverUrl}/api/browser-extension/latest`);
262
- const latest = await response.json().catch(() => ({}));
490
+ const { response, payload: latest } = await fetchJsonWithTimeout(
491
+ `${serverUrl}/api/browser-extension/latest`,
492
+ );
263
493
  if (!response.ok) throw new Error(latest.error || `Update check failed: ${response.status}`);
264
494
  const manifest = chrome.runtime.getManifest();
265
495
  const currentVersion = manifest.version;
@@ -0,0 +1,136 @@
1
+ export const DEFAULT_EXTENSION_HTTP_TIMEOUT_MS = 10000;
2
+ export const DEFAULT_MAX_SERVER_RESPONSE_BYTES = 1024 * 1024;
3
+
4
+ function abortError(signal, fallback = 'NeoAgent server request was aborted.') {
5
+ if (signal?.reason instanceof Error) return signal.reason;
6
+ const error = new Error(String(signal?.reason || fallback));
7
+ error.name = 'AbortError';
8
+ error.code = 'ABORT_ERR';
9
+ return error;
10
+ }
11
+
12
+ function throwIfAborted(signal) {
13
+ if (signal?.aborted) throw abortError(signal);
14
+ }
15
+
16
+ function responseTooLargeError(maxResponseBytes) {
17
+ const error = new Error(
18
+ `NeoAgent server response exceeded the ${maxResponseBytes}-byte safety limit.`,
19
+ );
20
+ error.code = 'EXTENSION_RESPONSE_TOO_LARGE';
21
+ return error;
22
+ }
23
+
24
+ function waitForAbortable(promise, signal) {
25
+ throwIfAborted(signal);
26
+ if (!signal) return Promise.resolve(promise);
27
+ return new Promise((resolve, reject) => {
28
+ const onAbort = () => reject(abortError(signal));
29
+ signal.addEventListener('abort', onAbort, { once: true });
30
+ Promise.resolve(promise).then(resolve, reject).finally(() => {
31
+ signal.removeEventListener('abort', onAbort);
32
+ });
33
+ });
34
+ }
35
+
36
+ export async function readJsonResponse(response, options = {}) {
37
+ const signal = options.signal || null;
38
+ const maxResponseBytes = Math.max(
39
+ 1,
40
+ Number(options.maxResponseBytes) || DEFAULT_MAX_SERVER_RESPONSE_BYTES,
41
+ );
42
+ throwIfAborted(signal);
43
+ const contentLength = Number(response?.headers?.get?.('content-length'));
44
+ if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
45
+ await Promise.resolve(response?.body?.cancel?.()).catch(() => {});
46
+ throw responseTooLargeError(maxResponseBytes);
47
+ }
48
+
49
+ const reader = response?.body?.getReader?.();
50
+ let text = '';
51
+ if (!reader) {
52
+ text = await waitForAbortable(response.text(), signal);
53
+ if (new TextEncoder().encode(text).byteLength > maxResponseBytes) {
54
+ throw responseTooLargeError(maxResponseBytes);
55
+ }
56
+ } else {
57
+ const decoder = new TextDecoder();
58
+ let totalBytes = 0;
59
+ const cancelReader = () => {
60
+ try {
61
+ Promise.resolve(reader.cancel(signal?.reason)).catch(() => {});
62
+ } catch {}
63
+ };
64
+ signal?.addEventListener('abort', cancelReader, { once: true });
65
+ try {
66
+ while (true) {
67
+ const { done, value } = await waitForAbortable(reader.read(), signal);
68
+ if (done) break;
69
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value || []);
70
+ totalBytes += chunk.byteLength;
71
+ if (totalBytes > maxResponseBytes) {
72
+ await Promise.resolve(reader.cancel()).catch(() => {});
73
+ throw responseTooLargeError(maxResponseBytes);
74
+ }
75
+ text += decoder.decode(chunk, { stream: true });
76
+ }
77
+ text += decoder.decode();
78
+ } finally {
79
+ signal?.removeEventListener('abort', cancelReader);
80
+ reader.releaseLock?.();
81
+ }
82
+ }
83
+
84
+ if (!text.trim()) return {};
85
+ try {
86
+ return JSON.parse(text);
87
+ } catch {
88
+ throw new Error('NeoAgent server returned invalid JSON.');
89
+ }
90
+ }
91
+
92
+ export async function fetchJsonWithTimeout(url, options = {}, config = {}) {
93
+ const timeoutMs = Math.max(
94
+ 1,
95
+ Number(config.timeoutMs) || DEFAULT_EXTENSION_HTTP_TIMEOUT_MS,
96
+ );
97
+ const maxResponseBytes = Math.max(
98
+ 1,
99
+ Number(config.maxResponseBytes) || DEFAULT_MAX_SERVER_RESPONSE_BYTES,
100
+ );
101
+ const fetchImpl = typeof config.fetchImpl === 'function' ? config.fetchImpl : fetch;
102
+ const callerSignal = options.signal || null;
103
+ throwIfAborted(callerSignal);
104
+
105
+ const controller = new AbortController();
106
+ const onCallerAbort = () => controller.abort(callerSignal.reason);
107
+ callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
108
+ const timer = setTimeout(() => {
109
+ const error = new Error(`NeoAgent server request timed out after ${timeoutMs}ms.`);
110
+ error.code = 'EXTENSION_HTTP_TIMEOUT';
111
+ controller.abort(error);
112
+ }, timeoutMs);
113
+
114
+ const { signal: _signal, ...fetchOptions } = options;
115
+ try {
116
+ const response = await waitForAbortable(
117
+ fetchImpl(url, {
118
+ ...fetchOptions,
119
+ signal: controller.signal,
120
+ }),
121
+ controller.signal,
122
+ );
123
+ const payload = await readJsonResponse(response, {
124
+ signal: controller.signal,
125
+ maxResponseBytes,
126
+ });
127
+ return { response, payload };
128
+ } catch (error) {
129
+ if (callerSignal?.aborted) throw abortError(callerSignal);
130
+ if (controller.signal.aborted) throw abortError(controller.signal);
131
+ throw error;
132
+ } finally {
133
+ clearTimeout(timer);
134
+ callerSignal?.removeEventListener('abort', onCallerAbort);
135
+ }
136
+ }