@bridgekitux/agent 0.1.0
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 +17 -0
- package/dist/src/audit.d.ts +18 -0
- package/dist/src/audit.d.ts.map +1 -0
- package/dist/src/audit.js +30 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +173 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +24 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +66 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/handlers.d.ts +87 -0
- package/dist/src/handlers.d.ts.map +1 -0
- package/dist/src/handlers.js +1143 -0
- package/dist/src/handlers.js.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/path-scope.d.ts +2 -0
- package/dist/src/path-scope.d.ts.map +1 -0
- package/dist/src/path-scope.js +12 -0
- package/dist/src/path-scope.js.map +1 -0
- package/dist/src/resources.d.ts +74 -0
- package/dist/src/resources.d.ts.map +1 -0
- package/dist/src/resources.js +92 -0
- package/dist/src/resources.js.map +1 -0
- package/dist/src/server.d.ts +54 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +291 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/state.d.ts +59 -0
- package/dist/src/state.d.ts.map +1 -0
- package/dist/src/state.js +98 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/vault.d.ts +31 -0
- package/dist/src/vault.d.ts.map +1 -0
- package/dist/src/vault.js +69 -0
- package/dist/src/vault.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { request as httpRequest } from 'node:http';
|
|
5
|
+
import { request as httpsRequest } from 'node:https';
|
|
6
|
+
import { createConnection } from 'node:net';
|
|
7
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { URL } from 'node:url';
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import { WebSocket as NodeWebSocket } from 'ws';
|
|
13
|
+
import { bridgeKitError, isRecord } from '@bridgekitux/protocol';
|
|
14
|
+
import { resolveInside } from './path-scope.js';
|
|
15
|
+
function requireRecord(value) {
|
|
16
|
+
if (!isRecord(value))
|
|
17
|
+
throw bridgeKitError('invalid_request', 'Capability args must be an object');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function requireString(value, name) {
|
|
21
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
22
|
+
throw bridgeKitError('invalid_request', `${name} must be a non-empty string`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function isWriteSql(sql) {
|
|
26
|
+
const trimmed = sql.trim().replace(/^\/\*[\s\S]*?\*\//, '').toLowerCase();
|
|
27
|
+
return !/^(select|show|describe|desc|explain|with)\b/.test(trimmed);
|
|
28
|
+
}
|
|
29
|
+
export function operationForInvoke(payload) {
|
|
30
|
+
if (payload.operation)
|
|
31
|
+
return payload.operation;
|
|
32
|
+
const args = isRecord(payload.args) ? payload.args : {};
|
|
33
|
+
switch (payload.capability) {
|
|
34
|
+
case 'db.connect':
|
|
35
|
+
return 'connect';
|
|
36
|
+
case 'db.raw':
|
|
37
|
+
return 'full';
|
|
38
|
+
case 'db.query': {
|
|
39
|
+
const sql = typeof args.sql === 'string' ? args.sql : typeof args.text === 'string' ? args.text : '';
|
|
40
|
+
return sql && isWriteSql(sql) ? 'query:write' : 'query:read';
|
|
41
|
+
}
|
|
42
|
+
case 'db.inspectSchema':
|
|
43
|
+
case 'db.listTables':
|
|
44
|
+
case 'db.listCollections':
|
|
45
|
+
return 'schema:read';
|
|
46
|
+
case 'db.runMigration':
|
|
47
|
+
return 'migration:run';
|
|
48
|
+
case 'db.close':
|
|
49
|
+
return 'close';
|
|
50
|
+
case 'http.request':
|
|
51
|
+
case 'network.request':
|
|
52
|
+
return 'request';
|
|
53
|
+
case 'http.raw':
|
|
54
|
+
return 'full';
|
|
55
|
+
case 'tcp.connect':
|
|
56
|
+
return 'connect';
|
|
57
|
+
case 'tcp.stream':
|
|
58
|
+
return 'stream';
|
|
59
|
+
case 'websocket.connect':
|
|
60
|
+
return 'connect';
|
|
61
|
+
case 'http.stream':
|
|
62
|
+
return 'stream';
|
|
63
|
+
case 'fs.watch':
|
|
64
|
+
return 'watch';
|
|
65
|
+
case 'logs.subscribe':
|
|
66
|
+
return 'subscribe';
|
|
67
|
+
case 'process.logs':
|
|
68
|
+
return 'logs';
|
|
69
|
+
default:
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function invokeCapability(ctx, payload) {
|
|
74
|
+
const resourceId = payload.resourceId;
|
|
75
|
+
if (!resourceId && payload.capability !== 'secret.list') {
|
|
76
|
+
throw bridgeKitError('invalid_request', 'resourceId is required for this capability');
|
|
77
|
+
}
|
|
78
|
+
if (payload.capability.startsWith('db.'))
|
|
79
|
+
return handleDatabase(ctx, await ctx.registry.get(resourceId), payload);
|
|
80
|
+
if (payload.capability.startsWith('fs.'))
|
|
81
|
+
return handleFile(await ctx.registry.get(resourceId), payload);
|
|
82
|
+
if (payload.capability.startsWith('secret.'))
|
|
83
|
+
return handleSecret(ctx, payload);
|
|
84
|
+
if (payload.capability === 'http.request' || payload.capability === 'http.raw' || payload.capability === 'network.request')
|
|
85
|
+
return handleHttp(await ctx.registry.get(resourceId), payload);
|
|
86
|
+
if (payload.capability === 'tcp.connect')
|
|
87
|
+
return handleTcp(await ctx.registry.get(resourceId), payload);
|
|
88
|
+
if (payload.capability.startsWith('port.'))
|
|
89
|
+
return handlePort(ctx, await ctx.registry.get(resourceId), payload);
|
|
90
|
+
if (payload.capability.startsWith('process.'))
|
|
91
|
+
return handleProcess(ctx, await ctx.registry.get(resourceId), payload);
|
|
92
|
+
if (payload.capability.startsWith('docker.'))
|
|
93
|
+
throw bridgeKitError('capability_not_implemented', 'Docker support is documented as post-MVP and is intentionally not enabled by default');
|
|
94
|
+
throw bridgeKitError('capability_not_supported', `Unsupported capability: ${payload.capability}`);
|
|
95
|
+
}
|
|
96
|
+
export async function subscribeCapability(ctx, payload, emit) {
|
|
97
|
+
const resource = await ctx.registry.get(payload.resourceId);
|
|
98
|
+
switch (payload.capability) {
|
|
99
|
+
case 'fs.watch':
|
|
100
|
+
return subscribeFileWatch(resource, payload, emit);
|
|
101
|
+
case 'http.stream':
|
|
102
|
+
return subscribeHttpStream(resource, payload, emit);
|
|
103
|
+
case 'tcp.stream':
|
|
104
|
+
return subscribeTcpStream(resource, payload, emit);
|
|
105
|
+
case 'websocket.connect':
|
|
106
|
+
return subscribeWebSocket(resource, payload, emit);
|
|
107
|
+
case 'logs.subscribe':
|
|
108
|
+
return subscribeLogs(ctx, resource, payload, emit);
|
|
109
|
+
case 'process.logs':
|
|
110
|
+
return subscribeProcessLogs(ctx, resource, emit);
|
|
111
|
+
default:
|
|
112
|
+
throw bridgeKitError('capability_not_supported', `Unsupported streaming capability: ${payload.capability}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function subscribeFileWatch(resource, payload, emit) {
|
|
116
|
+
if (resource.kind !== 'file')
|
|
117
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a file resource`);
|
|
118
|
+
const args = requireRecord(payload.args ?? {});
|
|
119
|
+
const file = resource;
|
|
120
|
+
const requestedPath = typeof args.path === 'string' ? args.path : '.';
|
|
121
|
+
const path = resolveInside(file.rootPath, requestedPath);
|
|
122
|
+
const recursive = Boolean(args.recursive);
|
|
123
|
+
const includeInitial = Boolean(args.includeInitial);
|
|
124
|
+
const debounceMs = typeof args.debounceMs === 'number' && Number.isFinite(args.debounceMs) && args.debounceMs > 0 ? Math.min(args.debounceMs, 5_000) : 0;
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const pending = new Map();
|
|
127
|
+
emit({
|
|
128
|
+
event: 'fs.ready',
|
|
129
|
+
resourceId: resource.id,
|
|
130
|
+
capability: 'fs.watch',
|
|
131
|
+
data: {
|
|
132
|
+
path: requestedPath,
|
|
133
|
+
recursive,
|
|
134
|
+
includeInitial,
|
|
135
|
+
timestamp: new Date().toISOString()
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (includeInitial) {
|
|
139
|
+
void listSnapshot(path, requestedPath, recursive).then((entries) => {
|
|
140
|
+
emit({
|
|
141
|
+
event: 'fs.snapshot',
|
|
142
|
+
resourceId: resource.id,
|
|
143
|
+
capability: 'fs.watch',
|
|
144
|
+
data: {
|
|
145
|
+
path: requestedPath,
|
|
146
|
+
recursive,
|
|
147
|
+
entries,
|
|
148
|
+
timestamp: new Date().toISOString()
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}).catch((error) => {
|
|
152
|
+
emit({
|
|
153
|
+
event: 'fs.error',
|
|
154
|
+
resourceId: resource.id,
|
|
155
|
+
capability: 'fs.watch',
|
|
156
|
+
data: { message: error instanceof Error ? error.message : 'Unknown snapshot error', timestamp: new Date().toISOString() }
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const emitChange = (eventType, filename) => {
|
|
161
|
+
const relativePath = filename ? join(requestedPath, filename).replace(/\\/g, '/') : requestedPath;
|
|
162
|
+
emit({
|
|
163
|
+
event: 'fs.change',
|
|
164
|
+
resourceId: resource.id,
|
|
165
|
+
capability: 'fs.watch',
|
|
166
|
+
data: {
|
|
167
|
+
eventType,
|
|
168
|
+
filename,
|
|
169
|
+
path: requestedPath,
|
|
170
|
+
relativePath,
|
|
171
|
+
recursive,
|
|
172
|
+
timestamp: new Date().toISOString()
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const watcher = watch(path, { recursive, signal: controller.signal }, (eventType, filename) => {
|
|
177
|
+
const normalized = filename?.toString() ?? null;
|
|
178
|
+
if (debounceMs === 0) {
|
|
179
|
+
emitChange(eventType, normalized);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const key = `${eventType}:${normalized ?? ''}`;
|
|
183
|
+
const previous = pending.get(key);
|
|
184
|
+
if (previous)
|
|
185
|
+
clearTimeout(previous);
|
|
186
|
+
pending.set(key, setTimeout(() => {
|
|
187
|
+
pending.delete(key);
|
|
188
|
+
emitChange(eventType, normalized);
|
|
189
|
+
}, debounceMs));
|
|
190
|
+
});
|
|
191
|
+
watcher.on('error', (error) => {
|
|
192
|
+
emit({
|
|
193
|
+
event: 'fs.error',
|
|
194
|
+
resourceId: resource.id,
|
|
195
|
+
capability: 'fs.watch',
|
|
196
|
+
data: { message: error.message, timestamp: new Date().toISOString() }
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
watcher.unref?.();
|
|
200
|
+
return {
|
|
201
|
+
close: () => {
|
|
202
|
+
for (const timer of pending.values())
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
pending.clear();
|
|
205
|
+
controller.abort();
|
|
206
|
+
watcher.close();
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function listSnapshot(root, requestedPath, recursive) {
|
|
211
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
212
|
+
const result = [];
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const absolute = join(root, entry.name);
|
|
215
|
+
const entryPath = join(requestedPath, entry.name).replace(/\\/g, '/');
|
|
216
|
+
const type = entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other';
|
|
217
|
+
result.push({ path: entryPath, type });
|
|
218
|
+
if (recursive && entry.isDirectory()) {
|
|
219
|
+
result.push(...await listSnapshot(absolute, entryPath, true));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
async function handleFile(resource, payload) {
|
|
225
|
+
if (resource.kind !== 'file')
|
|
226
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a file resource`);
|
|
227
|
+
const file = resource;
|
|
228
|
+
const args = requireRecord(payload.args ?? {});
|
|
229
|
+
const path = resolveInside(file.rootPath, typeof args.path === 'string' ? args.path : '.');
|
|
230
|
+
switch (payload.capability) {
|
|
231
|
+
case 'fs.read': {
|
|
232
|
+
const encoding = typeof args.encoding === 'string' ? args.encoding : 'utf8';
|
|
233
|
+
return { path: args.path ?? '.', content: await readFile(path, encoding) };
|
|
234
|
+
}
|
|
235
|
+
case 'fs.write': {
|
|
236
|
+
const content = typeof args.content === 'string' ? args.content : undefined;
|
|
237
|
+
if (content === undefined)
|
|
238
|
+
throw bridgeKitError('invalid_request', 'fs.write requires string content');
|
|
239
|
+
await mkdir(dirname(path), { recursive: true });
|
|
240
|
+
await writeFile(path, content, typeof args.encoding === 'string' ? { encoding: args.encoding } : undefined);
|
|
241
|
+
return { path: args.path ?? '.', bytes: Buffer.byteLength(content) };
|
|
242
|
+
}
|
|
243
|
+
case 'fs.list': {
|
|
244
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
245
|
+
return { path: args.path ?? '.', entries: entries.map((entry) => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other' })) };
|
|
246
|
+
}
|
|
247
|
+
case 'fs.mkdir':
|
|
248
|
+
await mkdir(path, { recursive: true });
|
|
249
|
+
return { path: args.path ?? '.', created: true };
|
|
250
|
+
case 'fs.delete':
|
|
251
|
+
await rm(path, { recursive: Boolean(args.recursive), force: Boolean(args.force) });
|
|
252
|
+
return { path: args.path ?? '.', deleted: true };
|
|
253
|
+
case 'fs.watch':
|
|
254
|
+
throw bridgeKitError('invalid_request', 'fs.watch requires the subscribe API instead of invoke');
|
|
255
|
+
default:
|
|
256
|
+
throw bridgeKitError('capability_not_supported', `Unsupported file capability: ${payload.capability}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function handleDatabase(_ctx, resource, payload) {
|
|
260
|
+
if (resource.kind !== 'database')
|
|
261
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a database resource`);
|
|
262
|
+
const database = resource;
|
|
263
|
+
switch (payload.capability) {
|
|
264
|
+
case 'db.connect':
|
|
265
|
+
return { resourceId: database.id, type: database.databaseType, connected: true };
|
|
266
|
+
case 'db.raw':
|
|
267
|
+
return rawDatabase(database, payload.args);
|
|
268
|
+
case 'db.query':
|
|
269
|
+
case 'db.runMigration':
|
|
270
|
+
return queryDatabase(database, payload.args);
|
|
271
|
+
case 'db.inspectSchema':
|
|
272
|
+
case 'db.listTables':
|
|
273
|
+
case 'db.listCollections':
|
|
274
|
+
return inspectDatabase(database);
|
|
275
|
+
case 'db.close':
|
|
276
|
+
return { closed: true };
|
|
277
|
+
default:
|
|
278
|
+
throw bridgeKitError('capability_not_supported', `Unsupported database capability: ${payload.capability}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function queryDatabase(resource, args = {}) {
|
|
282
|
+
switch (resource.databaseType) {
|
|
283
|
+
case 'sqlite': {
|
|
284
|
+
if (!resource.filePath)
|
|
285
|
+
throw bridgeKitError('invalid_resource', 'SQLite resource requires filePath');
|
|
286
|
+
const sql = requireString(args.sql ?? args.text, 'sql');
|
|
287
|
+
const db = new DatabaseSync(resource.filePath);
|
|
288
|
+
try {
|
|
289
|
+
const statement = db.prepare(sql);
|
|
290
|
+
const params = args.params ?? args.values ?? [];
|
|
291
|
+
if (isWriteSql(sql)) {
|
|
292
|
+
const result = statement.run(...params);
|
|
293
|
+
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid?.toString() };
|
|
294
|
+
}
|
|
295
|
+
return { rows: statement.all(...params) };
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
db.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
case 'postgres': {
|
|
302
|
+
const { Pool } = await import('pg');
|
|
303
|
+
const pool = new Pool({ connectionString: resource.connectionString });
|
|
304
|
+
try {
|
|
305
|
+
const result = await pool.query(requireString(args.sql ?? args.text, 'sql'), args.params ?? args.values);
|
|
306
|
+
return { rows: result.rows, rowCount: result.rowCount, fields: result.fields.map((field) => ({ name: field.name, dataTypeID: field.dataTypeID })) };
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
await pool.end();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
case 'mysql': {
|
|
313
|
+
const mysql = await import('mysql2/promise');
|
|
314
|
+
const pool = mysql.createPool(resource.connectionString);
|
|
315
|
+
try {
|
|
316
|
+
const [rows, fields] = await pool.query(requireString(args.sql ?? args.text, 'sql'), args.params ?? args.values);
|
|
317
|
+
return { rows, fields };
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await pool.end();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
case 'mongodb': {
|
|
324
|
+
const { MongoClient } = await import('mongodb');
|
|
325
|
+
const client = new MongoClient(resource.connectionString);
|
|
326
|
+
try {
|
|
327
|
+
await client.connect();
|
|
328
|
+
const db = client.db(resource.database);
|
|
329
|
+
const collectionName = requireString(args.collection, 'collection');
|
|
330
|
+
const collection = db.collection(collectionName);
|
|
331
|
+
switch (args.action ?? 'find') {
|
|
332
|
+
case 'find':
|
|
333
|
+
return { documents: await collection.find(args.filter ?? {}, args.options ?? {}).toArray() };
|
|
334
|
+
case 'findOne':
|
|
335
|
+
return { document: await collection.findOne(args.filter ?? {}, args.options ?? {}) };
|
|
336
|
+
case 'countDocuments':
|
|
337
|
+
return { count: await collection.countDocuments(args.filter ?? {}, args.options ?? {}) };
|
|
338
|
+
case 'distinct':
|
|
339
|
+
return { values: await collection.distinct(requireString(args.key, 'key'), args.filter ?? {}, args.options ?? {}) };
|
|
340
|
+
case 'aggregate':
|
|
341
|
+
return { documents: await collection.aggregate(args.pipeline ?? [], args.options ?? {}).toArray() };
|
|
342
|
+
case 'insertOne':
|
|
343
|
+
return await collection.insertOne(args.document ?? {}, args.options ?? {});
|
|
344
|
+
case 'insertMany':
|
|
345
|
+
return await collection.insertMany(args.documents ?? [], args.options ?? {});
|
|
346
|
+
case 'updateOne':
|
|
347
|
+
return await collection.updateOne(args.filter ?? {}, args.update ?? {}, args.options ?? {});
|
|
348
|
+
case 'updateMany':
|
|
349
|
+
return await collection.updateMany(args.filter ?? {}, args.update ?? {}, args.options ?? {});
|
|
350
|
+
case 'replaceOne':
|
|
351
|
+
return await collection.replaceOne(args.filter ?? {}, args.replacement ?? {}, args.options ?? {});
|
|
352
|
+
case 'deleteOne':
|
|
353
|
+
return await collection.deleteOne(args.filter ?? {}, args.options ?? {});
|
|
354
|
+
case 'deleteMany':
|
|
355
|
+
return await collection.deleteMany(args.filter ?? {}, args.options ?? {});
|
|
356
|
+
default:
|
|
357
|
+
throw bridgeKitError('invalid_request', `Unsupported MongoDB action: ${args.action}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
await client.close();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
case 'sqlserver': {
|
|
365
|
+
const mssql = await import('mssql');
|
|
366
|
+
const pool = await mssql.connect(resource.connectionString);
|
|
367
|
+
try {
|
|
368
|
+
const result = await pool.request().query(requireString(args.sql ?? args.text, 'sql'));
|
|
369
|
+
return { rows: result.recordset, rowsAffected: result.rowsAffected };
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await pool.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
throw bridgeKitError('capability_not_supported', `Unsupported database type: ${resource.databaseType}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function rawDatabase(resource, args = {}) {
|
|
380
|
+
if (resource.databaseType !== 'mongodb') {
|
|
381
|
+
return queryDatabase(resource, args);
|
|
382
|
+
}
|
|
383
|
+
const { MongoClient } = await import('mongodb');
|
|
384
|
+
const client = new MongoClient(resource.connectionString);
|
|
385
|
+
try {
|
|
386
|
+
await client.connect();
|
|
387
|
+
const db = client.db(resource.database);
|
|
388
|
+
if (isRecord(args.command)) {
|
|
389
|
+
return await db.command(args.command);
|
|
390
|
+
}
|
|
391
|
+
const collectionName = requireString(args.collection, 'collection');
|
|
392
|
+
const action = requireString(args.action, 'action');
|
|
393
|
+
const collection = db.collection(collectionName);
|
|
394
|
+
if (typeof collection[action] !== 'function')
|
|
395
|
+
throw bridgeKitError('invalid_request', `Unsupported MongoDB raw collection method: ${action}`);
|
|
396
|
+
const rawArgs = Array.isArray(args.args) ? args.args : [];
|
|
397
|
+
const result = await collection[action](...rawArgs);
|
|
398
|
+
if (result && typeof result.toArray === 'function')
|
|
399
|
+
return { documents: await result.toArray() };
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await client.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function inspectDatabase(resource) {
|
|
407
|
+
switch (resource.databaseType) {
|
|
408
|
+
case 'sqlite': {
|
|
409
|
+
const db = new DatabaseSync(resource.filePath);
|
|
410
|
+
try {
|
|
411
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
|
|
412
|
+
return { tables: tables.map((table) => table.name) };
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
db.close();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
case 'postgres':
|
|
419
|
+
return queryDatabase(resource, { sql: "SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name" });
|
|
420
|
+
case 'mysql':
|
|
421
|
+
return queryDatabase(resource, { sql: 'SHOW TABLES' });
|
|
422
|
+
case 'mongodb': {
|
|
423
|
+
const { MongoClient } = await import('mongodb');
|
|
424
|
+
const client = new MongoClient(resource.connectionString);
|
|
425
|
+
try {
|
|
426
|
+
await client.connect();
|
|
427
|
+
const collections = await client.db(resource.database).listCollections().toArray();
|
|
428
|
+
return { collections: collections.map((collection) => collection.name) };
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
await client.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
case 'sqlserver':
|
|
435
|
+
return queryDatabase(resource, { sql: "SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME" });
|
|
436
|
+
default:
|
|
437
|
+
throw bridgeKitError('capability_not_supported', `Unsupported database type: ${resource.databaseType}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async function handleSecret(ctx, payload) {
|
|
441
|
+
const args = requireRecord(payload.args ?? {});
|
|
442
|
+
const id = payload.resourceId ?? (typeof args.id === 'string' ? args.id : undefined);
|
|
443
|
+
switch (payload.capability) {
|
|
444
|
+
case 'secret.create':
|
|
445
|
+
return ctx.vault.create(requireString(id, 'resourceId'), requireString(args.value, 'value'));
|
|
446
|
+
case 'secret.resolve':
|
|
447
|
+
return { id: requireString(id, 'resourceId'), value: await ctx.vault.resolve(requireString(id, 'resourceId')) };
|
|
448
|
+
case 'secret.list':
|
|
449
|
+
return { secrets: await ctx.vault.list() };
|
|
450
|
+
case 'secret.revoke':
|
|
451
|
+
return ctx.vault.revoke(requireString(id, 'resourceId'));
|
|
452
|
+
default:
|
|
453
|
+
throw bridgeKitError('capability_not_supported', `Unsupported secret capability: ${payload.capability}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function handleHttp(resource, payload) {
|
|
457
|
+
const { url, method, headers, body } = approvedHttpRequest(resource, payload.args, payload.capability === 'http.raw');
|
|
458
|
+
const response = await fetch(url, { method, headers, body });
|
|
459
|
+
const text = await response.text();
|
|
460
|
+
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: text };
|
|
461
|
+
}
|
|
462
|
+
function approvedHttpRequest(resource, argsInput, raw = false) {
|
|
463
|
+
if (resource.kind !== 'http' && resource.kind !== 'network' && resource.kind !== 'service') {
|
|
464
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not an HTTP/network resource`);
|
|
465
|
+
}
|
|
466
|
+
const http = resource;
|
|
467
|
+
const args = requireRecord(argsInput ?? {});
|
|
468
|
+
const method = (typeof args.method === 'string' ? args.method : 'GET').toUpperCase();
|
|
469
|
+
if (!raw && !(http.allowedMethods ?? ['GET']).map((entry) => entry.toUpperCase()).includes(method)) {
|
|
470
|
+
throw bridgeKitError('permission_denied', `Method ${method} is not allowed for ${http.id}`);
|
|
471
|
+
}
|
|
472
|
+
const base = new URL(http.baseUrl);
|
|
473
|
+
const url = typeof args.path === 'string' ? new URL(args.path, base) : new URL(base.toString());
|
|
474
|
+
const scopedBasePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname.replace(/\/$/, '')}/`;
|
|
475
|
+
if (url.origin !== base.origin || (base.pathname !== '/' && !url.pathname.startsWith(scopedBasePath))) {
|
|
476
|
+
throw bridgeKitError('scope_violation', `HTTP path escapes approved base URL for ${http.id}`);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
http,
|
|
480
|
+
url,
|
|
481
|
+
method,
|
|
482
|
+
headers: { ...(http.defaultHeaders ?? {}), ...(isRecord(args.headers) ? args.headers : {}) },
|
|
483
|
+
body: typeof args.body === 'string' ? args.body : undefined
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function subscribeHttpStream(resource, payload, emit) {
|
|
487
|
+
const args = requireRecord(payload.args ?? {});
|
|
488
|
+
const { url, method, headers, body } = approvedHttpRequest(resource, payload.args, payload.operation === 'full' || args.raw === true);
|
|
489
|
+
const eventMode = args.eventMode === 'lines' || args.eventMode === 'sse' ? args.eventMode : 'chunks';
|
|
490
|
+
const idleTimeoutMs = typeof args.idleTimeoutMs === 'number' && Number.isFinite(args.idleTimeoutMs) && args.idleTimeoutMs > 0 ? args.idleTimeoutMs : 0;
|
|
491
|
+
const controller = new AbortController();
|
|
492
|
+
let idleTimer;
|
|
493
|
+
const resetIdleTimer = () => {
|
|
494
|
+
if (idleTimer)
|
|
495
|
+
clearTimeout(idleTimer);
|
|
496
|
+
if (idleTimeoutMs > 0) {
|
|
497
|
+
idleTimer = setTimeout(() => {
|
|
498
|
+
emit({
|
|
499
|
+
event: 'http.error',
|
|
500
|
+
resourceId: resource.id,
|
|
501
|
+
capability: 'http.stream',
|
|
502
|
+
data: { message: `HTTP stream idle timeout after ${idleTimeoutMs}ms`, timestamp: new Date().toISOString() }
|
|
503
|
+
});
|
|
504
|
+
controller.abort();
|
|
505
|
+
}, idleTimeoutMs);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
void (async () => {
|
|
509
|
+
try {
|
|
510
|
+
resetIdleTimer();
|
|
511
|
+
const response = await fetch(url, { method, headers, body, signal: controller.signal });
|
|
512
|
+
emit({
|
|
513
|
+
event: 'http.response',
|
|
514
|
+
resourceId: resource.id,
|
|
515
|
+
capability: 'http.stream',
|
|
516
|
+
data: {
|
|
517
|
+
status: response.status,
|
|
518
|
+
statusText: response.statusText,
|
|
519
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
520
|
+
timestamp: new Date().toISOString()
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
if (!response.body) {
|
|
524
|
+
emit({
|
|
525
|
+
event: 'http.complete',
|
|
526
|
+
resourceId: resource.id,
|
|
527
|
+
capability: 'http.stream',
|
|
528
|
+
data: { bytes: 0, timestamp: new Date().toISOString() }
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const reader = response.body.getReader();
|
|
533
|
+
const decoder = new TextDecoder();
|
|
534
|
+
let bytes = 0;
|
|
535
|
+
let bufferedText = '';
|
|
536
|
+
while (!controller.signal.aborted) {
|
|
537
|
+
const { done, value } = await reader.read();
|
|
538
|
+
if (done)
|
|
539
|
+
break;
|
|
540
|
+
resetIdleTimer();
|
|
541
|
+
bytes += value.byteLength;
|
|
542
|
+
const text = decoder.decode(value, { stream: true });
|
|
543
|
+
emit({
|
|
544
|
+
event: 'http.chunk',
|
|
545
|
+
resourceId: resource.id,
|
|
546
|
+
capability: 'http.stream',
|
|
547
|
+
data: {
|
|
548
|
+
chunk: text,
|
|
549
|
+
bytes: value.byteLength,
|
|
550
|
+
totalBytes: bytes,
|
|
551
|
+
timestamp: new Date().toISOString()
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
if (eventMode === 'lines' || eventMode === 'sse') {
|
|
555
|
+
bufferedText += text;
|
|
556
|
+
const lines = bufferedText.split(/\r?\n/);
|
|
557
|
+
bufferedText = lines.pop() ?? '';
|
|
558
|
+
for (const line of lines) {
|
|
559
|
+
if (eventMode === 'sse' && line.length === 0)
|
|
560
|
+
continue;
|
|
561
|
+
emit({
|
|
562
|
+
event: eventMode === 'sse' ? 'http.sse' : 'http.line',
|
|
563
|
+
resourceId: resource.id,
|
|
564
|
+
capability: 'http.stream',
|
|
565
|
+
data: {
|
|
566
|
+
line,
|
|
567
|
+
data: eventMode === 'sse' && line.startsWith('data:') ? line.slice(5).trimStart() : undefined,
|
|
568
|
+
totalBytes: bytes,
|
|
569
|
+
timestamp: new Date().toISOString()
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const finalText = decoder.decode();
|
|
576
|
+
if (finalText.length > 0) {
|
|
577
|
+
bufferedText += finalText;
|
|
578
|
+
}
|
|
579
|
+
if ((eventMode === 'lines' || eventMode === 'sse') && bufferedText.length > 0) {
|
|
580
|
+
emit({
|
|
581
|
+
event: eventMode === 'sse' ? 'http.sse' : 'http.line',
|
|
582
|
+
resourceId: resource.id,
|
|
583
|
+
capability: 'http.stream',
|
|
584
|
+
data: {
|
|
585
|
+
line: bufferedText,
|
|
586
|
+
data: eventMode === 'sse' && bufferedText.startsWith('data:') ? bufferedText.slice(5).trimStart() : undefined,
|
|
587
|
+
totalBytes: bytes,
|
|
588
|
+
timestamp: new Date().toISOString()
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (idleTimer)
|
|
593
|
+
clearTimeout(idleTimer);
|
|
594
|
+
emit({
|
|
595
|
+
event: 'http.complete',
|
|
596
|
+
resourceId: resource.id,
|
|
597
|
+
capability: 'http.stream',
|
|
598
|
+
data: { bytes, timestamp: new Date().toISOString() }
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (idleTimer)
|
|
603
|
+
clearTimeout(idleTimer);
|
|
604
|
+
if (controller.signal.aborted)
|
|
605
|
+
return;
|
|
606
|
+
emit({
|
|
607
|
+
event: 'http.error',
|
|
608
|
+
resourceId: resource.id,
|
|
609
|
+
capability: 'http.stream',
|
|
610
|
+
data: { message: error instanceof Error ? error.message : 'Unknown HTTP stream error', timestamp: new Date().toISOString() }
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
})();
|
|
614
|
+
return {
|
|
615
|
+
close: () => {
|
|
616
|
+
if (idleTimer)
|
|
617
|
+
clearTimeout(idleTimer);
|
|
618
|
+
controller.abort();
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async function handleTcp(resource, payload) {
|
|
623
|
+
if (resource.kind !== 'tcp')
|
|
624
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a TCP resource`);
|
|
625
|
+
const tcp = resource;
|
|
626
|
+
const args = requireRecord(payload.args ?? {});
|
|
627
|
+
const encoding = typeof args.encoding === 'string' ? args.encoding : 'utf8';
|
|
628
|
+
const timeoutMs = Number(args.timeoutMs ?? tcp.timeoutMs ?? 5_000);
|
|
629
|
+
return await new Promise((resolve, reject) => {
|
|
630
|
+
const chunks = [];
|
|
631
|
+
let settled = false;
|
|
632
|
+
const socket = tcp.tls
|
|
633
|
+
? tlsConnect({ host: tcp.host, port: tcp.port, servername: tcp.host })
|
|
634
|
+
: createConnection({ host: tcp.host, port: tcp.port });
|
|
635
|
+
const settleResolve = (value) => {
|
|
636
|
+
if (settled)
|
|
637
|
+
return;
|
|
638
|
+
settled = true;
|
|
639
|
+
resolve(value);
|
|
640
|
+
};
|
|
641
|
+
const settleReject = (error) => {
|
|
642
|
+
if (settled)
|
|
643
|
+
return;
|
|
644
|
+
settled = true;
|
|
645
|
+
reject(error);
|
|
646
|
+
};
|
|
647
|
+
const timer = setTimeout(() => {
|
|
648
|
+
socket.destroy();
|
|
649
|
+
settleReject(bridgeKitError('tcp_timeout', `TCP connection timed out after ${timeoutMs}ms`));
|
|
650
|
+
}, timeoutMs);
|
|
651
|
+
let wroteOnConnect = false;
|
|
652
|
+
const writeAfterConnect = () => {
|
|
653
|
+
if (wroteOnConnect)
|
|
654
|
+
return;
|
|
655
|
+
wroteOnConnect = true;
|
|
656
|
+
if (args.data !== undefined)
|
|
657
|
+
socket.write(args.data, encoding);
|
|
658
|
+
if (args.end !== false)
|
|
659
|
+
socket.end();
|
|
660
|
+
if (args.data === undefined && args.readUntilClose !== true) {
|
|
661
|
+
clearTimeout(timer);
|
|
662
|
+
socket.end();
|
|
663
|
+
settleResolve({ resourceId: tcp.id, connected: true, tls: Boolean(tcp.tls) });
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
socket.on('connect', () => {
|
|
667
|
+
if (!tcp.tls)
|
|
668
|
+
writeAfterConnect();
|
|
669
|
+
});
|
|
670
|
+
socket.on('secureConnect', writeAfterConnect);
|
|
671
|
+
socket.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
672
|
+
socket.on('error', (error) => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
settleReject(error);
|
|
675
|
+
});
|
|
676
|
+
socket.on('close', () => {
|
|
677
|
+
clearTimeout(timer);
|
|
678
|
+
settleResolve({
|
|
679
|
+
resourceId: tcp.id,
|
|
680
|
+
connected: true,
|
|
681
|
+
tls: Boolean(tcp.tls),
|
|
682
|
+
bytes: chunks.reduce((total, chunk) => total + chunk.length, 0),
|
|
683
|
+
data: Buffer.concat(chunks).toString(encoding)
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
function subscribeTcpStream(resource, payload, emit) {
|
|
689
|
+
if (resource.kind !== 'tcp')
|
|
690
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a TCP resource`);
|
|
691
|
+
const tcp = resource;
|
|
692
|
+
const args = requireRecord(payload.args ?? {});
|
|
693
|
+
const encoding = typeof args.encoding === 'string' ? args.encoding : 'utf8';
|
|
694
|
+
const timeoutMs = Number(args.timeoutMs ?? tcp.timeoutMs ?? 10_000);
|
|
695
|
+
const idleTimeoutMs = typeof args.idleTimeoutMs === 'number' && Number.isFinite(args.idleTimeoutMs) && args.idleTimeoutMs > 0 ? args.idleTimeoutMs : 0;
|
|
696
|
+
const socket = tcp.tls
|
|
697
|
+
? tlsConnect({ host: tcp.host, port: tcp.port, servername: tcp.host })
|
|
698
|
+
: createConnection({ host: tcp.host, port: tcp.port });
|
|
699
|
+
let open = false;
|
|
700
|
+
const connectTimer = setTimeout(() => {
|
|
701
|
+
emit({
|
|
702
|
+
event: 'tcp.error',
|
|
703
|
+
resourceId: resource.id,
|
|
704
|
+
capability: 'tcp.stream',
|
|
705
|
+
data: { message: `TCP stream connection timed out after ${timeoutMs}ms`, timestamp: new Date().toISOString() }
|
|
706
|
+
});
|
|
707
|
+
socket.destroy();
|
|
708
|
+
}, timeoutMs);
|
|
709
|
+
function markOpen() {
|
|
710
|
+
if (open)
|
|
711
|
+
return;
|
|
712
|
+
open = true;
|
|
713
|
+
clearTimeout(connectTimer);
|
|
714
|
+
if (args.noDelay !== false)
|
|
715
|
+
socket.setNoDelay(true);
|
|
716
|
+
if (args.keepAlive === true)
|
|
717
|
+
socket.setKeepAlive(true);
|
|
718
|
+
if (idleTimeoutMs > 0) {
|
|
719
|
+
socket.setTimeout(idleTimeoutMs, () => {
|
|
720
|
+
emit({
|
|
721
|
+
event: 'tcp.timeout',
|
|
722
|
+
resourceId: resource.id,
|
|
723
|
+
capability: 'tcp.stream',
|
|
724
|
+
data: { idleTimeoutMs, timestamp: new Date().toISOString() }
|
|
725
|
+
});
|
|
726
|
+
socket.destroy();
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
emit({
|
|
730
|
+
event: 'tcp.open',
|
|
731
|
+
resourceId: resource.id,
|
|
732
|
+
capability: 'tcp.stream',
|
|
733
|
+
data: { host: tcp.host, port: tcp.port, tls: Boolean(tcp.tls), timestamp: new Date().toISOString() }
|
|
734
|
+
});
|
|
735
|
+
if (args.data !== undefined)
|
|
736
|
+
socket.write(args.data, encoding);
|
|
737
|
+
}
|
|
738
|
+
socket.on('connect', () => {
|
|
739
|
+
if (!tcp.tls)
|
|
740
|
+
markOpen();
|
|
741
|
+
});
|
|
742
|
+
socket.on('secureConnect', markOpen);
|
|
743
|
+
socket.on('data', (chunk) => {
|
|
744
|
+
emit({
|
|
745
|
+
event: 'tcp.data',
|
|
746
|
+
resourceId: resource.id,
|
|
747
|
+
capability: 'tcp.stream',
|
|
748
|
+
data: {
|
|
749
|
+
chunk: chunk.toString(encoding),
|
|
750
|
+
bytes: chunk.length,
|
|
751
|
+
timestamp: new Date().toISOString()
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
socket.on('end', () => {
|
|
756
|
+
emit({
|
|
757
|
+
event: 'tcp.end',
|
|
758
|
+
resourceId: resource.id,
|
|
759
|
+
capability: 'tcp.stream',
|
|
760
|
+
data: { timestamp: new Date().toISOString() }
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
socket.on('close', (hadError) => {
|
|
764
|
+
clearTimeout(connectTimer);
|
|
765
|
+
emit({
|
|
766
|
+
event: 'tcp.close',
|
|
767
|
+
resourceId: resource.id,
|
|
768
|
+
capability: 'tcp.stream',
|
|
769
|
+
data: { hadError, timestamp: new Date().toISOString() }
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
socket.on('error', (error) => {
|
|
773
|
+
clearTimeout(connectTimer);
|
|
774
|
+
emit({
|
|
775
|
+
event: 'tcp.error',
|
|
776
|
+
resourceId: resource.id,
|
|
777
|
+
capability: 'tcp.stream',
|
|
778
|
+
data: { message: error.message, timestamp: new Date().toISOString() }
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
socket.unref?.();
|
|
782
|
+
return {
|
|
783
|
+
close: () => socket.destroy(),
|
|
784
|
+
send: async (sendPayload) => {
|
|
785
|
+
const data = streamSendPayloadToBuffer(sendPayload);
|
|
786
|
+
if (data.length > 0) {
|
|
787
|
+
const flushed = socket.write(data);
|
|
788
|
+
if (!flushed)
|
|
789
|
+
await new Promise((resolve) => socket.once('drain', resolve));
|
|
790
|
+
}
|
|
791
|
+
if (sendPayload.end)
|
|
792
|
+
socket.end();
|
|
793
|
+
return { sent: true, bytes: data.length, queued: socket.writableNeedDrain, ended: Boolean(sendPayload.end) };
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function subscribeWebSocket(resource, payload, emit) {
|
|
798
|
+
const { url, protocols, headers, timeoutMs, initialPayload } = approvedWebSocketRequest(resource, payload.args);
|
|
799
|
+
const socket = new NodeWebSocket(url, protocols.length > 0 ? protocols : undefined, { headers, handshakeTimeout: timeoutMs });
|
|
800
|
+
socket.on('open', () => {
|
|
801
|
+
emit({
|
|
802
|
+
event: 'websocket.open',
|
|
803
|
+
resourceId: resource.id,
|
|
804
|
+
capability: 'websocket.connect',
|
|
805
|
+
data: { url: redactUrl(url), protocol: socket.protocol, timestamp: new Date().toISOString() }
|
|
806
|
+
});
|
|
807
|
+
if (initialPayload)
|
|
808
|
+
socket.send(initialPayload);
|
|
809
|
+
});
|
|
810
|
+
socket.on('message', (data, isBinary) => {
|
|
811
|
+
const buffer = rawWebSocketDataToBuffer(data);
|
|
812
|
+
emit({
|
|
813
|
+
event: 'websocket.message',
|
|
814
|
+
resourceId: resource.id,
|
|
815
|
+
capability: 'websocket.connect',
|
|
816
|
+
data: {
|
|
817
|
+
data: isBinary ? undefined : buffer.toString('utf8'),
|
|
818
|
+
binaryBase64: isBinary ? buffer.toString('base64') : undefined,
|
|
819
|
+
bytes: buffer.length,
|
|
820
|
+
binary: isBinary,
|
|
821
|
+
timestamp: new Date().toISOString()
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
socket.on('close', (code, reason) => {
|
|
826
|
+
emit({
|
|
827
|
+
event: 'websocket.close',
|
|
828
|
+
resourceId: resource.id,
|
|
829
|
+
capability: 'websocket.connect',
|
|
830
|
+
data: { code, reason: reason.toString('utf8'), timestamp: new Date().toISOString() }
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
socket.on('error', (error) => {
|
|
834
|
+
emit({
|
|
835
|
+
event: 'websocket.error',
|
|
836
|
+
resourceId: resource.id,
|
|
837
|
+
capability: 'websocket.connect',
|
|
838
|
+
data: { message: error.message, timestamp: new Date().toISOString() }
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
return {
|
|
842
|
+
close: () => socket.close(),
|
|
843
|
+
send: async (sendPayload) => {
|
|
844
|
+
if (socket.readyState !== NodeWebSocket.OPEN) {
|
|
845
|
+
throw bridgeKitError('stream_not_open', `WebSocket resource ${resource.id} is not open`);
|
|
846
|
+
}
|
|
847
|
+
const data = streamSendPayloadToWire(sendPayload);
|
|
848
|
+
await new Promise((resolve, reject) => {
|
|
849
|
+
socket.send(data, (error) => error ? reject(error) : resolve());
|
|
850
|
+
});
|
|
851
|
+
if (sendPayload.end)
|
|
852
|
+
socket.close();
|
|
853
|
+
return { sent: true, bytes: Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data), ended: Boolean(sendPayload.end) };
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function approvedWebSocketRequest(resource, argsInput) {
|
|
858
|
+
if (resource.kind !== 'websocket')
|
|
859
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a WebSocket resource`);
|
|
860
|
+
const wsResource = resource;
|
|
861
|
+
const args = requireRecord(argsInput ?? {});
|
|
862
|
+
const base = new URL(wsResource.url);
|
|
863
|
+
const url = typeof args.path === 'string' ? new URL(args.path, base) : new URL(base.toString());
|
|
864
|
+
const scopedBasePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname.replace(/\/$/, '')}/`;
|
|
865
|
+
if (url.origin !== base.origin || (base.pathname !== '/' && !url.pathname.startsWith(scopedBasePath))) {
|
|
866
|
+
throw bridgeKitError('scope_violation', `WebSocket path escapes approved URL for ${wsResource.id}`);
|
|
867
|
+
}
|
|
868
|
+
const protocols = Array.isArray(args.protocols) && args.protocols.every((entry) => typeof entry === 'string')
|
|
869
|
+
? args.protocols
|
|
870
|
+
: wsResource.protocols ?? [];
|
|
871
|
+
const headers = { ...(wsResource.defaultHeaders ?? {}), ...(isRecord(args.headers) ? args.headers : {}) };
|
|
872
|
+
const timeoutMs = Number(args.timeoutMs ?? wsResource.timeoutMs ?? 10_000);
|
|
873
|
+
const initialPayload = args.binaryBase64
|
|
874
|
+
? Buffer.from(args.binaryBase64, 'base64')
|
|
875
|
+
: args.data !== undefined
|
|
876
|
+
? args.data
|
|
877
|
+
: args.json !== undefined
|
|
878
|
+
? JSON.stringify(args.json)
|
|
879
|
+
: undefined;
|
|
880
|
+
return { url: url.toString(), protocols, headers, timeoutMs, initialPayload };
|
|
881
|
+
}
|
|
882
|
+
function streamSendPayloadToBuffer(payload) {
|
|
883
|
+
const wire = streamSendPayloadToWire(payload);
|
|
884
|
+
return Buffer.isBuffer(wire) ? wire : Buffer.from(wire, typeof payload.encoding === 'string' ? payload.encoding : 'utf8');
|
|
885
|
+
}
|
|
886
|
+
function streamSendPayloadToWire(payload) {
|
|
887
|
+
if (payload.binaryBase64 !== undefined)
|
|
888
|
+
return Buffer.from(payload.binaryBase64, 'base64');
|
|
889
|
+
if (payload.data !== undefined)
|
|
890
|
+
return payload.data;
|
|
891
|
+
if (payload.json !== undefined)
|
|
892
|
+
return JSON.stringify(payload.json);
|
|
893
|
+
return '';
|
|
894
|
+
}
|
|
895
|
+
function rawWebSocketDataToBuffer(data) {
|
|
896
|
+
if (Buffer.isBuffer(data))
|
|
897
|
+
return data;
|
|
898
|
+
if (Array.isArray(data))
|
|
899
|
+
return Buffer.concat(data.map((entry) => rawWebSocketDataToBuffer(entry)));
|
|
900
|
+
if (data instanceof ArrayBuffer)
|
|
901
|
+
return Buffer.from(data);
|
|
902
|
+
if (ArrayBuffer.isView(data))
|
|
903
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
904
|
+
return Buffer.from(String(data));
|
|
905
|
+
}
|
|
906
|
+
function redactUrl(url) {
|
|
907
|
+
const parsed = new URL(url);
|
|
908
|
+
parsed.username = '';
|
|
909
|
+
parsed.password = '';
|
|
910
|
+
return parsed.toString();
|
|
911
|
+
}
|
|
912
|
+
function pipeProxy(upstreamUrl, incoming, outgoing) {
|
|
913
|
+
const transport = upstreamUrl.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
914
|
+
const request = transport(upstreamUrl, { method: incoming.method, headers: incoming.headers }, (response) => {
|
|
915
|
+
outgoing.writeHead(response.statusCode ?? 502, response.headers);
|
|
916
|
+
response.pipe(outgoing);
|
|
917
|
+
});
|
|
918
|
+
request.on('error', (error) => {
|
|
919
|
+
outgoing.writeHead(502, { 'content-type': 'application/json' });
|
|
920
|
+
outgoing.end(JSON.stringify({ error: error.message }));
|
|
921
|
+
});
|
|
922
|
+
incoming.pipe(request);
|
|
923
|
+
}
|
|
924
|
+
export async function createAgentHttpServer(ctxFactory) {
|
|
925
|
+
return createServer(async (req, res) => {
|
|
926
|
+
try {
|
|
927
|
+
const ctx = ctxFactory();
|
|
928
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? `${ctx.agentHost}:${ctx.agentPort}`}`);
|
|
929
|
+
if (url.pathname === '/health') {
|
|
930
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
931
|
+
res.end(JSON.stringify({ ok: true, service: 'bridgekit-agent' }));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (url.pathname === '/resources') {
|
|
935
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
936
|
+
res.end(JSON.stringify({ resources: await ctx.registry.describe() }));
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const match = url.pathname.match(/^\/_bridgekit\/ports\/([^/]+)(\/.*)?$/);
|
|
940
|
+
if (match) {
|
|
941
|
+
const resource = await ctx.registry.get(decodeURIComponent(match[1]));
|
|
942
|
+
if (resource.kind !== 'port')
|
|
943
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a port resource`);
|
|
944
|
+
const port = resource;
|
|
945
|
+
const upstream = new URL(`${port.protocol ?? 'http'}://${port.host}:${port.port}${match[2] ?? '/'}${url.search}`);
|
|
946
|
+
pipeProxy(upstream, req, res);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
950
|
+
res.end(JSON.stringify({ error: 'not_found' }));
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
954
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
955
|
+
res.end(JSON.stringify({ error: message }));
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
async function handlePort(ctx, resource, payload) {
|
|
960
|
+
if (resource.kind !== 'port')
|
|
961
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a port resource`);
|
|
962
|
+
const port = resource;
|
|
963
|
+
switch (payload.capability) {
|
|
964
|
+
case 'port.forward':
|
|
965
|
+
return { resourceId: port.id, localTarget: `${port.protocol ?? 'http'}://${port.host}:${port.port}`, proxyUrl: `http://${ctx.agentHost}:${ctx.agentPort}/_bridgekit/ports/${encodeURIComponent(port.id)}/` };
|
|
966
|
+
case 'port.status': {
|
|
967
|
+
try {
|
|
968
|
+
await stat(`/proc/${process.pid}`);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// Non-Linux hosts can still report configured status.
|
|
972
|
+
}
|
|
973
|
+
return { resourceId: port.id, host: port.host, port: port.port, configured: true };
|
|
974
|
+
}
|
|
975
|
+
case 'port.close':
|
|
976
|
+
return { resourceId: port.id, closed: true, note: 'Static MVP proxy has no persistent tunnel to close.' };
|
|
977
|
+
default:
|
|
978
|
+
throw bridgeKitError('capability_not_supported', `Unsupported port capability: ${payload.capability}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
export class ProcessManager {
|
|
982
|
+
processes = new Map();
|
|
983
|
+
logs = new Map();
|
|
984
|
+
subscribers = new Map();
|
|
985
|
+
start(id, resource, extraArgs = []) {
|
|
986
|
+
if (this.processes.has(id))
|
|
987
|
+
throw bridgeKitError('process_running', `Process ${id} is already running`);
|
|
988
|
+
const args = [...(resource.args ?? []), ...(resource.allowExtraArgs ? extraArgs : [])];
|
|
989
|
+
const child = spawn(resource.command, args, { cwd: resource.cwd, env: { ...process.env, ...(resource.env ?? {}) }, shell: false });
|
|
990
|
+
this.processes.set(id, child);
|
|
991
|
+
this.logs.set(id, []);
|
|
992
|
+
child.stdout.on('data', (chunk) => this.capture(id, chunk.toString(), 'stdout'));
|
|
993
|
+
child.stderr.on('data', (chunk) => this.capture(id, chunk.toString(), 'stderr'));
|
|
994
|
+
child.on('exit', (code, signal) => {
|
|
995
|
+
this.capture(id, `process exited code=${code ?? 'null'} signal=${signal ?? 'null'}`, 'system');
|
|
996
|
+
this.processes.delete(id);
|
|
997
|
+
});
|
|
998
|
+
return { processId: child.pid, resourceId: id };
|
|
999
|
+
}
|
|
1000
|
+
stop(id) {
|
|
1001
|
+
const child = this.processes.get(id);
|
|
1002
|
+
if (!child)
|
|
1003
|
+
return { stopped: false };
|
|
1004
|
+
child.kill('SIGTERM');
|
|
1005
|
+
this.processes.delete(id);
|
|
1006
|
+
return { stopped: true };
|
|
1007
|
+
}
|
|
1008
|
+
status(id) {
|
|
1009
|
+
const child = this.processes.get(id);
|
|
1010
|
+
return { running: Boolean(child), processId: child?.pid };
|
|
1011
|
+
}
|
|
1012
|
+
getLogs(id) {
|
|
1013
|
+
return { lines: this.logs.get(id) ?? [] };
|
|
1014
|
+
}
|
|
1015
|
+
subscribe(id, listener, replay = true) {
|
|
1016
|
+
const listeners = this.subscribers.get(id) ?? new Set();
|
|
1017
|
+
listeners.add(listener);
|
|
1018
|
+
this.subscribers.set(id, listeners);
|
|
1019
|
+
if (replay) {
|
|
1020
|
+
const lines = this.logs.get(id) ?? [];
|
|
1021
|
+
if (lines.length > 0) {
|
|
1022
|
+
queueMicrotask(() => listener({ stream: 'system', text: lines.join('\n'), lines, timestamp: new Date().toISOString() }));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return () => {
|
|
1026
|
+
const current = this.subscribers.get(id);
|
|
1027
|
+
current?.delete(listener);
|
|
1028
|
+
if (current?.size === 0)
|
|
1029
|
+
this.subscribers.delete(id);
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
capture(id, text, stream) {
|
|
1033
|
+
const lines = this.logs.get(id) ?? [];
|
|
1034
|
+
const nextLines = text.split('\n').filter(Boolean);
|
|
1035
|
+
lines.push(...nextLines);
|
|
1036
|
+
this.logs.set(id, lines.slice(-500));
|
|
1037
|
+
const event = { stream, text, lines: nextLines, timestamp: new Date().toISOString() };
|
|
1038
|
+
for (const listener of this.subscribers.get(id) ?? [])
|
|
1039
|
+
listener(event);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
function subscribeProcessLogs(ctx, resource, emit) {
|
|
1043
|
+
if (resource.kind !== 'process')
|
|
1044
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a process resource`);
|
|
1045
|
+
const unsubscribe = ctx.processManager.subscribe(resource.id, (event) => {
|
|
1046
|
+
emit({
|
|
1047
|
+
event: 'process.log',
|
|
1048
|
+
resourceId: resource.id,
|
|
1049
|
+
capability: 'logs.subscribe',
|
|
1050
|
+
data: event
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
return { close: unsubscribe };
|
|
1054
|
+
}
|
|
1055
|
+
function subscribeLogs(ctx, resource, payload, emit) {
|
|
1056
|
+
if (resource.kind === 'process')
|
|
1057
|
+
return subscribeProcessLogs(ctx, resource, emit);
|
|
1058
|
+
if (resource.kind !== 'log')
|
|
1059
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a log or process resource`);
|
|
1060
|
+
const log = resource;
|
|
1061
|
+
const args = requireRecord(payload.args ?? {});
|
|
1062
|
+
const controller = new AbortController();
|
|
1063
|
+
let offset = 0;
|
|
1064
|
+
async function readNewContent() {
|
|
1065
|
+
try {
|
|
1066
|
+
const info = await stat(log.filePath);
|
|
1067
|
+
if (info.size < offset)
|
|
1068
|
+
offset = 0;
|
|
1069
|
+
if (info.size === offset)
|
|
1070
|
+
return;
|
|
1071
|
+
const length = info.size - offset;
|
|
1072
|
+
const buffer = Buffer.alloc(length);
|
|
1073
|
+
const file = await open(log.filePath, 'r');
|
|
1074
|
+
try {
|
|
1075
|
+
await file.read(buffer, 0, length, offset);
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
await file.close();
|
|
1079
|
+
}
|
|
1080
|
+
offset = info.size;
|
|
1081
|
+
emit({
|
|
1082
|
+
event: 'log.chunk',
|
|
1083
|
+
resourceId: resource.id,
|
|
1084
|
+
capability: 'logs.subscribe',
|
|
1085
|
+
data: { text: buffer.toString('utf8'), bytes: length, timestamp: new Date().toISOString() }
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
emit({
|
|
1090
|
+
event: 'log.error',
|
|
1091
|
+
resourceId: resource.id,
|
|
1092
|
+
capability: 'logs.subscribe',
|
|
1093
|
+
data: { message: error instanceof Error ? error.message : 'Unknown log read error', timestamp: new Date().toISOString() }
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
void (async () => {
|
|
1098
|
+
try {
|
|
1099
|
+
const info = await stat(log.filePath);
|
|
1100
|
+
offset = args.fromStart === true ? 0 : info.size;
|
|
1101
|
+
if (args.fromStart === true)
|
|
1102
|
+
await readNewContent();
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
offset = 0;
|
|
1106
|
+
}
|
|
1107
|
+
})();
|
|
1108
|
+
const watcher = watch(log.filePath, { signal: controller.signal }, () => {
|
|
1109
|
+
void readNewContent();
|
|
1110
|
+
});
|
|
1111
|
+
watcher.on('error', (error) => {
|
|
1112
|
+
emit({
|
|
1113
|
+
event: 'log.error',
|
|
1114
|
+
resourceId: resource.id,
|
|
1115
|
+
capability: 'logs.subscribe',
|
|
1116
|
+
data: { message: error.message, timestamp: new Date().toISOString() }
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
watcher.unref?.();
|
|
1120
|
+
return { close: () => controller.abort() };
|
|
1121
|
+
}
|
|
1122
|
+
async function handleProcess(ctx, resource, payload) {
|
|
1123
|
+
if (resource.kind !== 'process')
|
|
1124
|
+
throw bridgeKitError('resource_type_mismatch', `${resource.id} is not a process resource`);
|
|
1125
|
+
const proc = resource;
|
|
1126
|
+
const args = requireRecord(payload.args ?? {});
|
|
1127
|
+
switch (payload.capability) {
|
|
1128
|
+
case 'process.start':
|
|
1129
|
+
return ctx.processManager.start(proc.id, proc, Array.isArray(args.args) ? args.args.filter((entry) => typeof entry === 'string') : []);
|
|
1130
|
+
case 'process.stop':
|
|
1131
|
+
return ctx.processManager.stop(proc.id);
|
|
1132
|
+
case 'process.restart':
|
|
1133
|
+
ctx.processManager.stop(proc.id);
|
|
1134
|
+
return ctx.processManager.start(proc.id, proc);
|
|
1135
|
+
case 'process.status':
|
|
1136
|
+
return ctx.processManager.status(proc.id);
|
|
1137
|
+
case 'process.logs':
|
|
1138
|
+
return ctx.processManager.getLogs(proc.id);
|
|
1139
|
+
default:
|
|
1140
|
+
throw bridgeKitError('capability_not_supported', `Unsupported process capability: ${payload.capability}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
//# sourceMappingURL=handlers.js.map
|