neoagent 3.2.1-beta.3 → 3.2.1-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/chrome-browser/protocol.mjs +143 -1
- package/flutter_app/lib/main_controller.dart +82 -0
- package/flutter_app/lib/main_integrations.dart +607 -8
- package/flutter_app/lib/main_security.dart +266 -112
- package/flutter_app/lib/src/backend_client.dart +78 -0
- package/lib/schema_migrations.js +48 -0
- package/package.json +10 -2
- package/server/guest-agent.cli.package.json +13 -0
- package/server/guest_agent.js +33 -10
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +69495 -68619
- package/server/routes/integrations.js +102 -0
- package/server/services/ai/systemPrompt.js +2 -2
- package/server/services/ai/tools.js +77 -0
- package/server/services/browser/controller.js +107 -0
- package/server/services/browser/extension/protocol.js +3 -0
- package/server/services/browser/extension/provider.js +12 -0
- package/server/services/credentials/bitwarden_cli.js +322 -0
- package/server/services/credentials/broker.js +594 -0
- package/server/services/integrations/bitwarden/constants.js +14 -0
- package/server/services/integrations/bitwarden/provider.js +197 -0
- package/server/services/integrations/bitwarden/snapshot.js +65 -0
- package/server/services/integrations/manager.js +1 -0
- package/server/services/integrations/registry.js +2 -0
- package/server/services/manager.js +23 -0
- package/server/services/messaging/whatsapp.js +22 -14
- package/server/services/runtime/backends/local-vm.js +13 -1
- package/server/services/runtime/guest_bootstrap.js +23 -4
- package/server/services/runtime/guest_image.js +4 -3
- package/server/services/runtime/manager.js +25 -11
- package/server/services/runtime/validation.js +7 -6
- package/server/services/security/tool_categories.js +6 -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
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BITWARDEN_PROVIDER_KEY = 'bitwarden';
|
|
4
|
+
|
|
5
|
+
const BITWARDEN_APP = {
|
|
6
|
+
id: 'password_manager',
|
|
7
|
+
label: 'Password manager',
|
|
8
|
+
description: 'Use selected Bitwarden items without exposing their secret values to the AI.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
BITWARDEN_APP,
|
|
13
|
+
BITWARDEN_PROVIDER_KEY,
|
|
14
|
+
};
|