neoagent 3.2.1-beta.1 → 3.2.1-beta.10

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 (172) 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 +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +111 -20
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +3 -0
  8. package/flutter_app/lib/main_operations.dart +334 -321
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +4 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +78 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +11 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +73083 -72209
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/browser.js +23 -15
  33. package/server/routes/desktop.js +18 -1
  34. package/server/routes/integrations.js +107 -1
  35. package/server/routes/memory.js +1 -0
  36. package/server/routes/settings.js +16 -5
  37. package/server/routes/social_reach.js +12 -3
  38. package/server/routes/social_video.js +4 -0
  39. package/server/services/agents/manager.js +1 -1
  40. package/server/services/ai/capabilityHealth.js +62 -96
  41. package/server/services/ai/compaction.js +7 -2
  42. package/server/services/ai/history.js +45 -6
  43. package/server/services/ai/integrated_tools/http_request.js +8 -0
  44. package/server/services/ai/loop/agent_engine_core.js +390 -162
  45. package/server/services/ai/loop/blank_recovery.js +5 -4
  46. package/server/services/ai/loop/callbacks.js +1 -0
  47. package/server/services/ai/loop/completion_judge.js +121 -5
  48. package/server/services/ai/loop/conversation_loop.js +510 -332
  49. package/server/services/ai/loop/messaging_delivery.js +129 -57
  50. package/server/services/ai/loop/model_call_guard.js +91 -0
  51. package/server/services/ai/loop/model_io.js +20 -45
  52. package/server/services/ai/loop/progress_classification.js +2 -0
  53. package/server/services/ai/loop/tool_dispatch.js +19 -8
  54. package/server/services/ai/loopPolicy.js +48 -21
  55. package/server/services/ai/messagingFallback.js +17 -17
  56. package/server/services/ai/model_discovery.js +227 -0
  57. package/server/services/ai/model_failure_cache.js +108 -0
  58. package/server/services/ai/model_identity.js +71 -0
  59. package/server/services/ai/models.js +68 -163
  60. package/server/services/ai/providerRetry.js +17 -59
  61. package/server/services/ai/provider_selector.js +166 -0
  62. package/server/services/ai/providers/anthropic.js +2 -2
  63. package/server/services/ai/providers/claudeCode.js +21 -33
  64. package/server/services/ai/providers/githubCopilot.js +41 -20
  65. package/server/services/ai/providers/google.js +135 -97
  66. package/server/services/ai/providers/grok.js +4 -3
  67. package/server/services/ai/providers/grokOauth.js +19 -27
  68. package/server/services/ai/providers/nvidia.js +10 -5
  69. package/server/services/ai/providers/ollama.js +111 -84
  70. package/server/services/ai/providers/ollama_stream.js +142 -0
  71. package/server/services/ai/providers/openai.js +39 -5
  72. package/server/services/ai/providers/openaiCodex.js +11 -4
  73. package/server/services/ai/providers/openrouter.js +29 -7
  74. package/server/services/ai/providers/provider_error.js +36 -0
  75. package/server/services/ai/settings.js +26 -2
  76. package/server/services/ai/systemPrompt.js +19 -12
  77. package/server/services/ai/taskAnalysis.js +58 -10
  78. package/server/services/ai/terminal_reply.js +18 -0
  79. package/server/services/ai/toolEvidence.js +350 -29
  80. package/server/services/ai/tools.js +190 -111
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/browser/controller.js +843 -385
  87. package/server/services/browser/extension/gateway.js +40 -16
  88. package/server/services/browser/extension/protocol.js +15 -1
  89. package/server/services/browser/extension/provider.js +71 -47
  90. package/server/services/browser/extension/registry.js +155 -34
  91. package/server/services/cli/executor.js +62 -9
  92. package/server/services/credentials/bitwarden_cli.js +322 -0
  93. package/server/services/credentials/broker.js +594 -0
  94. package/server/services/desktop/gateway.js +41 -4
  95. package/server/services/desktop/protocol.js +3 -0
  96. package/server/services/desktop/provider.js +39 -42
  97. package/server/services/desktop/registry.js +137 -52
  98. package/server/services/integrations/bitwarden/constants.js +14 -0
  99. package/server/services/integrations/bitwarden/provider.js +197 -0
  100. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  101. package/server/services/integrations/figma/provider.js +78 -12
  102. package/server/services/integrations/github/common.js +11 -6
  103. package/server/services/integrations/github/provider.js +52 -53
  104. package/server/services/integrations/google/provider.js +55 -19
  105. package/server/services/integrations/home_assistant/network.js +17 -20
  106. package/server/services/integrations/home_assistant/provider.js +7 -5
  107. package/server/services/integrations/home_assistant/tools.js +17 -5
  108. package/server/services/integrations/http.js +51 -0
  109. package/server/services/integrations/manager.js +159 -53
  110. package/server/services/integrations/microsoft/provider.js +80 -13
  111. package/server/services/integrations/neoarchive/provider.js +55 -29
  112. package/server/services/integrations/neorecall/client.js +17 -10
  113. package/server/services/integrations/neorecall/provider.js +20 -11
  114. package/server/services/integrations/notion/provider.js +16 -13
  115. package/server/services/integrations/oauth_provider.js +115 -51
  116. package/server/services/integrations/registry.js +2 -0
  117. package/server/services/integrations/slack/provider.js +98 -9
  118. package/server/services/integrations/spotify/provider.js +67 -71
  119. package/server/services/integrations/trello/provider.js +21 -7
  120. package/server/services/integrations/weather/provider.js +18 -12
  121. package/server/services/integrations/whatsapp/provider.js +76 -16
  122. package/server/services/manager.js +110 -1
  123. package/server/services/memory/embedding_index.js +20 -8
  124. package/server/services/memory/embeddings.js +151 -90
  125. package/server/services/memory/ingestion.js +50 -9
  126. package/server/services/memory/ingestion_documents.js +13 -3
  127. package/server/services/memory/manager.js +52 -19
  128. package/server/services/messaging/automation.js +85 -10
  129. package/server/services/messaging/formatting_guides.js +7 -4
  130. package/server/services/messaging/http_platforms.js +33 -13
  131. package/server/services/messaging/inbound_queue.js +78 -24
  132. package/server/services/messaging/inbound_store.js +224 -0
  133. package/server/services/messaging/manager.js +326 -51
  134. package/server/services/messaging/typing_keepalive.js +5 -2
  135. package/server/services/messaging/whatsapp.js +22 -14
  136. package/server/services/network/http.js +210 -0
  137. package/server/services/network/safe_request.js +307 -0
  138. package/server/services/runtime/backends/local-vm.js +227 -67
  139. package/server/services/runtime/docker-vm-manager.js +9 -0
  140. package/server/services/runtime/guest_bootstrap.js +30 -4
  141. package/server/services/runtime/guest_image.js +43 -12
  142. package/server/services/runtime/manager.js +77 -23
  143. package/server/services/runtime/validation.js +7 -6
  144. package/server/services/security/tool_categories.js +6 -0
  145. package/server/services/social_reach/channels/github.js +10 -4
  146. package/server/services/social_reach/channels/reddit.js +4 -4
  147. package/server/services/social_reach/channels/rss.js +2 -2
  148. package/server/services/social_reach/channels/social_video.js +12 -7
  149. package/server/services/social_reach/channels/v2ex.js +21 -8
  150. package/server/services/social_reach/channels/x.js +2 -2
  151. package/server/services/social_reach/channels/xueqiu.js +5 -5
  152. package/server/services/social_reach/service.js +9 -6
  153. package/server/services/social_reach/utils.js +65 -14
  154. package/server/services/social_video/service.js +160 -50
  155. package/server/services/tasks/integration_runtime.js +18 -8
  156. package/server/services/tasks/runtime.js +39 -4
  157. package/server/services/voice/agentBridge.js +17 -4
  158. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  159. package/server/services/voice/liveSession.js +31 -0
  160. package/server/services/voice/message.js +1 -1
  161. package/server/services/voice/openaiSpeech.js +33 -8
  162. package/server/services/voice/providers.js +233 -151
  163. package/server/services/voice/runtime.js +2 -2
  164. package/server/services/voice/runtimeManager.js +118 -20
  165. package/server/services/voice/turnRunner.js +6 -0
  166. package/server/services/wearable/firmware_manifest.js +51 -13
  167. package/server/services/wearable/service.js +1 -0
  168. package/server/utils/abort.js +96 -0
  169. package/server/utils/cloud-security.js +110 -3
  170. package/server/utils/files.js +31 -0
  171. package/server/utils/image_payload.js +95 -0
  172. package/server/utils/retry.js +107 -0
