@alfe.ai/openclaw-database 0.0.43 → 0.0.45
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/README.md +16 -0
- package/dist/plugin2.cjs +276 -73
- package/dist/plugin2.js +276 -73
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -10,3 +10,19 @@ OpenClaw plugin that gives an agent direct MongoDB access to its org's Atlas
|
|
|
10
10
|
cluster via `db_*` MCP tools (find / insert / update / delete / aggregate /
|
|
11
11
|
index / schema). Credentials are fetched from the Alfe database service at
|
|
12
12
|
activation time.
|
|
13
|
+
|
|
14
|
+
The retained implementation is defense-in-depth for existing installations:
|
|
15
|
+
it validates the credential response and database allowlist, blocks protected
|
|
16
|
+
collections and unsafe executable/cross-collection aggregation operators,
|
|
17
|
+
bounds query/input/output sizes, and requires explicit intent for destructive
|
|
18
|
+
collection-wide or drop operations. It remains `onStartup: false` and is not
|
|
19
|
+
present in any integration manifest.
|
|
20
|
+
|
|
21
|
+
## Development
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm --filter @alfe.ai/openclaw-database lint
|
|
25
|
+
pnpm --filter @alfe.ai/openclaw-database typecheck
|
|
26
|
+
pnpm --filter @alfe.ai/openclaw-database test
|
|
27
|
+
pnpm --filter @alfe.ai/openclaw-database build
|
|
28
|
+
```
|
package/dist/plugin2.cjs
CHANGED
|
@@ -4,18 +4,132 @@ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
|
|
|
4
4
|
let node_module = require("node:module");
|
|
5
5
|
//#region src/tools.ts
|
|
6
6
|
const DEFAULT_DB = "org_default";
|
|
7
|
-
|
|
7
|
+
const MAX_SKIP = 1e6;
|
|
8
|
+
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
const MAX_DATABASE_NAME_BYTES = 63;
|
|
10
|
+
const MAX_COLLECTION_NAME_BYTES = 255;
|
|
11
|
+
const MAX_QUERY_TIME_MS = 1e4;
|
|
12
|
+
const BLOCKED_FILTER_OPERATORS = new Set([
|
|
13
|
+
"$where",
|
|
14
|
+
"$function",
|
|
15
|
+
"$accumulator"
|
|
16
|
+
]);
|
|
17
|
+
const BLOCKED_AGGREGATION_STAGES = new Set([
|
|
18
|
+
"$out",
|
|
19
|
+
"$merge",
|
|
20
|
+
"$lookup",
|
|
21
|
+
"$graphLookup",
|
|
22
|
+
"$unionWith",
|
|
23
|
+
"$collStats",
|
|
24
|
+
"$indexStats",
|
|
25
|
+
"$currentOp",
|
|
26
|
+
"$listSessions",
|
|
27
|
+
"$listLocalSessions",
|
|
28
|
+
"$planCacheStats",
|
|
29
|
+
"$changeStream",
|
|
30
|
+
"$changeStreamSplitLargeEvent"
|
|
31
|
+
]);
|
|
32
|
+
const BLOCKED_PIPELINE_KEYS = new Set([...BLOCKED_FILTER_OPERATORS, ...BLOCKED_AGGREGATION_STAGES]);
|
|
33
|
+
function str() {
|
|
8
34
|
return {
|
|
9
35
|
type: "string",
|
|
10
|
-
|
|
36
|
+
minLength: 1,
|
|
37
|
+
maxLength: MAX_COLLECTION_NAME_BYTES
|
|
11
38
|
};
|
|
12
39
|
}
|
|
13
40
|
function optStr() {
|
|
14
41
|
return {
|
|
15
42
|
type: "string",
|
|
43
|
+
maxLength: MAX_DATABASE_NAME_BYTES,
|
|
16
44
|
default: ""
|
|
17
45
|
};
|
|
18
46
|
}
|
|
47
|
+
function isRecord$1(value) {
|
|
48
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function requireRecord(value, label) {
|
|
51
|
+
if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function requireRecordArray(value, label, maxItems, minItems = 1) {
|
|
55
|
+
if (!Array.isArray(value) || value.length < minItems || value.length > maxItems || !value.every(isRecord$1)) throw new Error(`${label} must contain between ${String(minItems)} and ${String(maxItems)} objects`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function checkKeys(value, blocked, context) {
|
|
59
|
+
if (!value || typeof value !== "object") return;
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const item of value) checkKeys(item, blocked, context);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const [key, child] of Object.entries(value)) {
|
|
65
|
+
if (blocked.has(key)) throw new Error(`Operator "${key}" is not allowed in ${context}`);
|
|
66
|
+
checkKeys(child, blocked, context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function normalizeDatabaseName(value) {
|
|
70
|
+
if (value === void 0 || value === null || value === "") return DEFAULT_DB;
|
|
71
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error("database must be a non-empty string");
|
|
72
|
+
if (Buffer.byteLength(value, "utf8") > MAX_DATABASE_NAME_BYTES || /[\0 /\\."$*<>:|?]/.test(value)) throw new Error("database name is invalid");
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function validateCollectionName(value) {
|
|
76
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error("collection must be a non-empty string");
|
|
77
|
+
if (Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("collection name is invalid");
|
|
78
|
+
if (value === "_audit" || value.startsWith("system.")) throw new Error(`Collection "${value}" is reserved for service internals`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function validateIndexName(value) {
|
|
82
|
+
if (typeof value !== "string" || value.trim().length === 0 || Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("indexName is invalid");
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function sanitizeFilter(value) {
|
|
86
|
+
const filter = requireRecord(value, "filter");
|
|
87
|
+
checkKeys(filter, BLOCKED_FILTER_OPERATORS, "filter");
|
|
88
|
+
return filter;
|
|
89
|
+
}
|
|
90
|
+
function sanitizeSort(value) {
|
|
91
|
+
const sort = requireRecord(value, "sort");
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [key, direction] of Object.entries(sort)) {
|
|
94
|
+
if (direction !== 1 && direction !== -1) throw new Error(`Invalid sort value for "${key}": must be 1 or -1`);
|
|
95
|
+
result[key] = direction;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function sanitizePipeline(value) {
|
|
100
|
+
const pipeline = requireRecordArray(value, "pipeline", 50, 0);
|
|
101
|
+
for (const stage of pipeline) for (const [key, child] of Object.entries(stage)) {
|
|
102
|
+
if (BLOCKED_AGGREGATION_STAGES.has(key)) throw new Error(`Aggregation stage "${key}" is not allowed`);
|
|
103
|
+
checkKeys(child, BLOCKED_PIPELINE_KEYS, `pipeline stage "${key}"`);
|
|
104
|
+
}
|
|
105
|
+
return pipeline;
|
|
106
|
+
}
|
|
107
|
+
function boundedInteger(value, fallback, minimum, maximum, label) {
|
|
108
|
+
const normalized = value === void 0 ? fallback : value;
|
|
109
|
+
if (typeof normalized !== "number" || !Number.isInteger(normalized) || normalized < minimum || normalized > maximum) throw new Error(`${label} must be an integer between ${String(minimum)} and ${String(maximum)}`);
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
function serializedBytes(value) {
|
|
113
|
+
const json = JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item);
|
|
114
|
+
return Buffer.byteLength(json, "utf8");
|
|
115
|
+
}
|
|
116
|
+
function assertWithinByteBudget(value, label) {
|
|
117
|
+
if (serializedBytes(value) > 5242880) throw new Error(`${label} exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
|
|
118
|
+
}
|
|
119
|
+
async function collectBounded(cursor, maxDocuments = 200) {
|
|
120
|
+
const results = [];
|
|
121
|
+
let totalBytes = 0;
|
|
122
|
+
for await (const document of cursor) {
|
|
123
|
+
if (results.length >= maxDocuments) throw new Error(`Query returned more than ${String(maxDocuments)} documents`);
|
|
124
|
+
totalBytes += serializedBytes(document);
|
|
125
|
+
if (totalBytes > 5242880) throw new Error(`Query result exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
|
|
126
|
+
results.push(document);
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
function requireDestructiveIntent(filter, allowAll) {
|
|
131
|
+
if (Object.keys(filter).length === 0 && allowAll !== true) throw new Error("An empty filter requires allowAll: true");
|
|
132
|
+
}
|
|
19
133
|
function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
20
134
|
async function db(name) {
|
|
21
135
|
const { mongoClient, databases } = lazyInit ? await lazyInit() : {
|
|
@@ -24,10 +138,14 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
24
138
|
})(),
|
|
25
139
|
databases: _databases
|
|
26
140
|
};
|
|
27
|
-
const dbName = name
|
|
141
|
+
const dbName = normalizeDatabaseName(name);
|
|
28
142
|
if (!databases.includes(dbName)) throw new Error(`Access denied: database "${dbName}" is not in the allowed list`);
|
|
29
143
|
return mongoClient.db(dbName);
|
|
30
144
|
}
|
|
145
|
+
async function collection(database, name) {
|
|
146
|
+
const collectionName = validateCollectionName(name);
|
|
147
|
+
return (await db(database)).collection(collectionName);
|
|
148
|
+
}
|
|
31
149
|
api.registerTool({
|
|
32
150
|
name: "db_find",
|
|
33
151
|
description: "Find documents matching a filter",
|
|
@@ -46,11 +164,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
46
164
|
default: {}
|
|
47
165
|
},
|
|
48
166
|
limit: {
|
|
49
|
-
type: "
|
|
167
|
+
type: "integer",
|
|
168
|
+
minimum: 1,
|
|
169
|
+
maximum: 200,
|
|
50
170
|
default: 20
|
|
51
171
|
},
|
|
52
172
|
skip: {
|
|
53
|
-
type: "
|
|
173
|
+
type: "integer",
|
|
174
|
+
minimum: 0,
|
|
175
|
+
maximum: MAX_SKIP,
|
|
54
176
|
default: 0
|
|
55
177
|
},
|
|
56
178
|
projection: {
|
|
@@ -61,7 +183,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
61
183
|
required: ["collection"]
|
|
62
184
|
},
|
|
63
185
|
execute: async (_id, p) => {
|
|
64
|
-
const
|
|
186
|
+
const limit = boundedInteger(p.limit, 20, 1, 200, "limit");
|
|
187
|
+
const skip = boundedInteger(p.skip, 0, 0, MAX_SKIP, "skip");
|
|
188
|
+
const docs = await collectBounded((await collection(p.database, p.collection)).find(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }).sort(sanitizeSort(p.sort ?? {})).project(requireRecord(p.projection ?? {}, "projection")).skip(skip).limit(limit), limit);
|
|
65
189
|
return {
|
|
66
190
|
documents: docs,
|
|
67
191
|
count: docs.length
|
|
@@ -89,7 +213,12 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
89
213
|
required: ["collection"]
|
|
90
214
|
},
|
|
91
215
|
execute: async (_id, p) => {
|
|
92
|
-
|
|
216
|
+
const doc = await (await collection(p.database, p.collection)).findOne(sanitizeFilter(p.filter ?? {}), {
|
|
217
|
+
projection: requireRecord(p.projection ?? {}, "projection"),
|
|
218
|
+
maxTimeMS: MAX_QUERY_TIME_MS
|
|
219
|
+
});
|
|
220
|
+
assertWithinByteBudget(doc, "Query result");
|
|
221
|
+
return doc ?? { _not_found: true };
|
|
93
222
|
}
|
|
94
223
|
});
|
|
95
224
|
api.registerTool({
|
|
@@ -103,17 +232,20 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
103
232
|
collection: str(),
|
|
104
233
|
documents: {
|
|
105
234
|
type: "array",
|
|
235
|
+
minItems: 1,
|
|
236
|
+
maxItems: 100,
|
|
106
237
|
items: { type: "object" }
|
|
107
238
|
}
|
|
108
239
|
},
|
|
109
240
|
required: ["collection", "documents"]
|
|
110
241
|
},
|
|
111
242
|
execute: async (_id, p) => {
|
|
112
|
-
const docs = p.documents;
|
|
113
|
-
|
|
243
|
+
const docs = requireRecordArray(p.documents, "documents", 100);
|
|
244
|
+
assertWithinByteBudget(docs, "Insert batch");
|
|
245
|
+
const result = await (await collection(p.database, p.collection)).insertMany(docs);
|
|
114
246
|
audit({
|
|
115
|
-
database: p.database
|
|
116
|
-
collection: p.collection,
|
|
247
|
+
database: normalizeDatabaseName(p.database),
|
|
248
|
+
collection: validateCollectionName(p.collection),
|
|
117
249
|
operation: "insert",
|
|
118
250
|
summary: `Inserted ${String(docs.length)} document(s)`
|
|
119
251
|
});
|
|
@@ -137,6 +269,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
137
269
|
upsert: {
|
|
138
270
|
type: "boolean",
|
|
139
271
|
default: false
|
|
272
|
+
},
|
|
273
|
+
allowAll: {
|
|
274
|
+
type: "boolean",
|
|
275
|
+
default: false,
|
|
276
|
+
description: "Must be true when using an empty filter intentionally"
|
|
140
277
|
}
|
|
141
278
|
},
|
|
142
279
|
required: [
|
|
@@ -146,10 +283,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
146
283
|
]
|
|
147
284
|
},
|
|
148
285
|
execute: async (_id, p) => {
|
|
149
|
-
const
|
|
286
|
+
const filter = sanitizeFilter(p.filter);
|
|
287
|
+
requireDestructiveIntent(filter, p.allowAll);
|
|
288
|
+
const update = requireRecord(p.update, "update");
|
|
289
|
+
if (Object.keys(update).length === 0) throw new Error("update must not be empty");
|
|
290
|
+
checkKeys(update, BLOCKED_FILTER_OPERATORS, "update");
|
|
291
|
+
const result = await (await collection(p.database, p.collection)).updateMany(filter, update, { upsert: p.upsert === true });
|
|
150
292
|
audit({
|
|
151
|
-
database: p.database
|
|
152
|
-
collection: p.collection,
|
|
293
|
+
database: normalizeDatabaseName(p.database),
|
|
294
|
+
collection: validateCollectionName(p.collection),
|
|
153
295
|
operation: "update",
|
|
154
296
|
summary: `Modified ${String(result.modifiedCount)}`
|
|
155
297
|
});
|
|
@@ -169,15 +311,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
169
311
|
properties: {
|
|
170
312
|
database: optStr(),
|
|
171
313
|
collection: str(),
|
|
172
|
-
filter: { type: "object" }
|
|
314
|
+
filter: { type: "object" },
|
|
315
|
+
allowAll: {
|
|
316
|
+
type: "boolean",
|
|
317
|
+
default: false,
|
|
318
|
+
description: "Must be true when using an empty filter intentionally"
|
|
319
|
+
}
|
|
173
320
|
},
|
|
174
321
|
required: ["collection", "filter"]
|
|
175
322
|
},
|
|
176
323
|
execute: async (_id, p) => {
|
|
177
|
-
const
|
|
324
|
+
const filter = sanitizeFilter(p.filter);
|
|
325
|
+
requireDestructiveIntent(filter, p.allowAll);
|
|
326
|
+
const result = await (await collection(p.database, p.collection)).deleteMany(filter);
|
|
178
327
|
audit({
|
|
179
|
-
database: p.database
|
|
180
|
-
collection: p.collection,
|
|
328
|
+
database: normalizeDatabaseName(p.database),
|
|
329
|
+
collection: validateCollectionName(p.collection),
|
|
181
330
|
operation: "delete",
|
|
182
331
|
summary: `Deleted ${String(result.deletedCount)}`
|
|
183
332
|
});
|
|
@@ -201,7 +350,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
201
350
|
required: ["collection"]
|
|
202
351
|
},
|
|
203
352
|
execute: async (_id, p) => {
|
|
204
|
-
return { count: await (await
|
|
353
|
+
return { count: await (await collection(p.database, p.collection)).countDocuments(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }) };
|
|
205
354
|
}
|
|
206
355
|
});
|
|
207
356
|
api.registerTool({
|
|
@@ -215,13 +364,16 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
215
364
|
collection: str(),
|
|
216
365
|
pipeline: {
|
|
217
366
|
type: "array",
|
|
367
|
+
minItems: 0,
|
|
368
|
+
maxItems: 50,
|
|
218
369
|
items: { type: "object" }
|
|
219
370
|
}
|
|
220
371
|
},
|
|
221
372
|
required: ["collection", "pipeline"]
|
|
222
373
|
},
|
|
223
374
|
execute: async (_id, p) => {
|
|
224
|
-
const
|
|
375
|
+
const pipeline = sanitizePipeline(p.pipeline);
|
|
376
|
+
const results = await collectBounded((await collection(p.database, p.collection)).aggregate([...pipeline, { $limit: 201 }], { maxTimeMS: MAX_QUERY_TIME_MS }), 200);
|
|
225
377
|
return {
|
|
226
378
|
results,
|
|
227
379
|
count: results.length
|
|
@@ -250,9 +402,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
250
402
|
properties: { database: optStr() }
|
|
251
403
|
},
|
|
252
404
|
execute: async (_id, p) => {
|
|
253
|
-
return { collections: (await (await db(p.database)).listCollections().toArray()).map((
|
|
254
|
-
name:
|
|
255
|
-
type:
|
|
405
|
+
return { collections: (await (await db(p.database)).listCollections({}, { nameOnly: true }).toArray()).filter((item) => item.name !== "_audit" && !item.name.startsWith("system.")).map((item) => ({
|
|
406
|
+
name: item.name,
|
|
407
|
+
type: item.type
|
|
256
408
|
})) };
|
|
257
409
|
}
|
|
258
410
|
});
|
|
@@ -269,10 +421,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
269
421
|
required: ["collection"]
|
|
270
422
|
},
|
|
271
423
|
execute: async (_id, p) => {
|
|
272
|
-
|
|
424
|
+
const collectionName = validateCollectionName(p.collection);
|
|
425
|
+
await (await db(p.database)).createCollection(collectionName);
|
|
273
426
|
audit({
|
|
274
|
-
database: p.database
|
|
275
|
-
collection:
|
|
427
|
+
database: normalizeDatabaseName(p.database),
|
|
428
|
+
collection: collectionName,
|
|
276
429
|
operation: "create_collection"
|
|
277
430
|
});
|
|
278
431
|
return { success: true };
|
|
@@ -286,15 +439,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
286
439
|
type: "object",
|
|
287
440
|
properties: {
|
|
288
441
|
database: optStr(),
|
|
289
|
-
collection: str()
|
|
442
|
+
collection: str(),
|
|
443
|
+
confirm: {
|
|
444
|
+
type: "boolean",
|
|
445
|
+
const: true,
|
|
446
|
+
description: "Must be true to confirm permanent collection deletion"
|
|
447
|
+
}
|
|
290
448
|
},
|
|
291
|
-
required: ["collection"]
|
|
449
|
+
required: ["collection", "confirm"]
|
|
292
450
|
},
|
|
293
451
|
execute: async (_id, p) => {
|
|
294
|
-
|
|
452
|
+
if (p.confirm !== true) throw new Error("Dropping a collection requires confirm: true");
|
|
453
|
+
const collectionName = validateCollectionName(p.collection);
|
|
454
|
+
await (await db(p.database)).dropCollection(collectionName);
|
|
295
455
|
audit({
|
|
296
|
-
database: p.database
|
|
297
|
-
collection:
|
|
456
|
+
database: normalizeDatabaseName(p.database),
|
|
457
|
+
collection: collectionName,
|
|
298
458
|
operation: "drop_collection"
|
|
299
459
|
});
|
|
300
460
|
return { success: true };
|
|
@@ -313,7 +473,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
313
473
|
required: ["collection"]
|
|
314
474
|
},
|
|
315
475
|
execute: async (_id, p) => {
|
|
316
|
-
const sample = await (await
|
|
476
|
+
const sample = await collectBounded((await collection(p.database, p.collection)).find({}, { maxTimeMS: MAX_QUERY_TIME_MS }).limit(100), 100);
|
|
317
477
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
318
478
|
for (const doc of sample) for (const [key, value] of Object.entries(doc)) {
|
|
319
479
|
const types = fieldTypes.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -342,8 +502,8 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
342
502
|
required: ["collection"]
|
|
343
503
|
},
|
|
344
504
|
execute: async (_id, p) => {
|
|
345
|
-
const coll =
|
|
346
|
-
const count = await coll.countDocuments();
|
|
505
|
+
const coll = await collection(p.database, p.collection);
|
|
506
|
+
const count = await coll.countDocuments({}, { maxTimeMS: MAX_QUERY_TIME_MS });
|
|
347
507
|
const indexes = await coll.indexes();
|
|
348
508
|
return {
|
|
349
509
|
count,
|
|
@@ -373,10 +533,10 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
373
533
|
required: ["collection", "keys"]
|
|
374
534
|
},
|
|
375
535
|
execute: async (_id, p) => {
|
|
376
|
-
const indexName = await (await
|
|
536
|
+
const indexName = await (await collection(p.database, p.collection)).createIndex(sanitizeSort(p.keys), requireRecord(p.options ?? {}, "options"));
|
|
377
537
|
audit({
|
|
378
|
-
database: p.database
|
|
379
|
-
collection: p.collection,
|
|
538
|
+
database: normalizeDatabaseName(p.database),
|
|
539
|
+
collection: validateCollectionName(p.collection),
|
|
380
540
|
operation: "create_index",
|
|
381
541
|
summary: indexName
|
|
382
542
|
});
|
|
@@ -396,7 +556,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
396
556
|
required: ["collection"]
|
|
397
557
|
},
|
|
398
558
|
execute: async (_id, p) => {
|
|
399
|
-
return { indexes: (await (await
|
|
559
|
+
return { indexes: (await (await collection(p.database, p.collection)).indexes()).map((i) => ({
|
|
400
560
|
name: i.name,
|
|
401
561
|
key: i.key,
|
|
402
562
|
unique: i.unique
|
|
@@ -412,56 +572,107 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
412
572
|
properties: {
|
|
413
573
|
database: optStr(),
|
|
414
574
|
collection: str(),
|
|
415
|
-
indexName: str()
|
|
575
|
+
indexName: str(),
|
|
576
|
+
confirm: {
|
|
577
|
+
type: "boolean",
|
|
578
|
+
const: true,
|
|
579
|
+
description: "Must be true to confirm permanent index deletion"
|
|
580
|
+
}
|
|
416
581
|
},
|
|
417
|
-
required: [
|
|
582
|
+
required: [
|
|
583
|
+
"collection",
|
|
584
|
+
"indexName",
|
|
585
|
+
"confirm"
|
|
586
|
+
]
|
|
418
587
|
},
|
|
419
588
|
execute: async (_id, p) => {
|
|
420
|
-
|
|
589
|
+
if (p.confirm !== true) throw new Error("Dropping an index requires confirm: true");
|
|
590
|
+
const indexName = validateIndexName(p.indexName);
|
|
591
|
+
await (await collection(p.database, p.collection)).dropIndex(indexName);
|
|
421
592
|
audit({
|
|
422
|
-
database: p.database
|
|
423
|
-
collection: p.collection,
|
|
593
|
+
database: normalizeDatabaseName(p.database),
|
|
594
|
+
collection: validateCollectionName(p.collection),
|
|
424
595
|
operation: "drop_index",
|
|
425
|
-
summary:
|
|
596
|
+
summary: indexName
|
|
426
597
|
});
|
|
427
598
|
return { success: true };
|
|
428
599
|
}
|
|
429
600
|
});
|
|
430
601
|
}
|
|
431
602
|
//#endregion
|
|
603
|
+
//#region src/database-credentials.ts
|
|
604
|
+
function isRecord(value) {
|
|
605
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
606
|
+
}
|
|
607
|
+
/** Validate the control-plane credential response before it reaches MongoDB. */
|
|
608
|
+
function parseDatabaseCredentials(value) {
|
|
609
|
+
if (!isRecord(value)) throw new Error("Invalid database credential response");
|
|
610
|
+
const { connectionString, username, password, databases } = value;
|
|
611
|
+
if (typeof connectionString !== "string" || typeof username !== "string" || username.length < 1 || typeof password !== "string" || password.length < 1 || !Array.isArray(databases) || databases.length < 1) throw new Error("Invalid database credential response");
|
|
612
|
+
const normalizedDatabases = [];
|
|
613
|
+
for (const database of databases) {
|
|
614
|
+
if (typeof database !== "string" || database.length < 1) throw new Error("Invalid database credential response");
|
|
615
|
+
normalizedDatabases.push(database);
|
|
616
|
+
}
|
|
617
|
+
const url = new URL(connectionString);
|
|
618
|
+
if (url.protocol !== "mongodb:" && url.protocol !== "mongodb+srv:") throw new Error("Unsupported database connection protocol");
|
|
619
|
+
if (!url.hostname) throw new Error("Invalid database credential response");
|
|
620
|
+
url.username = username;
|
|
621
|
+
url.password = password;
|
|
622
|
+
return {
|
|
623
|
+
connectionString: url.toString(),
|
|
624
|
+
databases: [...new Set(normalizedDatabases)]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
//#endregion
|
|
432
628
|
//#region src/plugin.ts
|
|
433
629
|
const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
|
|
434
630
|
let mongoClient = null;
|
|
435
631
|
let initPromise = null;
|
|
632
|
+
let connectionGeneration = 0;
|
|
633
|
+
function errorClass(error) {
|
|
634
|
+
return error instanceof Error && error.name ? error.name : "UnknownError";
|
|
635
|
+
}
|
|
636
|
+
async function closeDatabaseConnection() {
|
|
637
|
+
connectionGeneration += 1;
|
|
638
|
+
initPromise = null;
|
|
639
|
+
const current = mongoClient;
|
|
640
|
+
mongoClient = null;
|
|
641
|
+
if (current) await current.close();
|
|
642
|
+
}
|
|
436
643
|
/**
|
|
437
644
|
* Lazy initializer — fetches credentials and connects to MongoDB on first use.
|
|
438
645
|
* Returns a shared promise so concurrent calls don't duplicate work.
|
|
439
646
|
*/
|
|
440
647
|
function ensureInitialized(client) {
|
|
441
648
|
if (initPromise) return initPromise;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
url.username = credentials.username;
|
|
447
|
-
url.password = credentials.password;
|
|
448
|
-
const mc = new mongodb.MongoClient(url.toString(), {
|
|
649
|
+
const generation = connectionGeneration;
|
|
650
|
+
const pending = (async () => {
|
|
651
|
+
const credentials = parseDatabaseCredentials(await client.registerDatabaseCredentials());
|
|
652
|
+
const mc = new mongodb.MongoClient(credentials.connectionString, {
|
|
449
653
|
maxPoolSize: 3,
|
|
450
654
|
minPoolSize: 1,
|
|
451
655
|
serverSelectionTimeoutMS: 5e3
|
|
452
656
|
});
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
mongoClient
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
657
|
+
try {
|
|
658
|
+
await mc.connect();
|
|
659
|
+
if (generation !== connectionGeneration) throw new Error("Database service stopped during initialization");
|
|
660
|
+
mongoClient = mc;
|
|
661
|
+
return {
|
|
662
|
+
mongoClient: mc,
|
|
663
|
+
databases: credentials.databases,
|
|
664
|
+
client
|
|
665
|
+
};
|
|
666
|
+
} catch (error) {
|
|
667
|
+
await mc.close().catch(() => void 0);
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
460
670
|
})();
|
|
461
|
-
initPromise
|
|
462
|
-
|
|
671
|
+
initPromise = pending;
|
|
672
|
+
pending.catch(() => {
|
|
673
|
+
if (initPromise === pending) initPromise = null;
|
|
463
674
|
});
|
|
464
|
-
return
|
|
675
|
+
return pending;
|
|
465
676
|
}
|
|
466
677
|
const ACTIVATED_KEY = "__alfeDatabasePluginActivated";
|
|
467
678
|
const g = globalThis;
|
|
@@ -481,7 +692,7 @@ const plugin = {
|
|
|
481
692
|
apiUrl: config.apiUrl
|
|
482
693
|
});
|
|
483
694
|
} catch (err) {
|
|
484
|
-
log.error(`Database plugin: failed to resolve config
|
|
695
|
+
log.error(`Database plugin: failed to resolve config (${errorClass(err)})`);
|
|
485
696
|
return;
|
|
486
697
|
}
|
|
487
698
|
registerTools(api, null, [], (entry) => {
|
|
@@ -496,16 +707,12 @@ const plugin = {
|
|
|
496
707
|
ensureInitialized(client).then(({ databases }) => {
|
|
497
708
|
log.info(`Database plugin connected — ${String(databases.length)} databases available`);
|
|
498
709
|
}).catch((err) => {
|
|
499
|
-
log.warn(`Database plugin: background init failed
|
|
710
|
+
log.warn(`Database plugin: background init failed; first tool use will retry (${errorClass(err)})`);
|
|
500
711
|
});
|
|
501
712
|
};
|
|
502
713
|
const stopDatabaseService = async () => {
|
|
503
714
|
g[ACTIVATED_KEY] = false;
|
|
504
|
-
|
|
505
|
-
await mongoClient.close();
|
|
506
|
-
mongoClient = null;
|
|
507
|
-
initPromise = null;
|
|
508
|
-
}
|
|
715
|
+
await closeDatabaseConnection();
|
|
509
716
|
log.info("Database plugin deactivated");
|
|
510
717
|
};
|
|
511
718
|
api.registerService({
|
|
@@ -521,11 +728,7 @@ const plugin = {
|
|
|
521
728
|
async deactivate(api) {
|
|
522
729
|
g[ACTIVATED_KEY] = false;
|
|
523
730
|
api.logger.info("Database plugin deactivating...");
|
|
524
|
-
|
|
525
|
-
await mongoClient.close();
|
|
526
|
-
mongoClient = null;
|
|
527
|
-
initPromise = null;
|
|
528
|
-
}
|
|
731
|
+
await closeDatabaseConnection();
|
|
529
732
|
}
|
|
530
733
|
};
|
|
531
734
|
//#endregion
|
package/dist/plugin2.js
CHANGED
|
@@ -4,18 +4,132 @@ import { resolveConfig } from "@alfe.ai/config";
|
|
|
4
4
|
import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
|
|
5
5
|
//#region src/tools.ts
|
|
6
6
|
const DEFAULT_DB = "org_default";
|
|
7
|
-
|
|
7
|
+
const MAX_SKIP = 1e6;
|
|
8
|
+
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
const MAX_DATABASE_NAME_BYTES = 63;
|
|
10
|
+
const MAX_COLLECTION_NAME_BYTES = 255;
|
|
11
|
+
const MAX_QUERY_TIME_MS = 1e4;
|
|
12
|
+
const BLOCKED_FILTER_OPERATORS = new Set([
|
|
13
|
+
"$where",
|
|
14
|
+
"$function",
|
|
15
|
+
"$accumulator"
|
|
16
|
+
]);
|
|
17
|
+
const BLOCKED_AGGREGATION_STAGES = new Set([
|
|
18
|
+
"$out",
|
|
19
|
+
"$merge",
|
|
20
|
+
"$lookup",
|
|
21
|
+
"$graphLookup",
|
|
22
|
+
"$unionWith",
|
|
23
|
+
"$collStats",
|
|
24
|
+
"$indexStats",
|
|
25
|
+
"$currentOp",
|
|
26
|
+
"$listSessions",
|
|
27
|
+
"$listLocalSessions",
|
|
28
|
+
"$planCacheStats",
|
|
29
|
+
"$changeStream",
|
|
30
|
+
"$changeStreamSplitLargeEvent"
|
|
31
|
+
]);
|
|
32
|
+
const BLOCKED_PIPELINE_KEYS = new Set([...BLOCKED_FILTER_OPERATORS, ...BLOCKED_AGGREGATION_STAGES]);
|
|
33
|
+
function str() {
|
|
8
34
|
return {
|
|
9
35
|
type: "string",
|
|
10
|
-
|
|
36
|
+
minLength: 1,
|
|
37
|
+
maxLength: MAX_COLLECTION_NAME_BYTES
|
|
11
38
|
};
|
|
12
39
|
}
|
|
13
40
|
function optStr() {
|
|
14
41
|
return {
|
|
15
42
|
type: "string",
|
|
43
|
+
maxLength: MAX_DATABASE_NAME_BYTES,
|
|
16
44
|
default: ""
|
|
17
45
|
};
|
|
18
46
|
}
|
|
47
|
+
function isRecord$1(value) {
|
|
48
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function requireRecord(value, label) {
|
|
51
|
+
if (!isRecord$1(value)) throw new Error(`${label} must be an object`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function requireRecordArray(value, label, maxItems, minItems = 1) {
|
|
55
|
+
if (!Array.isArray(value) || value.length < minItems || value.length > maxItems || !value.every(isRecord$1)) throw new Error(`${label} must contain between ${String(minItems)} and ${String(maxItems)} objects`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function checkKeys(value, blocked, context) {
|
|
59
|
+
if (!value || typeof value !== "object") return;
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
for (const item of value) checkKeys(item, blocked, context);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const [key, child] of Object.entries(value)) {
|
|
65
|
+
if (blocked.has(key)) throw new Error(`Operator "${key}" is not allowed in ${context}`);
|
|
66
|
+
checkKeys(child, blocked, context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function normalizeDatabaseName(value) {
|
|
70
|
+
if (value === void 0 || value === null || value === "") return DEFAULT_DB;
|
|
71
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error("database must be a non-empty string");
|
|
72
|
+
if (Buffer.byteLength(value, "utf8") > MAX_DATABASE_NAME_BYTES || /[\0 /\\."$*<>:|?]/.test(value)) throw new Error("database name is invalid");
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function validateCollectionName(value) {
|
|
76
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error("collection must be a non-empty string");
|
|
77
|
+
if (Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("collection name is invalid");
|
|
78
|
+
if (value === "_audit" || value.startsWith("system.")) throw new Error(`Collection "${value}" is reserved for service internals`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function validateIndexName(value) {
|
|
82
|
+
if (typeof value !== "string" || value.trim().length === 0 || Buffer.byteLength(value, "utf8") > MAX_COLLECTION_NAME_BYTES || value.includes("\0")) throw new Error("indexName is invalid");
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function sanitizeFilter(value) {
|
|
86
|
+
const filter = requireRecord(value, "filter");
|
|
87
|
+
checkKeys(filter, BLOCKED_FILTER_OPERATORS, "filter");
|
|
88
|
+
return filter;
|
|
89
|
+
}
|
|
90
|
+
function sanitizeSort(value) {
|
|
91
|
+
const sort = requireRecord(value, "sort");
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [key, direction] of Object.entries(sort)) {
|
|
94
|
+
if (direction !== 1 && direction !== -1) throw new Error(`Invalid sort value for "${key}": must be 1 or -1`);
|
|
95
|
+
result[key] = direction;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function sanitizePipeline(value) {
|
|
100
|
+
const pipeline = requireRecordArray(value, "pipeline", 50, 0);
|
|
101
|
+
for (const stage of pipeline) for (const [key, child] of Object.entries(stage)) {
|
|
102
|
+
if (BLOCKED_AGGREGATION_STAGES.has(key)) throw new Error(`Aggregation stage "${key}" is not allowed`);
|
|
103
|
+
checkKeys(child, BLOCKED_PIPELINE_KEYS, `pipeline stage "${key}"`);
|
|
104
|
+
}
|
|
105
|
+
return pipeline;
|
|
106
|
+
}
|
|
107
|
+
function boundedInteger(value, fallback, minimum, maximum, label) {
|
|
108
|
+
const normalized = value === void 0 ? fallback : value;
|
|
109
|
+
if (typeof normalized !== "number" || !Number.isInteger(normalized) || normalized < minimum || normalized > maximum) throw new Error(`${label} must be an integer between ${String(minimum)} and ${String(maximum)}`);
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
function serializedBytes(value) {
|
|
113
|
+
const json = JSON.stringify(value, (_key, item) => typeof item === "bigint" ? item.toString() : item);
|
|
114
|
+
return Buffer.byteLength(json, "utf8");
|
|
115
|
+
}
|
|
116
|
+
function assertWithinByteBudget(value, label) {
|
|
117
|
+
if (serializedBytes(value) > 5242880) throw new Error(`${label} exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
|
|
118
|
+
}
|
|
119
|
+
async function collectBounded(cursor, maxDocuments = 200) {
|
|
120
|
+
const results = [];
|
|
121
|
+
let totalBytes = 0;
|
|
122
|
+
for await (const document of cursor) {
|
|
123
|
+
if (results.length >= maxDocuments) throw new Error(`Query returned more than ${String(maxDocuments)} documents`);
|
|
124
|
+
totalBytes += serializedBytes(document);
|
|
125
|
+
if (totalBytes > 5242880) throw new Error(`Query result exceeds the ${String(MAX_RESULT_BYTES)}-byte limit`);
|
|
126
|
+
results.push(document);
|
|
127
|
+
}
|
|
128
|
+
return results;
|
|
129
|
+
}
|
|
130
|
+
function requireDestructiveIntent(filter, allowAll) {
|
|
131
|
+
if (Object.keys(filter).length === 0 && allowAll !== true) throw new Error("An empty filter requires allowAll: true");
|
|
132
|
+
}
|
|
19
133
|
function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
20
134
|
async function db(name) {
|
|
21
135
|
const { mongoClient, databases } = lazyInit ? await lazyInit() : {
|
|
@@ -24,10 +138,14 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
24
138
|
})(),
|
|
25
139
|
databases: _databases
|
|
26
140
|
};
|
|
27
|
-
const dbName = name
|
|
141
|
+
const dbName = normalizeDatabaseName(name);
|
|
28
142
|
if (!databases.includes(dbName)) throw new Error(`Access denied: database "${dbName}" is not in the allowed list`);
|
|
29
143
|
return mongoClient.db(dbName);
|
|
30
144
|
}
|
|
145
|
+
async function collection(database, name) {
|
|
146
|
+
const collectionName = validateCollectionName(name);
|
|
147
|
+
return (await db(database)).collection(collectionName);
|
|
148
|
+
}
|
|
31
149
|
api.registerTool({
|
|
32
150
|
name: "db_find",
|
|
33
151
|
description: "Find documents matching a filter",
|
|
@@ -46,11 +164,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
46
164
|
default: {}
|
|
47
165
|
},
|
|
48
166
|
limit: {
|
|
49
|
-
type: "
|
|
167
|
+
type: "integer",
|
|
168
|
+
minimum: 1,
|
|
169
|
+
maximum: 200,
|
|
50
170
|
default: 20
|
|
51
171
|
},
|
|
52
172
|
skip: {
|
|
53
|
-
type: "
|
|
173
|
+
type: "integer",
|
|
174
|
+
minimum: 0,
|
|
175
|
+
maximum: MAX_SKIP,
|
|
54
176
|
default: 0
|
|
55
177
|
},
|
|
56
178
|
projection: {
|
|
@@ -61,7 +183,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
61
183
|
required: ["collection"]
|
|
62
184
|
},
|
|
63
185
|
execute: async (_id, p) => {
|
|
64
|
-
const
|
|
186
|
+
const limit = boundedInteger(p.limit, 20, 1, 200, "limit");
|
|
187
|
+
const skip = boundedInteger(p.skip, 0, 0, MAX_SKIP, "skip");
|
|
188
|
+
const docs = await collectBounded((await collection(p.database, p.collection)).find(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }).sort(sanitizeSort(p.sort ?? {})).project(requireRecord(p.projection ?? {}, "projection")).skip(skip).limit(limit), limit);
|
|
65
189
|
return {
|
|
66
190
|
documents: docs,
|
|
67
191
|
count: docs.length
|
|
@@ -89,7 +213,12 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
89
213
|
required: ["collection"]
|
|
90
214
|
},
|
|
91
215
|
execute: async (_id, p) => {
|
|
92
|
-
|
|
216
|
+
const doc = await (await collection(p.database, p.collection)).findOne(sanitizeFilter(p.filter ?? {}), {
|
|
217
|
+
projection: requireRecord(p.projection ?? {}, "projection"),
|
|
218
|
+
maxTimeMS: MAX_QUERY_TIME_MS
|
|
219
|
+
});
|
|
220
|
+
assertWithinByteBudget(doc, "Query result");
|
|
221
|
+
return doc ?? { _not_found: true };
|
|
93
222
|
}
|
|
94
223
|
});
|
|
95
224
|
api.registerTool({
|
|
@@ -103,17 +232,20 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
103
232
|
collection: str(),
|
|
104
233
|
documents: {
|
|
105
234
|
type: "array",
|
|
235
|
+
minItems: 1,
|
|
236
|
+
maxItems: 100,
|
|
106
237
|
items: { type: "object" }
|
|
107
238
|
}
|
|
108
239
|
},
|
|
109
240
|
required: ["collection", "documents"]
|
|
110
241
|
},
|
|
111
242
|
execute: async (_id, p) => {
|
|
112
|
-
const docs = p.documents;
|
|
113
|
-
|
|
243
|
+
const docs = requireRecordArray(p.documents, "documents", 100);
|
|
244
|
+
assertWithinByteBudget(docs, "Insert batch");
|
|
245
|
+
const result = await (await collection(p.database, p.collection)).insertMany(docs);
|
|
114
246
|
audit({
|
|
115
|
-
database: p.database
|
|
116
|
-
collection: p.collection,
|
|
247
|
+
database: normalizeDatabaseName(p.database),
|
|
248
|
+
collection: validateCollectionName(p.collection),
|
|
117
249
|
operation: "insert",
|
|
118
250
|
summary: `Inserted ${String(docs.length)} document(s)`
|
|
119
251
|
});
|
|
@@ -137,6 +269,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
137
269
|
upsert: {
|
|
138
270
|
type: "boolean",
|
|
139
271
|
default: false
|
|
272
|
+
},
|
|
273
|
+
allowAll: {
|
|
274
|
+
type: "boolean",
|
|
275
|
+
default: false,
|
|
276
|
+
description: "Must be true when using an empty filter intentionally"
|
|
140
277
|
}
|
|
141
278
|
},
|
|
142
279
|
required: [
|
|
@@ -146,10 +283,15 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
146
283
|
]
|
|
147
284
|
},
|
|
148
285
|
execute: async (_id, p) => {
|
|
149
|
-
const
|
|
286
|
+
const filter = sanitizeFilter(p.filter);
|
|
287
|
+
requireDestructiveIntent(filter, p.allowAll);
|
|
288
|
+
const update = requireRecord(p.update, "update");
|
|
289
|
+
if (Object.keys(update).length === 0) throw new Error("update must not be empty");
|
|
290
|
+
checkKeys(update, BLOCKED_FILTER_OPERATORS, "update");
|
|
291
|
+
const result = await (await collection(p.database, p.collection)).updateMany(filter, update, { upsert: p.upsert === true });
|
|
150
292
|
audit({
|
|
151
|
-
database: p.database
|
|
152
|
-
collection: p.collection,
|
|
293
|
+
database: normalizeDatabaseName(p.database),
|
|
294
|
+
collection: validateCollectionName(p.collection),
|
|
153
295
|
operation: "update",
|
|
154
296
|
summary: `Modified ${String(result.modifiedCount)}`
|
|
155
297
|
});
|
|
@@ -169,15 +311,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
169
311
|
properties: {
|
|
170
312
|
database: optStr(),
|
|
171
313
|
collection: str(),
|
|
172
|
-
filter: { type: "object" }
|
|
314
|
+
filter: { type: "object" },
|
|
315
|
+
allowAll: {
|
|
316
|
+
type: "boolean",
|
|
317
|
+
default: false,
|
|
318
|
+
description: "Must be true when using an empty filter intentionally"
|
|
319
|
+
}
|
|
173
320
|
},
|
|
174
321
|
required: ["collection", "filter"]
|
|
175
322
|
},
|
|
176
323
|
execute: async (_id, p) => {
|
|
177
|
-
const
|
|
324
|
+
const filter = sanitizeFilter(p.filter);
|
|
325
|
+
requireDestructiveIntent(filter, p.allowAll);
|
|
326
|
+
const result = await (await collection(p.database, p.collection)).deleteMany(filter);
|
|
178
327
|
audit({
|
|
179
|
-
database: p.database
|
|
180
|
-
collection: p.collection,
|
|
328
|
+
database: normalizeDatabaseName(p.database),
|
|
329
|
+
collection: validateCollectionName(p.collection),
|
|
181
330
|
operation: "delete",
|
|
182
331
|
summary: `Deleted ${String(result.deletedCount)}`
|
|
183
332
|
});
|
|
@@ -201,7 +350,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
201
350
|
required: ["collection"]
|
|
202
351
|
},
|
|
203
352
|
execute: async (_id, p) => {
|
|
204
|
-
return { count: await (await
|
|
353
|
+
return { count: await (await collection(p.database, p.collection)).countDocuments(sanitizeFilter(p.filter ?? {}), { maxTimeMS: MAX_QUERY_TIME_MS }) };
|
|
205
354
|
}
|
|
206
355
|
});
|
|
207
356
|
api.registerTool({
|
|
@@ -215,13 +364,16 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
215
364
|
collection: str(),
|
|
216
365
|
pipeline: {
|
|
217
366
|
type: "array",
|
|
367
|
+
minItems: 0,
|
|
368
|
+
maxItems: 50,
|
|
218
369
|
items: { type: "object" }
|
|
219
370
|
}
|
|
220
371
|
},
|
|
221
372
|
required: ["collection", "pipeline"]
|
|
222
373
|
},
|
|
223
374
|
execute: async (_id, p) => {
|
|
224
|
-
const
|
|
375
|
+
const pipeline = sanitizePipeline(p.pipeline);
|
|
376
|
+
const results = await collectBounded((await collection(p.database, p.collection)).aggregate([...pipeline, { $limit: 201 }], { maxTimeMS: MAX_QUERY_TIME_MS }), 200);
|
|
225
377
|
return {
|
|
226
378
|
results,
|
|
227
379
|
count: results.length
|
|
@@ -250,9 +402,9 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
250
402
|
properties: { database: optStr() }
|
|
251
403
|
},
|
|
252
404
|
execute: async (_id, p) => {
|
|
253
|
-
return { collections: (await (await db(p.database)).listCollections().toArray()).map((
|
|
254
|
-
name:
|
|
255
|
-
type:
|
|
405
|
+
return { collections: (await (await db(p.database)).listCollections({}, { nameOnly: true }).toArray()).filter((item) => item.name !== "_audit" && !item.name.startsWith("system.")).map((item) => ({
|
|
406
|
+
name: item.name,
|
|
407
|
+
type: item.type
|
|
256
408
|
})) };
|
|
257
409
|
}
|
|
258
410
|
});
|
|
@@ -269,10 +421,11 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
269
421
|
required: ["collection"]
|
|
270
422
|
},
|
|
271
423
|
execute: async (_id, p) => {
|
|
272
|
-
|
|
424
|
+
const collectionName = validateCollectionName(p.collection);
|
|
425
|
+
await (await db(p.database)).createCollection(collectionName);
|
|
273
426
|
audit({
|
|
274
|
-
database: p.database
|
|
275
|
-
collection:
|
|
427
|
+
database: normalizeDatabaseName(p.database),
|
|
428
|
+
collection: collectionName,
|
|
276
429
|
operation: "create_collection"
|
|
277
430
|
});
|
|
278
431
|
return { success: true };
|
|
@@ -286,15 +439,22 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
286
439
|
type: "object",
|
|
287
440
|
properties: {
|
|
288
441
|
database: optStr(),
|
|
289
|
-
collection: str()
|
|
442
|
+
collection: str(),
|
|
443
|
+
confirm: {
|
|
444
|
+
type: "boolean",
|
|
445
|
+
const: true,
|
|
446
|
+
description: "Must be true to confirm permanent collection deletion"
|
|
447
|
+
}
|
|
290
448
|
},
|
|
291
|
-
required: ["collection"]
|
|
449
|
+
required: ["collection", "confirm"]
|
|
292
450
|
},
|
|
293
451
|
execute: async (_id, p) => {
|
|
294
|
-
|
|
452
|
+
if (p.confirm !== true) throw new Error("Dropping a collection requires confirm: true");
|
|
453
|
+
const collectionName = validateCollectionName(p.collection);
|
|
454
|
+
await (await db(p.database)).dropCollection(collectionName);
|
|
295
455
|
audit({
|
|
296
|
-
database: p.database
|
|
297
|
-
collection:
|
|
456
|
+
database: normalizeDatabaseName(p.database),
|
|
457
|
+
collection: collectionName,
|
|
298
458
|
operation: "drop_collection"
|
|
299
459
|
});
|
|
300
460
|
return { success: true };
|
|
@@ -313,7 +473,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
313
473
|
required: ["collection"]
|
|
314
474
|
},
|
|
315
475
|
execute: async (_id, p) => {
|
|
316
|
-
const sample = await (await
|
|
476
|
+
const sample = await collectBounded((await collection(p.database, p.collection)).find({}, { maxTimeMS: MAX_QUERY_TIME_MS }).limit(100), 100);
|
|
317
477
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
318
478
|
for (const doc of sample) for (const [key, value] of Object.entries(doc)) {
|
|
319
479
|
const types = fieldTypes.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -342,8 +502,8 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
342
502
|
required: ["collection"]
|
|
343
503
|
},
|
|
344
504
|
execute: async (_id, p) => {
|
|
345
|
-
const coll =
|
|
346
|
-
const count = await coll.countDocuments();
|
|
505
|
+
const coll = await collection(p.database, p.collection);
|
|
506
|
+
const count = await coll.countDocuments({}, { maxTimeMS: MAX_QUERY_TIME_MS });
|
|
347
507
|
const indexes = await coll.indexes();
|
|
348
508
|
return {
|
|
349
509
|
count,
|
|
@@ -373,10 +533,10 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
373
533
|
required: ["collection", "keys"]
|
|
374
534
|
},
|
|
375
535
|
execute: async (_id, p) => {
|
|
376
|
-
const indexName = await (await
|
|
536
|
+
const indexName = await (await collection(p.database, p.collection)).createIndex(sanitizeSort(p.keys), requireRecord(p.options ?? {}, "options"));
|
|
377
537
|
audit({
|
|
378
|
-
database: p.database
|
|
379
|
-
collection: p.collection,
|
|
538
|
+
database: normalizeDatabaseName(p.database),
|
|
539
|
+
collection: validateCollectionName(p.collection),
|
|
380
540
|
operation: "create_index",
|
|
381
541
|
summary: indexName
|
|
382
542
|
});
|
|
@@ -396,7 +556,7 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
396
556
|
required: ["collection"]
|
|
397
557
|
},
|
|
398
558
|
execute: async (_id, p) => {
|
|
399
|
-
return { indexes: (await (await
|
|
559
|
+
return { indexes: (await (await collection(p.database, p.collection)).indexes()).map((i) => ({
|
|
400
560
|
name: i.name,
|
|
401
561
|
key: i.key,
|
|
402
562
|
unique: i.unique
|
|
@@ -412,56 +572,107 @@ function registerTools(api, _client, _databases, audit, lazyInit) {
|
|
|
412
572
|
properties: {
|
|
413
573
|
database: optStr(),
|
|
414
574
|
collection: str(),
|
|
415
|
-
indexName: str()
|
|
575
|
+
indexName: str(),
|
|
576
|
+
confirm: {
|
|
577
|
+
type: "boolean",
|
|
578
|
+
const: true,
|
|
579
|
+
description: "Must be true to confirm permanent index deletion"
|
|
580
|
+
}
|
|
416
581
|
},
|
|
417
|
-
required: [
|
|
582
|
+
required: [
|
|
583
|
+
"collection",
|
|
584
|
+
"indexName",
|
|
585
|
+
"confirm"
|
|
586
|
+
]
|
|
418
587
|
},
|
|
419
588
|
execute: async (_id, p) => {
|
|
420
|
-
|
|
589
|
+
if (p.confirm !== true) throw new Error("Dropping an index requires confirm: true");
|
|
590
|
+
const indexName = validateIndexName(p.indexName);
|
|
591
|
+
await (await collection(p.database, p.collection)).dropIndex(indexName);
|
|
421
592
|
audit({
|
|
422
|
-
database: p.database
|
|
423
|
-
collection: p.collection,
|
|
593
|
+
database: normalizeDatabaseName(p.database),
|
|
594
|
+
collection: validateCollectionName(p.collection),
|
|
424
595
|
operation: "drop_index",
|
|
425
|
-
summary:
|
|
596
|
+
summary: indexName
|
|
426
597
|
});
|
|
427
598
|
return { success: true };
|
|
428
599
|
}
|
|
429
600
|
});
|
|
430
601
|
}
|
|
431
602
|
//#endregion
|
|
603
|
+
//#region src/database-credentials.ts
|
|
604
|
+
function isRecord(value) {
|
|
605
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
606
|
+
}
|
|
607
|
+
/** Validate the control-plane credential response before it reaches MongoDB. */
|
|
608
|
+
function parseDatabaseCredentials(value) {
|
|
609
|
+
if (!isRecord(value)) throw new Error("Invalid database credential response");
|
|
610
|
+
const { connectionString, username, password, databases } = value;
|
|
611
|
+
if (typeof connectionString !== "string" || typeof username !== "string" || username.length < 1 || typeof password !== "string" || password.length < 1 || !Array.isArray(databases) || databases.length < 1) throw new Error("Invalid database credential response");
|
|
612
|
+
const normalizedDatabases = [];
|
|
613
|
+
for (const database of databases) {
|
|
614
|
+
if (typeof database !== "string" || database.length < 1) throw new Error("Invalid database credential response");
|
|
615
|
+
normalizedDatabases.push(database);
|
|
616
|
+
}
|
|
617
|
+
const url = new URL(connectionString);
|
|
618
|
+
if (url.protocol !== "mongodb:" && url.protocol !== "mongodb+srv:") throw new Error("Unsupported database connection protocol");
|
|
619
|
+
if (!url.hostname) throw new Error("Invalid database credential response");
|
|
620
|
+
url.username = username;
|
|
621
|
+
url.password = password;
|
|
622
|
+
return {
|
|
623
|
+
connectionString: url.toString(),
|
|
624
|
+
databases: [...new Set(normalizedDatabases)]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
//#endregion
|
|
432
628
|
//#region src/plugin.ts
|
|
433
629
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
434
630
|
let mongoClient = null;
|
|
435
631
|
let initPromise = null;
|
|
632
|
+
let connectionGeneration = 0;
|
|
633
|
+
function errorClass(error) {
|
|
634
|
+
return error instanceof Error && error.name ? error.name : "UnknownError";
|
|
635
|
+
}
|
|
636
|
+
async function closeDatabaseConnection() {
|
|
637
|
+
connectionGeneration += 1;
|
|
638
|
+
initPromise = null;
|
|
639
|
+
const current = mongoClient;
|
|
640
|
+
mongoClient = null;
|
|
641
|
+
if (current) await current.close();
|
|
642
|
+
}
|
|
436
643
|
/**
|
|
437
644
|
* Lazy initializer — fetches credentials and connects to MongoDB on first use.
|
|
438
645
|
* Returns a shared promise so concurrent calls don't duplicate work.
|
|
439
646
|
*/
|
|
440
647
|
function ensureInitialized(client) {
|
|
441
648
|
if (initPromise) return initPromise;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
url.username = credentials.username;
|
|
447
|
-
url.password = credentials.password;
|
|
448
|
-
const mc = new MongoClient(url.toString(), {
|
|
649
|
+
const generation = connectionGeneration;
|
|
650
|
+
const pending = (async () => {
|
|
651
|
+
const credentials = parseDatabaseCredentials(await client.registerDatabaseCredentials());
|
|
652
|
+
const mc = new MongoClient(credentials.connectionString, {
|
|
449
653
|
maxPoolSize: 3,
|
|
450
654
|
minPoolSize: 1,
|
|
451
655
|
serverSelectionTimeoutMS: 5e3
|
|
452
656
|
});
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
mongoClient
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
657
|
+
try {
|
|
658
|
+
await mc.connect();
|
|
659
|
+
if (generation !== connectionGeneration) throw new Error("Database service stopped during initialization");
|
|
660
|
+
mongoClient = mc;
|
|
661
|
+
return {
|
|
662
|
+
mongoClient: mc,
|
|
663
|
+
databases: credentials.databases,
|
|
664
|
+
client
|
|
665
|
+
};
|
|
666
|
+
} catch (error) {
|
|
667
|
+
await mc.close().catch(() => void 0);
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
460
670
|
})();
|
|
461
|
-
initPromise
|
|
462
|
-
|
|
671
|
+
initPromise = pending;
|
|
672
|
+
pending.catch(() => {
|
|
673
|
+
if (initPromise === pending) initPromise = null;
|
|
463
674
|
});
|
|
464
|
-
return
|
|
675
|
+
return pending;
|
|
465
676
|
}
|
|
466
677
|
const ACTIVATED_KEY = "__alfeDatabasePluginActivated";
|
|
467
678
|
const g = globalThis;
|
|
@@ -481,7 +692,7 @@ const plugin = {
|
|
|
481
692
|
apiUrl: config.apiUrl
|
|
482
693
|
});
|
|
483
694
|
} catch (err) {
|
|
484
|
-
log.error(`Database plugin: failed to resolve config
|
|
695
|
+
log.error(`Database plugin: failed to resolve config (${errorClass(err)})`);
|
|
485
696
|
return;
|
|
486
697
|
}
|
|
487
698
|
registerTools(api, null, [], (entry) => {
|
|
@@ -496,16 +707,12 @@ const plugin = {
|
|
|
496
707
|
ensureInitialized(client).then(({ databases }) => {
|
|
497
708
|
log.info(`Database plugin connected — ${String(databases.length)} databases available`);
|
|
498
709
|
}).catch((err) => {
|
|
499
|
-
log.warn(`Database plugin: background init failed
|
|
710
|
+
log.warn(`Database plugin: background init failed; first tool use will retry (${errorClass(err)})`);
|
|
500
711
|
});
|
|
501
712
|
};
|
|
502
713
|
const stopDatabaseService = async () => {
|
|
503
714
|
g[ACTIVATED_KEY] = false;
|
|
504
|
-
|
|
505
|
-
await mongoClient.close();
|
|
506
|
-
mongoClient = null;
|
|
507
|
-
initPromise = null;
|
|
508
|
-
}
|
|
715
|
+
await closeDatabaseConnection();
|
|
509
716
|
log.info("Database plugin deactivated");
|
|
510
717
|
};
|
|
511
718
|
api.registerService({
|
|
@@ -521,11 +728,7 @@ const plugin = {
|
|
|
521
728
|
async deactivate(api) {
|
|
522
729
|
g[ACTIVATED_KEY] = false;
|
|
523
730
|
api.logger.info("Database plugin deactivating...");
|
|
524
|
-
|
|
525
|
-
await mongoClient.close();
|
|
526
|
-
mongoClient = null;
|
|
527
|
-
initPromise = null;
|
|
528
|
-
}
|
|
731
|
+
await closeDatabaseConnection();
|
|
529
732
|
}
|
|
530
733
|
};
|
|
531
734
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/openclaw-database",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.45",
|
|
4
4
|
"description": "OpenClaw database plugin — MongoDB access for agents via MCP tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/plugin.js",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"mongodb": "^6.12.0",
|
|
31
|
-
"@alfe.ai/agent-api-client": "0.
|
|
32
|
-
"@alfe.ai/config": "0.4.
|
|
31
|
+
"@alfe.ai/agent-api-client": "0.16.0",
|
|
32
|
+
"@alfe.ai/config": "0.4.1"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"openclaw": ">=2026.3.0"
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsdown",
|
|
55
55
|
"dev": "tsdown --watch",
|
|
56
|
+
"test": "vitest run",
|
|
56
57
|
"typecheck": "tsc --noEmit",
|
|
57
58
|
"lint": "eslint ."
|
|
58
59
|
}
|