@mhingston5/conduit 1.0.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/.env.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ConfigService } from './core/config.service.js';
|
|
2
|
+
import { createLogger, loggerStorage } from './core/logger.js';
|
|
3
|
+
import { SocketTransport } from './transport/socket.transport.js';
|
|
4
|
+
import { OpsServer } from './core/ops.server.js';
|
|
5
|
+
import { ConcurrencyService } from './core/concurrency.service.js';
|
|
6
|
+
import { RequestController } from './core/request.controller.js';
|
|
7
|
+
import { GatewayService } from './gateway/gateway.service.js';
|
|
8
|
+
import { SecurityService } from './core/security.service.js';
|
|
9
|
+
import { OtelService } from './core/otel.service.js';
|
|
10
|
+
import { DenoExecutor } from './executors/deno.executor.js';
|
|
11
|
+
import { PyodideExecutor } from './executors/pyodide.executor.js';
|
|
12
|
+
import { IsolateExecutor } from './executors/isolate.executor.js';
|
|
13
|
+
import { ExecutorRegistry } from './core/registries/executor.registry.js';
|
|
14
|
+
import { ExecutionService } from './core/execution.service.js';
|
|
15
|
+
import { buildDefaultMiddleware } from './core/middleware/middleware.builder.js';
|
|
16
|
+
async function main() {
|
|
17
|
+
const configService = new ConfigService();
|
|
18
|
+
const logger = createLogger(configService);
|
|
19
|
+
|
|
20
|
+
const otelService = new OtelService(logger);
|
|
21
|
+
await otelService.start();
|
|
22
|
+
|
|
23
|
+
await loggerStorage.run({ correlationId: 'system' }, async () => {
|
|
24
|
+
const securityService = new SecurityService(logger, configService.get('ipcBearerToken'));
|
|
25
|
+
|
|
26
|
+
const gatewayService = new GatewayService(logger, securityService);
|
|
27
|
+
const upstreams = configService.get('upstreams') || [];
|
|
28
|
+
for (const upstream of upstreams) {
|
|
29
|
+
gatewayService.registerUpstream(upstream);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const executorRegistry = new ExecutorRegistry();
|
|
33
|
+
executorRegistry.register('deno', new DenoExecutor(configService.get('denoMaxPoolSize')));
|
|
34
|
+
executorRegistry.register('python', new PyodideExecutor(configService.get('pyodideMaxPoolSize')));
|
|
35
|
+
|
|
36
|
+
// IsolateExecutor needs gatewayService
|
|
37
|
+
const isolateExecutor = new IsolateExecutor(logger, gatewayService);
|
|
38
|
+
executorRegistry.register('isolate', isolateExecutor);
|
|
39
|
+
|
|
40
|
+
const executionService = new ExecutionService(
|
|
41
|
+
logger,
|
|
42
|
+
configService.get('resourceLimits'),
|
|
43
|
+
gatewayService,
|
|
44
|
+
securityService,
|
|
45
|
+
executorRegistry
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const requestController = new RequestController(
|
|
49
|
+
logger,
|
|
50
|
+
executionService,
|
|
51
|
+
gatewayService,
|
|
52
|
+
buildDefaultMiddleware(securityService)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const opsServer = new OpsServer(logger, configService.all, gatewayService, requestController);
|
|
56
|
+
await opsServer.listen();
|
|
57
|
+
|
|
58
|
+
const concurrencyService = new ConcurrencyService(logger, {
|
|
59
|
+
maxConcurrent: configService.get('maxConcurrent')
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
63
|
+
const port = configService.get('port');
|
|
64
|
+
const address = await transport.listen({ port });
|
|
65
|
+
executionService.ipcAddress = address; // Update IPC address on ExecutionService instead of RequestController
|
|
66
|
+
|
|
67
|
+
// Pre-warm workers
|
|
68
|
+
await requestController.warmup();
|
|
69
|
+
|
|
70
|
+
logger.info('Conduit server started');
|
|
71
|
+
|
|
72
|
+
// Handle graceful shutdown
|
|
73
|
+
const shutdown = async () => {
|
|
74
|
+
logger.info('Shutting down...');
|
|
75
|
+
await Promise.all([
|
|
76
|
+
transport.close(),
|
|
77
|
+
opsServer.close(),
|
|
78
|
+
requestController.shutdown(),
|
|
79
|
+
otelService.shutdown(),
|
|
80
|
+
]);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
process.on('SIGINT', shutdown);
|
|
85
|
+
process.on('SIGTERM', shutdown);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main().catch((err) => {
|
|
90
|
+
console.error('Failed to start Conduit:', err);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
package/src/sdk/index.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { ToolBinding, groupByNamespace } from './tool-binding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates in-memory SDK code from discovered tool bindings.
|
|
5
|
+
* The generated code creates a nested object structure where
|
|
6
|
+
* tools.namespace.method(args) => callTool("namespace__method", args)
|
|
7
|
+
*/
|
|
8
|
+
export class SDKGenerator {
|
|
9
|
+
/**
|
|
10
|
+
* Convert camelCase to snake_case for Python
|
|
11
|
+
*/
|
|
12
|
+
private toSnakeCase(str: string): string {
|
|
13
|
+
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Escape a string for use in generated code
|
|
18
|
+
*/
|
|
19
|
+
private escapeString(str: string): string {
|
|
20
|
+
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate TypeScript SDK code to be injected into Deno sandbox.
|
|
25
|
+
* Creates: tools.namespace.method(args) => __internalCallTool("namespace__method", args)
|
|
26
|
+
* @param bindings Tool bindings to generate SDK for
|
|
27
|
+
* @param allowedTools Optional allowlist for $raw() enforcement
|
|
28
|
+
* @param enableRawFallback Enable $raw() escape hatch (default: true)
|
|
29
|
+
*/
|
|
30
|
+
generateTypeScript(bindings: ToolBinding[], allowedTools?: string[], enableRawFallback = true): string {
|
|
31
|
+
const grouped = groupByNamespace(bindings);
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
|
|
34
|
+
lines.push('// Generated SDK - Do not edit');
|
|
35
|
+
|
|
36
|
+
// Inject allowlist for SDK-level enforcement
|
|
37
|
+
if (allowedTools && allowedTools.length > 0) {
|
|
38
|
+
const normalizedList = allowedTools.map(t => t.replace(/\./g, '__'));
|
|
39
|
+
lines.push(`const __allowedTools = ${JSON.stringify(normalizedList)};`);
|
|
40
|
+
} else {
|
|
41
|
+
lines.push('const __allowedTools = null;');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lines.push('const tools = {');
|
|
45
|
+
|
|
46
|
+
for (const [namespace, tools] of grouped.entries()) {
|
|
47
|
+
// Validate namespace is a valid identifier
|
|
48
|
+
const safeNamespace = this.isValidIdentifier(namespace) ? namespace : `["${this.escapeString(namespace)}"]`;
|
|
49
|
+
|
|
50
|
+
if (this.isValidIdentifier(namespace)) {
|
|
51
|
+
lines.push(` ${namespace}: {`);
|
|
52
|
+
} else {
|
|
53
|
+
lines.push(` "${this.escapeString(namespace)}": {`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const tool of tools) {
|
|
57
|
+
const methodName = this.isValidIdentifier(tool.methodName)
|
|
58
|
+
? tool.methodName
|
|
59
|
+
: `["${this.escapeString(tool.methodName)}"]`;
|
|
60
|
+
|
|
61
|
+
// Add JSDoc if description available
|
|
62
|
+
if (tool.description) {
|
|
63
|
+
lines.push(` /** ${this.escapeString(tool.description)} */`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this.isValidIdentifier(tool.methodName)) {
|
|
67
|
+
lines.push(` async ${tool.methodName}(args) {`);
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(` "${this.escapeString(tool.methodName)}": async function(args) {`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(` return await __internalCallTool("${this.escapeString(tool.name)}", args);`);
|
|
72
|
+
lines.push(` },`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(` },`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add $raw escape hatch with allowlist validation
|
|
79
|
+
if (enableRawFallback) {
|
|
80
|
+
lines.push(` /** Call a tool by its full name (escape hatch for dynamic/unknown tools) */`);
|
|
81
|
+
lines.push(` async $raw(name, args) {`);
|
|
82
|
+
lines.push(` const normalized = name.replace(/\\./g, '__');`);
|
|
83
|
+
lines.push(` if (__allowedTools) {`);
|
|
84
|
+
lines.push(` const allowed = __allowedTools.some(p => {`);
|
|
85
|
+
lines.push(` if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));`);
|
|
86
|
+
lines.push(` return normalized === p;`);
|
|
87
|
+
lines.push(` });`);
|
|
88
|
+
lines.push(` if (!allowed) throw new Error(\`Tool \${name} is not in the allowlist\`);`);
|
|
89
|
+
lines.push(` }`);
|
|
90
|
+
lines.push(` return await __internalCallTool(normalized, args);`);
|
|
91
|
+
lines.push(` },`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lines.push('};');
|
|
95
|
+
lines.push('(globalThis as any).tools = tools;');
|
|
96
|
+
|
|
97
|
+
return lines.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generate Python SDK code to be injected into Pyodide sandbox.
|
|
102
|
+
* Creates: tools.namespace.method(args) => _internal_call_tool("namespace__method", args)
|
|
103
|
+
* @param bindings Tool bindings to generate SDK for
|
|
104
|
+
* @param allowedTools Optional allowlist for raw() enforcement
|
|
105
|
+
* @param enableRawFallback Enable raw() escape hatch (default: true)
|
|
106
|
+
*/
|
|
107
|
+
generatePython(bindings: ToolBinding[], allowedTools?: string[], enableRawFallback = true): string {
|
|
108
|
+
const grouped = groupByNamespace(bindings);
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
lines.push('# Generated SDK - Do not edit');
|
|
112
|
+
|
|
113
|
+
// Inject allowlist for SDK-level enforcement
|
|
114
|
+
if (allowedTools && allowedTools.length > 0) {
|
|
115
|
+
const normalizedList = allowedTools.map(t => t.replace(/\./g, '__'));
|
|
116
|
+
lines.push(`_allowed_tools = ${JSON.stringify(normalizedList)}`);
|
|
117
|
+
} else {
|
|
118
|
+
lines.push('_allowed_tools = None');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push('class _ToolNamespace:');
|
|
123
|
+
lines.push(' def __init__(self, methods):');
|
|
124
|
+
lines.push(' for name, fn in methods.items():');
|
|
125
|
+
lines.push(' setattr(self, name, fn)');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('class _Tools:');
|
|
128
|
+
lines.push(' def __init__(self):');
|
|
129
|
+
|
|
130
|
+
for (const [namespace, tools] of grouped.entries()) {
|
|
131
|
+
const safeNamespace = this.toSnakeCase(namespace);
|
|
132
|
+
const methodsDict: string[] = [];
|
|
133
|
+
|
|
134
|
+
for (const tool of tools) {
|
|
135
|
+
const methodName = this.toSnakeCase(tool.methodName);
|
|
136
|
+
const fullName = tool.name;
|
|
137
|
+
// Use async lambda - Python doesn't have async lambdas natively,
|
|
138
|
+
// so we define methods that return awaitable coroutines
|
|
139
|
+
methodsDict.push(` "${methodName}": lambda args, n="${this.escapeString(fullName)}": _internal_call_tool(n, args)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lines.push(` self.${safeNamespace} = _ToolNamespace({`);
|
|
143
|
+
lines.push(methodsDict.join(',\n'));
|
|
144
|
+
lines.push(` })`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add raw escape hatch with allowlist validation
|
|
148
|
+
if (enableRawFallback) {
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push(' async def raw(self, name, args):');
|
|
151
|
+
lines.push(' """Call a tool by its full name (escape hatch for dynamic/unknown tools)"""');
|
|
152
|
+
lines.push(' normalized = name.replace(".", "__")');
|
|
153
|
+
lines.push(' if _allowed_tools is not None:');
|
|
154
|
+
lines.push(' allowed = any(');
|
|
155
|
+
lines.push(' normalized.startswith(p[:-1]) if p.endswith("__*") else normalized == p');
|
|
156
|
+
lines.push(' for p in _allowed_tools');
|
|
157
|
+
lines.push(' )');
|
|
158
|
+
lines.push(' if not allowed:');
|
|
159
|
+
lines.push(' raise PermissionError(f"Tool {name} is not in the allowlist")');
|
|
160
|
+
lines.push(' return await _internal_call_tool(normalized, args)');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push('tools = _Tools()');
|
|
165
|
+
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Generate JavaScript SDK code for isolated-vm (V8 Isolate).
|
|
171
|
+
* Creates: tools.namespace.method(args) => __callToolSync("namespace__method", JSON.stringify(args))
|
|
172
|
+
* @param bindings Tool bindings to generate SDK for
|
|
173
|
+
* @param allowedTools Optional allowlist for $raw() enforcement
|
|
174
|
+
* @param enableRawFallback Enable $raw() escape hatch (default: true)
|
|
175
|
+
*/
|
|
176
|
+
generateIsolateSDK(bindings: ToolBinding[], allowedTools?: string[], enableRawFallback = true): string {
|
|
177
|
+
const grouped = groupByNamespace(bindings);
|
|
178
|
+
const lines: string[] = [];
|
|
179
|
+
|
|
180
|
+
lines.push('// Generated SDK for isolated-vm');
|
|
181
|
+
|
|
182
|
+
// Inject allowlist for SDK-level enforcement (optional, as Gateway also enforces)
|
|
183
|
+
if (allowedTools && allowedTools.length > 0) {
|
|
184
|
+
const normalizedList = allowedTools.map(t => t.replace(/\./g, '__'));
|
|
185
|
+
lines.push(`const __allowedTools = ${JSON.stringify(normalizedList)};`);
|
|
186
|
+
} else {
|
|
187
|
+
lines.push('const __allowedTools = null;');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
lines.push('const tools = {');
|
|
191
|
+
|
|
192
|
+
for (const [namespace, tools] of grouped.entries()) {
|
|
193
|
+
const safeNamespace = this.isValidIdentifier(namespace) ? namespace : `["${this.escapeString(namespace)}"]`;
|
|
194
|
+
|
|
195
|
+
if (this.isValidIdentifier(namespace)) {
|
|
196
|
+
lines.push(` ${namespace}: {`);
|
|
197
|
+
} else {
|
|
198
|
+
lines.push(` "${this.escapeString(namespace)}": {`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const tool of tools) {
|
|
202
|
+
const methodName = this.isValidIdentifier(tool.methodName) ? tool.methodName : `["${this.escapeString(tool.methodName)}"]`;
|
|
203
|
+
|
|
204
|
+
if (this.isValidIdentifier(tool.methodName)) {
|
|
205
|
+
lines.push(` async ${methodName}(args) {`);
|
|
206
|
+
} else {
|
|
207
|
+
lines.push(` "${this.escapeString(tool.methodName)}": async function(args) {`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push(` const resStr = await __callTool("${this.escapeString(tool.name)}", JSON.stringify(args || {}));`);
|
|
211
|
+
lines.push(` return JSON.parse(resStr);`);
|
|
212
|
+
lines.push(` },`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lines.push(` },`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add $raw escape hatch
|
|
219
|
+
if (enableRawFallback) {
|
|
220
|
+
lines.push(` async $raw(name, args) {`);
|
|
221
|
+
lines.push(` const normalized = name.replace(/\\./g, '__');`);
|
|
222
|
+
lines.push(` if (__allowedTools) {`);
|
|
223
|
+
lines.push(` const allowed = __allowedTools.some(p => {`);
|
|
224
|
+
lines.push(` if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));`);
|
|
225
|
+
lines.push(` return normalized === p;`);
|
|
226
|
+
lines.push(` });`);
|
|
227
|
+
lines.push(` if (!allowed) throw new Error(\`Tool \${name} is not in the allowlist\`);`);
|
|
228
|
+
lines.push(` }`);
|
|
229
|
+
lines.push(` const resStr = await __callTool(normalized, JSON.stringify(args || {}));`);
|
|
230
|
+
lines.push(` return JSON.parse(resStr);`);
|
|
231
|
+
lines.push(` },`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lines.push('};');
|
|
235
|
+
|
|
236
|
+
return lines.join('\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check if a string is a valid JavaScript/Python identifier
|
|
241
|
+
*/
|
|
242
|
+
private isValidIdentifier(str: string): boolean {
|
|
243
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic interface for tool bindings.
|
|
3
|
+
* Used to generate typed SDK code for sandbox injection.
|
|
4
|
+
*/
|
|
5
|
+
export interface ToolBinding {
|
|
6
|
+
/** Full qualified name: "github__createIssue" */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Upstream ID / namespace: "github" */
|
|
9
|
+
namespace: string;
|
|
10
|
+
/** Tool method name: "createIssue" */
|
|
11
|
+
methodName: string;
|
|
12
|
+
/** JSON Schema for input validation */
|
|
13
|
+
inputSchema?: object;
|
|
14
|
+
/** Human-readable description */
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SDKGeneratorOptions {
|
|
19
|
+
/** Tool bindings to include in SDK */
|
|
20
|
+
tools: ToolBinding[];
|
|
21
|
+
/** Allow $raw escape hatch for dynamic calls (default: true) */
|
|
22
|
+
enableRawFallback?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Inline parsing to avoid circular dependency with PolicyService
|
|
26
|
+
function parseToolName(qualifiedName: string): { namespace: string; name: string } {
|
|
27
|
+
const separatorIndex = qualifiedName.indexOf('__');
|
|
28
|
+
if (separatorIndex === -1) {
|
|
29
|
+
return { namespace: '', name: qualifiedName };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
namespace: qualifiedName.substring(0, separatorIndex),
|
|
33
|
+
name: qualifiedName.substring(separatorIndex + 2)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert a prefixed tool name to a ToolBinding.
|
|
39
|
+
* @param name Full tool name in format "namespace__methodName"
|
|
40
|
+
* @param inputSchema Optional JSON Schema
|
|
41
|
+
* @param description Optional description
|
|
42
|
+
*/
|
|
43
|
+
export function toToolBinding(
|
|
44
|
+
name: string,
|
|
45
|
+
inputSchema?: object,
|
|
46
|
+
description?: string
|
|
47
|
+
): ToolBinding {
|
|
48
|
+
const toolId = parseToolName(name);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
namespace: toolId.namespace || 'default',
|
|
53
|
+
methodName: toolId.name || name,
|
|
54
|
+
inputSchema,
|
|
55
|
+
description,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a ToolStub to a ToolBinding.
|
|
61
|
+
* @param stub ToolStub from GatewayService
|
|
62
|
+
*/
|
|
63
|
+
export function fromToolStub(stub: { id: string; name: string; description?: string }): ToolBinding {
|
|
64
|
+
const toolId = parseToolName(stub.id);
|
|
65
|
+
return {
|
|
66
|
+
name: stub.id,
|
|
67
|
+
namespace: toolId.namespace || 'default',
|
|
68
|
+
methodName: stub.name, // stub.name is already the method name (e.g. "create_issue")
|
|
69
|
+
description: stub.description,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Group tool bindings by namespace for SDK generation.
|
|
75
|
+
*/
|
|
76
|
+
export function groupByNamespace(bindings: ToolBinding[]): Map<string, ToolBinding[]> {
|
|
77
|
+
const groups = new Map<string, ToolBinding[]>();
|
|
78
|
+
|
|
79
|
+
for (const binding of bindings) {
|
|
80
|
+
const existing = groups.get(binding.namespace) || [];
|
|
81
|
+
existing.push(binding);
|
|
82
|
+
groups.set(binding.namespace, existing);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return groups;
|
|
86
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Logger } from 'pino';
|
|
5
|
+
import { RequestController } from '../core/request.controller.js';
|
|
6
|
+
import { JSONRPCRequest, ConduitError } from '../core/types.js';
|
|
7
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
8
|
+
import { ConcurrencyService } from '../core/concurrency.service.js';
|
|
9
|
+
import { loggerStorage } from '../core/logger.js';
|
|
10
|
+
|
|
11
|
+
export interface TransportOptions {
|
|
12
|
+
path?: string; // For Unix Socket or Named Pipe
|
|
13
|
+
port?: number; // For TCP (development)
|
|
14
|
+
host?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SocketTransport {
|
|
18
|
+
private server: net.Server;
|
|
19
|
+
private logger: Logger;
|
|
20
|
+
private requestController: RequestController;
|
|
21
|
+
private concurrencyService: ConcurrencyService;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
logger: Logger,
|
|
25
|
+
requestController: RequestController,
|
|
26
|
+
concurrencyService: ConcurrencyService
|
|
27
|
+
) {
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
this.requestController = requestController;
|
|
30
|
+
this.concurrencyService = concurrencyService;
|
|
31
|
+
this.server = net.createServer((socket) => {
|
|
32
|
+
this.handleConnection(socket);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.server.on('error', (err) => {
|
|
36
|
+
this.logger.error({ err }, 'Server error');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async listen(options: TransportOptions): Promise<string> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (options.path) {
|
|
43
|
+
// Strict IPC mode
|
|
44
|
+
const socketPath = this.formatSocketPath(options.path);
|
|
45
|
+
this.logger.info({ socketPath }, 'Binding to IPC socket');
|
|
46
|
+
|
|
47
|
+
// Cleanup existing socket if needed (unlikely on Windows, but good for Unix)
|
|
48
|
+
if (os.platform() !== 'win32' && path.isAbsolute(socketPath)) {
|
|
49
|
+
// We rely on caller or deployment to clean up, or error out.
|
|
50
|
+
// Trying to unlink here might be dangerous if we don't own it.
|
|
51
|
+
// But strictly, we should just listen.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.server.listen(socketPath, () => {
|
|
55
|
+
this.resolveAddress(resolve);
|
|
56
|
+
});
|
|
57
|
+
} else if (options.port !== undefined) {
|
|
58
|
+
// Strict TCP mode
|
|
59
|
+
this.logger.info({ port: options.port, host: options.host }, 'Binding to TCP port');
|
|
60
|
+
this.server.listen(options.port, options.host || '127.0.0.1', () => {
|
|
61
|
+
this.resolveAddress(resolve);
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
reject(new Error('Invalid transport configuration: neither path nor port provided'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.server.on('error', reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private resolveAddress(resolve: (value: string) => void) {
|
|
73
|
+
const address = this.server.address();
|
|
74
|
+
const addressStr = typeof address === 'string' ? address : `${address?.address}:${address?.port}`;
|
|
75
|
+
this.logger.info({ address: addressStr }, 'Transport server listening');
|
|
76
|
+
resolve(addressStr);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private formatSocketPath(inputPath: string): string {
|
|
80
|
+
if (os.platform() === 'win32') {
|
|
81
|
+
// Windows Named Pipe format: \\.\pipe\conduit-[id]
|
|
82
|
+
if (!inputPath.startsWith('\\\\.\\pipe\\')) {
|
|
83
|
+
return `\\\\.\\pipe\\${inputPath}`;
|
|
84
|
+
}
|
|
85
|
+
return inputPath;
|
|
86
|
+
} else {
|
|
87
|
+
// Unix Socket path
|
|
88
|
+
return path.isAbsolute(inputPath) ? inputPath : path.join(os.tmpdir(), inputPath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private handleConnection(socket: net.Socket) {
|
|
93
|
+
const remoteAddress = socket.remoteAddress || 'pipe';
|
|
94
|
+
this.logger.debug({ remoteAddress }, 'New connection established');
|
|
95
|
+
|
|
96
|
+
socket.setEncoding('utf8');
|
|
97
|
+
let buffer = '';
|
|
98
|
+
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB limit
|
|
99
|
+
|
|
100
|
+
socket.on('data', async (chunk) => {
|
|
101
|
+
buffer += chunk;
|
|
102
|
+
|
|
103
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
104
|
+
this.logger.error({ remoteAddress }, 'Connection exceeded max buffer size, closing');
|
|
105
|
+
socket.destroy();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Backpressure: pause processing new chunks until this buffer is handled
|
|
110
|
+
socket.pause();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
let pos: number;
|
|
114
|
+
while ((pos = buffer.indexOf('\n')) >= 0) {
|
|
115
|
+
const line = buffer.substring(0, pos).trim();
|
|
116
|
+
buffer = buffer.substring(pos + 1);
|
|
117
|
+
|
|
118
|
+
if (!line) continue;
|
|
119
|
+
|
|
120
|
+
let request: JSONRPCRequest;
|
|
121
|
+
try {
|
|
122
|
+
request = JSON.parse(line) as JSONRPCRequest;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.logger.error({ err, line }, 'Failed to parse JSON-RPC request');
|
|
125
|
+
const errorResponse = {
|
|
126
|
+
jsonrpc: '2.0',
|
|
127
|
+
id: null,
|
|
128
|
+
error: {
|
|
129
|
+
code: -32700,
|
|
130
|
+
message: 'Parse error',
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
socket.write(JSON.stringify(errorResponse) + '\n');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const context = new ExecutionContext({
|
|
138
|
+
logger: this.logger,
|
|
139
|
+
remoteAddress: remoteAddress,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await loggerStorage.run({ correlationId: context.correlationId }, async () => {
|
|
143
|
+
try {
|
|
144
|
+
const response = await this.concurrencyService.run(() =>
|
|
145
|
+
this.requestController.handleRequest(request, context)
|
|
146
|
+
);
|
|
147
|
+
socket.write(JSON.stringify(response) + '\n');
|
|
148
|
+
} catch (err: any) {
|
|
149
|
+
if (err.name === 'QueueFullError') {
|
|
150
|
+
socket.write(JSON.stringify({
|
|
151
|
+
jsonrpc: '2.0',
|
|
152
|
+
id: request.id,
|
|
153
|
+
error: {
|
|
154
|
+
code: ConduitError.ServerBusy,
|
|
155
|
+
message: 'Server busy'
|
|
156
|
+
}
|
|
157
|
+
}) + '\n');
|
|
158
|
+
} else {
|
|
159
|
+
this.logger.error({ err, requestId: request.id }, 'Request handling failed');
|
|
160
|
+
// Internal error handling usually done by Middleware/RequestController return
|
|
161
|
+
// But if something crashed outside standard flow:
|
|
162
|
+
socket.write(JSON.stringify({
|
|
163
|
+
jsonrpc: '2.0',
|
|
164
|
+
id: request.id,
|
|
165
|
+
error: {
|
|
166
|
+
code: ConduitError.InternalError,
|
|
167
|
+
message: 'Internal server error'
|
|
168
|
+
}
|
|
169
|
+
}) + '\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
this.logger.error({ err }, 'Unexpected error in socket data handler');
|
|
176
|
+
socket.destroy();
|
|
177
|
+
} finally {
|
|
178
|
+
socket.resume();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
socket.on('close', () => {
|
|
183
|
+
this.logger.debug({ remoteAddress }, 'Connection closed');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
socket.on('error', (err) => {
|
|
187
|
+
this.logger.error({ err, remoteAddress }, 'Socket error');
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async close(): Promise<void> {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
if (this.server.listening) {
|
|
194
|
+
this.server.close(() => {
|
|
195
|
+
this.logger.info('Transport server closed');
|
|
196
|
+
resolve();
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
resolve();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|