@@ -0,0 +1,594 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const db = require('../../db/database');
5
+ const { resolveAgentId } = require('../agents/manager');
6
+ const { decryptValue, encryptValue } = require('../integrations/secrets');
7
+ const { validateCloudUrlWithDns } = require('../../utils/cloud-security');
8
+
9
+ const ALLOWED_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
10
+ const ALLOWED_AUTH_TYPES = new Set(['bearer', 'basic', 'header']);
11
+ const PROTECTED_FILL_TTL_MS = 5 * 60 * 1000;
12
+ const MAX_RESPONSE_BYTES = 512 * 1024;
13
+
14
+ function parseJson(value, fallback = {}) {
15
+ try {
16
+ const parsed = JSON.parse(String(value || ''));
17
+ return parsed && typeof parsed === 'object' ? parsed : fallback;
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+
23
+ function requireText(value, label) {
24
+ const text = String(value || '').trim();
25
+ if (!text) throw new Error(`${label} is required.`);
26
+ return text;
27
+ }
28
+
29
+ function normalizeHttpsOrigin(value) {
30
+ const url = new URL(requireText(value, 'Origin'));
31
+ if (url.protocol !== 'https:' || url.username || url.password) {
32
+ throw new Error('Credential targets must use an HTTPS origin without embedded credentials.');
33
+ }
34
+ return url.origin;
35
+ }
36
+
37
+ function normalizePathPrefix(value) {
38
+ const text = String(value || '/').trim() || '/';
39
+ if (
40
+ !text.startsWith('/')
41
+ || text.includes('\\')
42
+ || /(?:^|\/)\.\.?($|\/)/.test(text)
43
+ || /%(?:2e|2f|5c)/i.test(text)
44
+ ) {
45
+ throw new Error('Credential API path prefixes must start with /.');
46
+ }
47
+ return text;
48
+ }
49
+
50
+ function itemOrigins(item) {
51
+ const values = Array.isArray(item?.login?.uris) ? item.login.uris : [];
52
+ const origins = new Set();
53
+ for (const entry of values) {
54
+ try {
55
+ origins.add(normalizeHttpsOrigin(entry?.uri));
56
+ } catch {
57
+ // Ignore non-HTTPS and malformed vault URIs.
58
+ }
59
+ }
60
+ return Array.from(origins).sort();
61
+ }
62
+
63
+ function sanitizeItem(item) {
64
+ const username = String(item?.login?.username || '');
65
+ const fields = Array.isArray(item?.fields) ? item.fields : [];
66
+ return {
67
+ id: String(item?.id || ''),
68
+ name: String(item?.name || 'Untitled'),
69
+ usernameMasked: username
70
+ ? `${username.slice(0, 1)}${'*'.repeat(Math.min(8, Math.max(3, username.length - 2)))}${username.length > 1 ? username.slice(-1) : ''}`
71
+ : '',
72
+ origins: itemOrigins(item),
73
+ hasPassword: Boolean(item?.login && Object.hasOwn(item.login, 'password')),
74
+ fields: fields.map((field) => ({
75
+ id: String(field?.id || ''),
76
+ name: String(field?.name || ''),
77
+ type: Number(field?.type || 0),
78
+ })).filter((field) => field.id || field.name),
79
+ };
80
+ }
81
+
82
+ function readItemField(item, reference) {
83
+ const ref = String(reference || '').trim();
84
+ if (ref === 'login.username') return String(item?.login?.username || '');
85
+ if (ref === 'login.password') return String(item?.login?.password || '');
86
+ const fields = Array.isArray(item?.fields) ? item.fields : [];
87
+ const field = fields.find((candidate) =>
88
+ String(candidate?.id || '') === ref || String(candidate?.name || '') === ref
89
+ );
90
+ return String(field?.value || '');
91
+ }
92
+
93
+ function encodeSecretVariants(value) {
94
+ const text = String(value || '');
95
+ if (!text) return [];
96
+ return Array.from(new Set([
97
+ text,
98
+ encodeURIComponent(text),
99
+ Buffer.from(text, 'utf8').toString('base64'),
100
+ Buffer.from(text, 'utf8').toString('base64url'),
101
+ Buffer.from(text, 'utf8').toString('hex'),
102
+ ].filter(Boolean))).sort((left, right) => right.length - left.length);
103
+ }
104
+
105
+ function redactResolvedValues(value, resolvedValues) {
106
+ let text = String(value || '');
107
+ for (const resolved of resolvedValues) {
108
+ for (const variant of encodeSecretVariants(resolved)) {
109
+ text = text.split(variant).join('[redacted]');
110
+ }
111
+ }
112
+ return text;
113
+ }
114
+
115
+ function safeResponseHeaders(headers, resolvedValues = []) {
116
+ const blocked = new Set([
117
+ 'authorization',
118
+ 'cookie',
119
+ 'set-cookie',
120
+ 'proxy-authenticate',
121
+ 'proxy-authorization',
122
+ 'www-authenticate',
123
+ ]);
124
+ return Object.fromEntries(
125
+ Array.from(headers.entries())
126
+ .filter(([name]) => !blocked.has(name.toLowerCase()))
127
+ .map(([name, value]) => [name, redactResolvedValues(value, resolvedValues)]),
128
+ );
129
+ }
130
+
131
+ function pathMatchesPrefix(pathname, prefix) {
132
+ if (prefix === '/') return true;
133
+ const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
134
+ return pathname === normalized || pathname.startsWith(`${normalized}/`);
135
+ }
136
+
137
+ class CredentialBroker {
138
+ constructor(options = {}) {
139
+ this.bitwarden = options.bitwarden;
140
+ this.runtimeManager = options.runtimeManager || null;
141
+ this.protectedFills = new Map();
142
+ }
143
+
144
+ setRuntimeManager(runtimeManager) {
145
+ this.runtimeManager = runtimeManager;
146
+ }
147
+
148
+ #scope(userId, agentId) {
149
+ const normalizedUserId = Number(userId);
150
+ if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
151
+ throw new Error('A valid user is required.');
152
+ }
153
+ return {
154
+ userId: normalizedUserId,
155
+ agentId: resolveAgentId(normalizedUserId, agentId || null),
156
+ };
157
+ }
158
+
159
+ #bindingRow(userId, agentId, bindingId) {
160
+ return db.prepare(
161
+ `SELECT * FROM credential_bindings
162
+ WHERE id = ? AND user_id = ? AND agent_id = ?`,
163
+ ).get(String(bindingId || ''), userId, agentId) || null;
164
+ }
165
+
166
+ #decodeBinding(row) {
167
+ if (!row) return null;
168
+ return {
169
+ id: row.id,
170
+ alias: row.alias,
171
+ providerKey: row.provider_key,
172
+ connectionId: row.connection_id,
173
+ usageType: row.usage_type,
174
+ itemRef: decryptValue(row.item_ref_encrypted),
175
+ fieldConfig: parseJson(decryptValue(row.field_config_encrypted), {}),
176
+ targetConfig: parseJson(row.target_config_json, {}),
177
+ createdAt: row.created_at,
178
+ updatedAt: row.updated_at,
179
+ };
180
+ }
181
+
182
+ #publicBinding(row) {
183
+ const binding = this.#decodeBinding(row);
184
+ return binding && {
185
+ id: binding.id,
186
+ alias: binding.alias,
187
+ providerKey: binding.providerKey,
188
+ usageType: binding.usageType,
189
+ target: binding.usageType === 'browser'
190
+ ? { origins: binding.targetConfig.origins || [] }
191
+ : {
192
+ origin: binding.targetConfig.origin,
193
+ pathPrefix: binding.targetConfig.pathPrefix,
194
+ methods: binding.targetConfig.methods || [],
195
+ authType: binding.targetConfig.authType,
196
+ headerName: binding.targetConfig.authType === 'header'
197
+ ? binding.targetConfig.headerName
198
+ : undefined,
199
+ },
200
+ createdAt: binding.createdAt,
201
+ updatedAt: binding.updatedAt,
202
+ };
203
+ }
204
+
205
+ listBindings(userId, agentId) {
206
+ const scope = this.#scope(userId, agentId);
207
+ return db.prepare(
208
+ `SELECT * FROM credential_bindings
209
+ WHERE user_id = ? AND agent_id = ?
210
+ ORDER BY lower(alias), created_at`,
211
+ ).all(scope.userId, scope.agentId).map((row) => this.#publicBinding(row));
212
+ }
213
+
214
+ async listVaultItems(userId, agentId, options = {}) {
215
+ const scope = this.#scope(userId, agentId);
216
+ await this.bitwarden.sync(scope.userId, scope.agentId, options);
217
+ const items = await this.bitwarden.listItems(scope.userId, scope.agentId, options);
218
+ return items.map(sanitizeItem).filter((item) => item.id);
219
+ }
220
+
221
+ async #bindingValues(scope, input, options = {}) {
222
+ const alias = requireText(input?.alias, 'Binding alias').slice(0, 120);
223
+ const usageType = String(input?.usageType || '').trim();
224
+ if (!['browser', 'http'].includes(usageType)) {
225
+ throw new Error('Binding usageType must be browser or http.');
226
+ }
227
+ const connectionId = Number(input?.connectionId);
228
+ if (!Number.isInteger(connectionId) || connectionId <= 0) {
229
+ throw new Error('A connected Bitwarden account is required.');
230
+ }
231
+ const connection = db.prepare(
232
+ `SELECT id FROM integration_connections
233
+ WHERE id = ? AND user_id = ? AND agent_id = ?
234
+ AND provider_key = 'bitwarden' AND status = 'connected'`,
235
+ ).get(connectionId, scope.userId, scope.agentId);
236
+ if (!connection) throw new Error('The Bitwarden connection was not found.');
237
+ const itemRef = requireText(input?.itemId, 'Bitwarden item');
238
+ const item = await this.bitwarden.getItem(scope.userId, scope.agentId, itemRef, options);
239
+ let fieldConfig;
240
+ let targetConfig;
241
+
242
+ if (usageType === 'browser') {
243
+ const availableOrigins = itemOrigins(item);
244
+ const requested = Array.isArray(input?.origins) ? input.origins.map(normalizeHttpsOrigin) : [];
245
+ const origins = requested.length > 0 ? requested : availableOrigins;
246
+ if (origins.length === 0 || origins.some((origin) => !availableOrigins.includes(origin))) {
247
+ throw new Error('Browser credential origins must be selected from HTTPS URIs saved on the Bitwarden item.');
248
+ }
249
+ if (!readItemField(item, 'login.password')) {
250
+ throw new Error('The selected Bitwarden item does not contain a login password.');
251
+ }
252
+ fieldConfig = {
253
+ usernameField: 'login.username',
254
+ passwordField: 'login.password',
255
+ };
256
+ targetConfig = { origins: Array.from(new Set(origins)).sort() };
257
+ } else {
258
+ const authType = String(input?.authType || '').trim().toLowerCase();
259
+ if (!ALLOWED_AUTH_TYPES.has(authType)) {
260
+ throw new Error('HTTP authType must be bearer, basic, or header.');
261
+ }
262
+ const secretField = requireText(input?.secretField, 'Secret field');
263
+ if (!readItemField(item, secretField)) {
264
+ throw new Error('The selected Bitwarden secret field is empty.');
265
+ }
266
+ const origin = normalizeHttpsOrigin(input?.origin);
267
+ const pathPrefix = normalizePathPrefix(input?.pathPrefix);
268
+ const methods = Array.from(new Set(
269
+ (Array.isArray(input?.methods) ? input.methods : ['GET'])
270
+ .map((method) => String(method || '').toUpperCase())
271
+ .filter((method) => ALLOWED_HTTP_METHODS.has(method)),
272
+ ));
273
+ if (methods.length === 0) throw new Error('At least one supported HTTP method is required.');
274
+ const headerName = authType === 'header'
275
+ ? requireText(input?.headerName, 'Header name')
276
+ : null;
277
+ if (headerName && !/^[A-Za-z0-9-]+$/.test(headerName)) {
278
+ throw new Error('Credential header names may contain only letters, numbers, and hyphens.');
279
+ }
280
+ if (headerName && ['authorization', 'cookie', 'set-cookie'].includes(headerName.toLowerCase())) {
281
+ throw new Error('Use bearer or basic authentication for reserved authentication headers.');
282
+ }
283
+ fieldConfig = {
284
+ secretField,
285
+ usernameField: authType === 'basic'
286
+ ? requireText(input?.usernameField, 'Username field')
287
+ : null,
288
+ };
289
+ targetConfig = { origin, pathPrefix, methods, authType, headerName };
290
+ }
291
+ return { alias, usageType, connectionId, itemRef, fieldConfig, targetConfig };
292
+ }
293
+
294
+ async createBinding(userId, agentId, input, options = {}) {
295
+ const scope = this.#scope(userId, agentId);
296
+ const values = await this.#bindingValues(scope, input, options);
297
+ const id = crypto.randomUUID();
298
+ db.prepare(
299
+ `INSERT INTO credential_bindings (
300
+ id, user_id, agent_id, provider_key, connection_id, alias, usage_type,
301
+ item_ref_encrypted, field_config_encrypted, target_config_json
302
+ ) VALUES (?, ?, ?, 'bitwarden', ?, ?, ?, ?, ?, ?)`,
303
+ ).run(
304
+ id,
305
+ scope.userId,
306
+ scope.agentId,
307
+ values.connectionId,
308
+ values.alias,
309
+ values.usageType,
310
+ encryptValue(values.itemRef),
311
+ encryptValue(JSON.stringify(values.fieldConfig)),
312
+ JSON.stringify(values.targetConfig),
313
+ );
314
+ return this.#publicBinding(this.#bindingRow(scope.userId, scope.agentId, id));
315
+ }
316
+
317
+ async updateBinding(userId, agentId, bindingId, input, options = {}) {
318
+ const scope = this.#scope(userId, agentId);
319
+ const current = this.#decodeBinding(this.#bindingRow(scope.userId, scope.agentId, bindingId));
320
+ if (!current) throw new Error('Credential binding not found.');
321
+ const values = await this.#bindingValues(scope, {
322
+ ...input,
323
+ alias: input?.alias ?? current.alias,
324
+ usageType: input?.usageType ?? current.usageType,
325
+ connectionId: input?.connectionId ?? current.connectionId,
326
+ itemId: input?.itemId ?? current.itemRef,
327
+ origins: input?.origins ?? current.targetConfig.origins,
328
+ origin: input?.origin ?? current.targetConfig.origin,
329
+ pathPrefix: input?.pathPrefix ?? current.targetConfig.pathPrefix,
330
+ methods: input?.methods ?? current.targetConfig.methods,
331
+ authType: input?.authType ?? current.targetConfig.authType,
332
+ headerName: input?.headerName ?? current.targetConfig.headerName,
333
+ secretField: input?.secretField ?? current.fieldConfig.secretField,
334
+ usernameField: input?.usernameField ?? current.fieldConfig.usernameField,
335
+ }, options);
336
+ db.prepare(
337
+ `UPDATE credential_bindings
338
+ SET connection_id = ?, alias = ?, usage_type = ?, item_ref_encrypted = ?,
339
+ field_config_encrypted = ?, target_config_json = ?, updated_at = datetime('now')
340
+ WHERE id = ? AND user_id = ? AND agent_id = ?`,
341
+ ).run(
342
+ values.connectionId,
343
+ values.alias,
344
+ values.usageType,
345
+ encryptValue(values.itemRef),
346
+ encryptValue(JSON.stringify(values.fieldConfig)),
347
+ JSON.stringify(values.targetConfig),
348
+ String(bindingId || ''),
349
+ scope.userId,
350
+ scope.agentId,
351
+ );
352
+ return this.#publicBinding(this.#bindingRow(scope.userId, scope.agentId, bindingId));
353
+ }
354
+
355
+ deleteBinding(userId, agentId, bindingId) {
356
+ const scope = this.#scope(userId, agentId);
357
+ const result = db.prepare(
358
+ 'DELETE FROM credential_bindings WHERE id = ? AND user_id = ? AND agent_id = ?',
359
+ ).run(String(bindingId || ''), scope.userId, scope.agentId);
360
+ return { deleted: result.changes > 0 };
361
+ }
362
+
363
+ #audit(scope, bindingId, operation, target, outcome, errorCode = null, runId = null) {
364
+ db.prepare(
365
+ `INSERT INTO credential_usage_audit (
366
+ id, user_id, agent_id, run_id, binding_id, operation, target, outcome, error_code
367
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
368
+ ).run(
369
+ crypto.randomUUID(),
370
+ scope.userId,
371
+ scope.agentId,
372
+ runId || null,
373
+ bindingId || null,
374
+ operation,
375
+ target || null,
376
+ outcome,
377
+ errorCode || null,
378
+ );
379
+ }
380
+
381
+ async #resolveBindingItem(scope, binding, work, options = {}) {
382
+ if (binding.providerKey !== 'bitwarden') {
383
+ throw new Error(`Unsupported credential provider: ${binding.providerKey}`);
384
+ }
385
+ await this.bitwarden.sync(scope.userId, scope.agentId, options);
386
+ const item = await this.bitwarden.getItem(scope.userId, scope.agentId, binding.itemRef, options);
387
+ return work(item);
388
+ }
389
+
390
+ async fillBrowser(userId, agentId, input, context = {}) {
391
+ const scope = this.#scope(userId, agentId);
392
+ const binding = this.#decodeBinding(this.#bindingRow(scope.userId, scope.agentId, input?.binding_id));
393
+ if (!binding || binding.usageType !== 'browser') throw new Error('Browser credential binding not found.');
394
+ if (!this.runtimeManager) throw new Error('Browser runtime is unavailable.');
395
+ const provider = await this.runtimeManager.getBrowserProviderForUser(scope.userId);
396
+ if (!provider || typeof provider.fillCredential !== 'function') {
397
+ throw new Error('The selected browser backend does not support protected credential fill.');
398
+ }
399
+ const page = await provider.getPageInfo();
400
+ const origin = page?.url ? new URL(page.url).origin : '';
401
+ if (!binding.targetConfig.origins?.includes(origin)) {
402
+ throw new Error(`Credential binding ${binding.alias} is not allowed on the current browser origin.`);
403
+ }
404
+
405
+ try {
406
+ const result = await this.#resolveBindingItem(scope, binding, async (item) => {
407
+ const username = readItemField(item, binding.fieldConfig.usernameField);
408
+ const password = readItemField(item, binding.fieldConfig.passwordField);
409
+ const requestedStage = String(input?.stage || 'both');
410
+ if (!['username', 'password', 'both'].includes(requestedStage)) {
411
+ throw new Error('Credential stage must be username, password, or both.');
412
+ }
413
+ const stage = requestedStage;
414
+ const usernameSelector = String(input?.username_selector || '').trim();
415
+ const passwordSelector = String(input?.password_selector || '').trim();
416
+ if (stage !== 'password' && !usernameSelector) {
417
+ throw new Error('Username selector is required for this credential stage.');
418
+ }
419
+ if (stage !== 'username' && !passwordSelector) {
420
+ throw new Error('Password selector is required for this credential stage.');
421
+ }
422
+ if (stage !== 'username' && !password) {
423
+ throw new Error('The bound Bitwarden password is empty.');
424
+ }
425
+ return provider.fillCredential({
426
+ usernameSelector: stage === 'password' ? '' : usernameSelector,
427
+ passwordSelector: stage === 'username' ? '' : passwordSelector,
428
+ username: stage === 'password' ? '' : username,
429
+ password: stage === 'username' ? '' : password,
430
+ allowedOrigin: origin,
431
+ });
432
+ });
433
+ const protectedFillId = String(result?.protectedFillId || crypto.randomUUID());
434
+ this.protectedFills.set(protectedFillId, {
435
+ userId: scope.userId,
436
+ agentId: scope.agentId,
437
+ bindingId: binding.id,
438
+ runId: context.runId || null,
439
+ expiresAt: Date.now() + PROTECTED_FILL_TTL_MS,
440
+ });
441
+ this.#audit(scope, binding.id, 'browser_fill', origin, 'success', null, context.runId);
442
+ return {
443
+ success: true,
444
+ protected_fill_id: protectedFillId,
445
+ binding: binding.alias,
446
+ origin,
447
+ protected: true,
448
+ message: 'Credentials filled in protected mode. Submit or cancel the protected form.',
449
+ };
450
+ } catch (error) {
451
+ this.#audit(scope, binding.id, 'browser_fill', origin, 'failed', error.code, context.runId);
452
+ throw error;
453
+ }
454
+ }
455
+
456
+ async submitProtected(userId, agentId, protectedFillId, context = {}) {
457
+ const scope = this.#scope(userId, agentId);
458
+ const entry = this.protectedFills.get(String(protectedFillId || ''));
459
+ if (!entry || entry.userId !== scope.userId || entry.agentId !== scope.agentId) {
460
+ throw new Error('Protected browser fill is missing or expired.');
461
+ }
462
+ const provider = await this.runtimeManager.getBrowserProviderForUser(scope.userId);
463
+ if (entry.expiresAt <= Date.now()) {
464
+ await provider.cancelProtectedCredential(String(protectedFillId)).catch(() => {});
465
+ this.protectedFills.delete(String(protectedFillId));
466
+ throw new Error('Protected browser fill expired and was cleared.');
467
+ }
468
+ const result = await provider.submitProtectedCredential(String(protectedFillId));
469
+ this.protectedFills.delete(String(protectedFillId));
470
+ this.#audit(scope, entry.bindingId, 'browser_submit', result?.url || null, 'success', null, context.runId);
471
+ return result;
472
+ }
473
+
474
+ async cancelProtected(userId, agentId, protectedFillId, context = {}) {
475
+ const scope = this.#scope(userId, agentId);
476
+ const entry = this.protectedFills.get(String(protectedFillId || ''));
477
+ if (!entry || entry.userId !== scope.userId || entry.agentId !== scope.agentId) {
478
+ throw new Error('Protected browser fill is missing or expired.');
479
+ }
480
+ const provider = await this.runtimeManager.getBrowserProviderForUser(scope.userId);
481
+ const result = await provider.cancelProtectedCredential(String(protectedFillId));
482
+ this.protectedFills.delete(String(protectedFillId));
483
+ this.#audit(scope, entry.bindingId, 'browser_cancel', null, 'success', null, context.runId);
484
+ return result;
485
+ }
486
+
487
+ async httpRequest(userId, agentId, input, context = {}) {
488
+ const scope = this.#scope(userId, agentId);
489
+ const binding = this.#decodeBinding(this.#bindingRow(scope.userId, scope.agentId, input?.binding_id));
490
+ if (!binding || binding.usageType !== 'http') throw new Error('HTTP credential binding not found.');
491
+ const method = String(input?.method || 'GET').toUpperCase();
492
+ const url = new URL(requireText(input?.url, 'Request URL'));
493
+ const target = binding.targetConfig;
494
+ if (
495
+ url.protocol !== 'https:'
496
+ || /%(?:2e|2f|5c)/i.test(url.pathname)
497
+ || url.origin !== target.origin
498
+ || !pathMatchesPrefix(url.pathname, target.pathPrefix)
499
+ || !target.methods.includes(method)
500
+ ) {
501
+ throw new Error('Credential request does not match the binding target policy.');
502
+ }
503
+ const validation = await validateCloudUrlWithDns(url.toString(), { signal: context.signal });
504
+ if (!validation.allowed) throw new Error('Credential request target is not allowed.');
505
+ const headers = Object.fromEntries(Object.entries(input?.headers || {}).map(([name, value]) => [
506
+ String(name),
507
+ String(value),
508
+ ]));
509
+ const blockedNames = new Set(['authorization', 'cookie', String(target.headerName || '').toLowerCase()]);
510
+ if (Object.keys(headers).some((name) => blockedNames.has(name.toLowerCase()))) {
511
+ throw new Error('Authentication and cookie headers are controlled by the credential binding.');
512
+ }
513
+
514
+ try {
515
+ return await this.#resolveBindingItem(scope, binding, async (item) => {
516
+ const secret = readItemField(item, binding.fieldConfig.secretField);
517
+ const username = binding.fieldConfig.usernameField
518
+ ? readItemField(item, binding.fieldConfig.usernameField)
519
+ : '';
520
+ if (!secret) throw new Error('The bound Bitwarden secret is empty.');
521
+ if (target.authType === 'bearer') headers.Authorization = `Bearer ${secret}`;
522
+ if (target.authType === 'basic') {
523
+ headers.Authorization = `Basic ${Buffer.from(`${username}:${secret}`, 'utf8').toString('base64')}`;
524
+ }
525
+ if (target.authType === 'header') headers[target.headerName] = secret;
526
+ const controller = new AbortController();
527
+ const timeoutMs = Math.max(1000, Math.min(Number(input?.timeout_ms || 30_000), 120_000));
528
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
529
+ const onAbort = () => controller.abort();
530
+ context.signal?.addEventListener('abort', onAbort, { once: true });
531
+ try {
532
+ const response = await fetch(url, {
533
+ method,
534
+ headers,
535
+ body: ['POST', 'PUT', 'PATCH'].includes(method) && input?.body != null
536
+ ? String(input.body)
537
+ : undefined,
538
+ redirect: 'manual',
539
+ signal: controller.signal,
540
+ });
541
+ const chunks = [];
542
+ let byteCount = 0;
543
+ const reader = response.body?.getReader();
544
+ if (reader) {
545
+ while (byteCount <= MAX_RESPONSE_BYTES) {
546
+ const { done, value } = await reader.read();
547
+ if (done) break;
548
+ const chunk = Buffer.from(value);
549
+ chunks.push(chunk);
550
+ byteCount += chunk.length;
551
+ }
552
+ if (byteCount > MAX_RESPONSE_BYTES) await reader.cancel().catch(() => {});
553
+ }
554
+ const bytes = Buffer.concat(chunks);
555
+ const truncated = byteCount > MAX_RESPONSE_BYTES;
556
+ const body = bytes.subarray(0, MAX_RESPONSE_BYTES).toString('utf8');
557
+ const resolvedValues = [secret, username, `${username}:${secret}`, `Bearer ${secret}`];
558
+ this.#audit(scope, binding.id, 'http_request', `${method} ${url.origin}${url.pathname}`, 'success', null, context.runId);
559
+ return {
560
+ status: response.status,
561
+ headers: safeResponseHeaders(response.headers, resolvedValues),
562
+ body: redactResolvedValues(body, resolvedValues),
563
+ truncated,
564
+ };
565
+ } finally {
566
+ clearTimeout(timer);
567
+ context.signal?.removeEventListener('abort', onAbort);
568
+ }
569
+ }, { signal: context.signal });
570
+ } catch (error) {
571
+ this.#audit(scope, binding.id, 'http_request', `${method} ${url.origin}${url.pathname}`, 'failed', error.code, context.runId);
572
+ throw error;
573
+ }
574
+ }
575
+
576
+ summarizeBindings(userId, agentId) {
577
+ const bindings = this.listBindings(userId, agentId);
578
+ if (bindings.length === 0) return 'No Bitwarden credential bindings are configured.';
579
+ return bindings.map((binding) => {
580
+ const targets = binding.usageType === 'browser'
581
+ ? binding.target.origins.join(', ')
582
+ : `${binding.target.methods.join('/')} ${binding.target.origin}${binding.target.pathPrefix}`;
583
+ return `${binding.id}: ${binding.alias} (${binding.usageType}; ${targets})`;
584
+ }).join('; ');
585
+ }
586
+ }
587
+
588
+ module.exports = {
589
+ CredentialBroker,
590
+ itemOrigins,
591
+ readItemField,
592
+ redactResolvedValues,
593
+ sanitizeItem,
594
+ };
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  const { WebSocketServer } = require('ws');
2
4
  const {
3
5
  DESKTOP_COMPANION_WS_PATH,
@@ -15,6 +17,9 @@ const {
15
17
  const UPGRADE_RATE_LIMIT_WINDOW_MS = 60 * 1000;
16
18
  const UPGRADE_RATE_LIMIT_MAX_ATTEMPTS = 30;
17
19
  const UPGRADE_RATE_LIMIT_ENTRY_TTL_MS = 10 * 60 * 1000;
20
+ const UPGRADE_AUTH_TIMEOUT_MS = 5000;
21
+ const HELLO_TIMEOUT_MS = 5000;
22
+ const MAX_HELLO_BYTES = 64 * 1024;
18
23
 
19
24
  function rejectUpgrade(socket, statusCode, message) {
20
25
  try {
@@ -88,6 +93,8 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
88
93
  });
89
94
  const upgradeAttempts = new Map();
90
95
  const upgradeThrottleObserver = createUpgradeThrottleObserver();
96
+ let closing = false;
97
+ let closePromise = null;
91
98
 
92
99
  if (app?.locals) {
93
100
  app.locals.getDesktopGatewayRateLimitSnapshot = () => upgradeThrottleObserver.snapshot();
@@ -115,7 +122,7 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
115
122
  return true;
116
123
  }
117
124
 
118
- httpServer.on('upgrade', (req, socket, head) => {
125
+ const handleUpgrade = (req, socket, head) => {
119
126
  let url;
120
127
  try {
121
128
  url = new URL(req.url, 'http://localhost');
@@ -125,6 +132,10 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
125
132
  if (url.pathname !== DESKTOP_COMPANION_WS_PATH) {
126
133
  return;
127
134
  }
135
+ if (closing) {
136
+ rejectUpgrade(socket, 503, 'Service Unavailable');
137
+ return;
138
+ }
128
139
 
129
140
  const remoteAddress = remoteAddressFromRequest(req);
130
141
  if (!allowUpgradeAttempt(remoteAddress)) {
@@ -133,7 +144,16 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
133
144
  return;
134
145
  }
135
146
 
147
+ const authTimer = setTimeout(() => {
148
+ rejectUpgrade(socket, 504, 'Gateway Timeout');
149
+ }, UPGRADE_AUTH_TIMEOUT_MS);
150
+ authTimer.unref?.();
136
151
  sessionMiddleware(req, {}, (err) => {
152
+ clearTimeout(authTimer);
153
+ if (closing || socket.destroyed) {
154
+ if (!socket.destroyed) rejectUpgrade(socket, 503, 'Service Unavailable');
155
+ return;
156
+ }
137
157
  if (err) {
138
158
  rejectUpgrade(socket, 500, 'Session Error');
139
159
  return;
@@ -155,11 +175,18 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
155
175
  if (!initialized) {
156
176
  try { ws.close(1008, 'Desktop companion hello timed out'); } catch {}
157
177
  }
158
- }, 5000);
178
+ }, HELLO_TIMEOUT_MS);
179
+ helloTimer.unref?.();
180
+ const clearHelloTimer = () => clearTimeout(helloTimer);
181
+ ws.once('close', clearHelloTimer);
182
+ ws.once('error', clearHelloTimer);
159
183
 
160
184
  ws.once('message', (data) => {
161
185
  clearTimeout(helloTimer);
162
186
  try {
187
+ if (Buffer.byteLength(data) > MAX_HELLO_BYTES) {
188
+ throw Object.assign(new Error('Desktop companion hello is too large.'), { status: 413 });
189
+ }
163
190
  const message = parseDesktopMessage(data);
164
191
  if (!isDesktopCompanionHello(message)) {
165
192
  throw Object.assign(new Error('Desktop companion hello is required.'), { status: 400 });
@@ -227,11 +254,21 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamH
227
254
  });
228
255
  });
229
256
  });
230
- });
257
+ };
258
+ httpServer.on('upgrade', handleUpgrade);
231
259
 
232
260
  if (app?.locals) {
233
261
  app.locals.desktopCompanionGateway = {
234
- close: () => new Promise((resolve) => wss.close(() => resolve())),
262
+ close: () => {
263
+ if (closePromise) return closePromise;
264
+ closing = true;
265
+ httpServer.removeListener('upgrade', handleUpgrade);
266
+ for (const client of wss.clients) {
267
+ try { client.terminate(); } catch {}
268
+ }
269
+ closePromise = new Promise((resolve) => wss.close(() => resolve()));
270
+ return closePromise;
271
+ },
235
272
  };
236
273
  }
237
274