@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { spawn, exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { platform } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
9
|
+
import { ResourceLimits } from '../core/config.service.js';
|
|
10
|
+
import { ConduitError } from '../core/types.js';
|
|
11
|
+
import { resolveAssetPath } from '../core/asset.utils.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
import { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
16
|
+
|
|
17
|
+
export { ExecutionResult };
|
|
18
|
+
|
|
19
|
+
// Deprecated: use ExecutorConfig
|
|
20
|
+
export interface IPCInfo {
|
|
21
|
+
ipcAddress: string;
|
|
22
|
+
ipcToken: string;
|
|
23
|
+
sdkCode?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class DenoExecutor implements Executor {
|
|
27
|
+
private shimContent: string = '';
|
|
28
|
+
// Track active processes for cleanup
|
|
29
|
+
// Using 'any' for the Set because ChildProcess type import can be finicky across node versions/types
|
|
30
|
+
// but at runtime it is a ChildProcess
|
|
31
|
+
private activeProcesses = new Set<any>();
|
|
32
|
+
private maxConcurrentProcesses: number;
|
|
33
|
+
|
|
34
|
+
constructor(maxConcurrentProcesses = 10) {
|
|
35
|
+
this.maxConcurrentProcesses = maxConcurrentProcesses;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private getShim(): string {
|
|
39
|
+
if (this.shimContent) return this.shimContent;
|
|
40
|
+
try {
|
|
41
|
+
const assetPath = resolveAssetPath('deno-shim.ts');
|
|
42
|
+
this.shimContent = fs.readFileSync(assetPath, 'utf-8');
|
|
43
|
+
return this.shimContent;
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
throw new Error(`Failed to load Deno shim: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async execute(code: string, limits: ResourceLimits, context: ExecutionContext, config?: ExecutorConfig): Promise<ExecutionResult> {
|
|
50
|
+
const { logger } = context;
|
|
51
|
+
|
|
52
|
+
// Check concurrent process limit
|
|
53
|
+
if (this.activeProcesses.size >= this.maxConcurrentProcesses) {
|
|
54
|
+
return {
|
|
55
|
+
stdout: '',
|
|
56
|
+
stderr: '',
|
|
57
|
+
exitCode: null,
|
|
58
|
+
error: {
|
|
59
|
+
code: ConduitError.ServerBusy,
|
|
60
|
+
message: 'Too many concurrent Deno processes'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let stderr = '';
|
|
67
|
+
let totalOutputBytes = 0;
|
|
68
|
+
let totalLogEntries = 0;
|
|
69
|
+
let isTerminated = false;
|
|
70
|
+
|
|
71
|
+
let shim = this.getShim()
|
|
72
|
+
.replace('__CONDUIT_IPC_ADDRESS__', config?.ipcAddress || '')
|
|
73
|
+
.replace('__CONDUIT_IPC_TOKEN__', config?.ipcToken || '');
|
|
74
|
+
|
|
75
|
+
if (shim.includes('__CONDUIT_IPC_ADDRESS__')) {
|
|
76
|
+
throw new Error('Failed to inject IPC address into Deno shim');
|
|
77
|
+
}
|
|
78
|
+
if (shim.includes('__CONDUIT_IPC_TOKEN__')) {
|
|
79
|
+
throw new Error('Failed to inject IPC token into Deno shim');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Inject SDK if provided
|
|
83
|
+
if (config?.sdkCode) {
|
|
84
|
+
shim = shim.replace('// __CONDUIT_SDK_INJECTION__', config.sdkCode);
|
|
85
|
+
if (shim.includes('// __CONDUIT_SDK_INJECTION__')) {
|
|
86
|
+
// Should have been replaced
|
|
87
|
+
throw new Error('Failed to inject SDK code into Deno shim');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fullCode = shim + '\n' + code;
|
|
92
|
+
|
|
93
|
+
// Use --v8-flags for memory limit if possible, or monitor RSS
|
|
94
|
+
// Deno 2.x supports --v8-flags
|
|
95
|
+
const args = [
|
|
96
|
+
'run',
|
|
97
|
+
`--v8-flags=--max-heap-size=${limits.memoryLimitMb}`,
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Security: Restrict permissions.
|
|
101
|
+
// We only allow network access to the IPC host if it's a TCP address.
|
|
102
|
+
// Unix sockets don't need --allow-net.
|
|
103
|
+
if (config?.ipcAddress && !config.ipcAddress.includes('/') && !config.ipcAddress.includes('\\')) {
|
|
104
|
+
try {
|
|
105
|
+
// Use URL parser to safely extract hostname (handles IPv6 brackets and ports correctly)
|
|
106
|
+
// Prepend http:// to ensure it parses as a valid URL structure
|
|
107
|
+
const url = new URL(`http://${config.ipcAddress}`);
|
|
108
|
+
let normalizedHost = url.hostname;
|
|
109
|
+
|
|
110
|
+
// Remove brackets from IPv6 addresses if present (e.g., [::1] -> ::1)
|
|
111
|
+
normalizedHost = normalizedHost.replace(/[\[\]]/g, '');
|
|
112
|
+
|
|
113
|
+
if (normalizedHost === '0.0.0.0' || normalizedHost === '::' || normalizedHost === '::1' || normalizedHost === '') {
|
|
114
|
+
normalizedHost = '127.0.0.1';
|
|
115
|
+
}
|
|
116
|
+
args.push(`--allow-net=${normalizedHost}`);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// If address is malformed, we simply don't add the permission
|
|
119
|
+
logger.warn({ address: config.ipcAddress, err }, 'Failed to parse IPC address for Deno permissions');
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// No network by default
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
args.push('-'); // Read from stdin
|
|
126
|
+
|
|
127
|
+
// logger.info({ args }, 'Spawning Deno');
|
|
128
|
+
const child = spawn('deno', args, {
|
|
129
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
+
env: {
|
|
131
|
+
PATH: process.env.PATH,
|
|
132
|
+
HOME: process.env.HOME,
|
|
133
|
+
TMPDIR: process.env.TMPDIR,
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
this.activeProcesses.add(child);
|
|
138
|
+
|
|
139
|
+
child.on('spawn', () => {
|
|
140
|
+
// logger.info('Deno process spawned');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const cleanupProcess = () => {
|
|
144
|
+
this.activeProcesses.delete(child);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const timeout = setTimeout(() => {
|
|
149
|
+
if (!isTerminated) {
|
|
150
|
+
isTerminated = true;
|
|
151
|
+
if (typeof (monitorInterval as any) !== 'undefined') clearInterval(monitorInterval);
|
|
152
|
+
child.kill('SIGKILL');
|
|
153
|
+
logger.warn('Execution timed out, SIGKILL sent');
|
|
154
|
+
cleanupProcess();
|
|
155
|
+
resolve({
|
|
156
|
+
stdout,
|
|
157
|
+
stderr,
|
|
158
|
+
exitCode: null,
|
|
159
|
+
error: {
|
|
160
|
+
code: ConduitError.RequestTimeout,
|
|
161
|
+
message: 'Execution timed out',
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}, limits.timeoutMs);
|
|
166
|
+
|
|
167
|
+
// RSS Monitoring loop
|
|
168
|
+
// Optimization: increased interval to 2s and added platform check
|
|
169
|
+
const isWindows = platform() === 'win32';
|
|
170
|
+
const monitorInterval = setInterval(async () => {
|
|
171
|
+
if (isTerminated || !child.pid) {
|
|
172
|
+
clearInterval(monitorInterval);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
let rssMb = 0;
|
|
177
|
+
if (isWindows) {
|
|
178
|
+
try {
|
|
179
|
+
// Windows: tasklist /FI "PID eq <pid>" /FO CSV /NH
|
|
180
|
+
// Output: "deno.exe","1234","Console","1","12,345 K"
|
|
181
|
+
const { stdout: tasklistOut } = await execAsync(`tasklist /FI "PID eq ${child.pid}" /FO CSV /NH`);
|
|
182
|
+
const match = tasklistOut.match(/"([^"]+ K)"$/m); // Matches the last column with K
|
|
183
|
+
if (match) {
|
|
184
|
+
// Remove ' K' and ',' then parse
|
|
185
|
+
const memStr = match[1].replace(/[ K,]/g, '');
|
|
186
|
+
const memKb = parseInt(memStr, 10);
|
|
187
|
+
if (!isNaN(memKb)) {
|
|
188
|
+
rssMb = memKb / 1024;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// tasklist might fail if process gone
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// On Mac/Linux, ps -o rss= -p [pid] returns RSS in KB
|
|
196
|
+
const { stdout: rssStdout } = await execAsync(`ps -o rss= -p ${child.pid}`);
|
|
197
|
+
const rssKb = parseInt(rssStdout.trim());
|
|
198
|
+
if (!isNaN(rssKb)) {
|
|
199
|
+
rssMb = rssKb / 1024;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (rssMb > limits.memoryLimitMb) {
|
|
204
|
+
isTerminated = true;
|
|
205
|
+
if (typeof (monitorInterval as any) !== 'undefined') clearInterval(monitorInterval);
|
|
206
|
+
child.kill('SIGKILL');
|
|
207
|
+
logger.warn({ rssMb, limitMb: limits.memoryLimitMb }, 'Deno RSS limit exceeded, SIGKILL sent');
|
|
208
|
+
cleanupProcess();
|
|
209
|
+
resolve({
|
|
210
|
+
stdout,
|
|
211
|
+
stderr,
|
|
212
|
+
exitCode: null,
|
|
213
|
+
error: {
|
|
214
|
+
code: ConduitError.MemoryLimitExceeded,
|
|
215
|
+
message: `Memory limit exceeded: ${rssMb.toFixed(2)}MB > ${limits.memoryLimitMb}MB`,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// Process might have exited already or ps failed
|
|
221
|
+
clearInterval(monitorInterval);
|
|
222
|
+
}
|
|
223
|
+
}, 2000); // Check every 2000ms
|
|
224
|
+
|
|
225
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
226
|
+
if (isTerminated) return;
|
|
227
|
+
|
|
228
|
+
totalOutputBytes += chunk.length;
|
|
229
|
+
const newLines = (chunk.toString().match(/\n/g) || []).length;
|
|
230
|
+
totalLogEntries += newLines;
|
|
231
|
+
|
|
232
|
+
if (totalOutputBytes > limits.maxOutputBytes || totalLogEntries > limits.maxLogEntries) {
|
|
233
|
+
isTerminated = true;
|
|
234
|
+
if (typeof (monitorInterval as any) !== 'undefined') clearInterval(monitorInterval);
|
|
235
|
+
child.kill('SIGKILL');
|
|
236
|
+
logger.warn({ bytes: totalOutputBytes, lines: totalLogEntries }, 'Limits exceeded, SIGKILL sent');
|
|
237
|
+
cleanupProcess();
|
|
238
|
+
resolve({
|
|
239
|
+
stdout: stdout + chunk.toString().slice(0, limits.maxOutputBytes - (totalOutputBytes - chunk.length)),
|
|
240
|
+
stderr,
|
|
241
|
+
exitCode: null,
|
|
242
|
+
error: {
|
|
243
|
+
code: totalOutputBytes > limits.maxOutputBytes ? ConduitError.OutputLimitExceeded : ConduitError.LogLimitExceeded,
|
|
244
|
+
message: totalOutputBytes > limits.maxOutputBytes ? 'Output limit exceeded' : 'Log entry limit exceeded',
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
stdout += chunk.toString();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
253
|
+
if (isTerminated) return;
|
|
254
|
+
|
|
255
|
+
totalOutputBytes += chunk.length;
|
|
256
|
+
const newLines = (chunk.toString().match(/\n/g) || []).length;
|
|
257
|
+
totalLogEntries += newLines;
|
|
258
|
+
|
|
259
|
+
if (totalOutputBytes > limits.maxOutputBytes || totalLogEntries > limits.maxLogEntries) {
|
|
260
|
+
isTerminated = true;
|
|
261
|
+
if (typeof (monitorInterval as any) !== 'undefined') clearInterval(monitorInterval);
|
|
262
|
+
child.kill('SIGKILL');
|
|
263
|
+
logger.warn({ bytes: totalOutputBytes, lines: totalLogEntries }, 'Limits exceeded, SIGKILL sent');
|
|
264
|
+
cleanupProcess();
|
|
265
|
+
resolve({
|
|
266
|
+
stdout,
|
|
267
|
+
stderr: stderr + chunk.toString().slice(0, limits.maxOutputBytes - (totalOutputBytes - chunk.length)),
|
|
268
|
+
exitCode: null,
|
|
269
|
+
error: {
|
|
270
|
+
code: totalOutputBytes > limits.maxOutputBytes ? ConduitError.OutputLimitExceeded : ConduitError.LogLimitExceeded,
|
|
271
|
+
message: totalOutputBytes > limits.maxOutputBytes ? 'Output limit exceeded' : 'Log entry limit exceeded',
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
stderr += chunk.toString();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
child.on('close', (code) => {
|
|
280
|
+
clearTimeout(timeout);
|
|
281
|
+
if (typeof (monitorInterval as any) !== 'undefined') clearInterval(monitorInterval);
|
|
282
|
+
cleanupProcess();
|
|
283
|
+
if (isTerminated) return; // Already resolved via timeout or limit
|
|
284
|
+
|
|
285
|
+
resolve({
|
|
286
|
+
stdout,
|
|
287
|
+
stderr,
|
|
288
|
+
exitCode: code,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
child.on('error', (err: any) => {
|
|
293
|
+
clearTimeout(timeout);
|
|
294
|
+
logger.error({ err }, 'Child process error');
|
|
295
|
+
cleanupProcess();
|
|
296
|
+
|
|
297
|
+
let message = err.message;
|
|
298
|
+
if (err.code === 'ENOENT') {
|
|
299
|
+
message = 'Deno executable not found in PATH. Please ensure Deno is installed.';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
resolve({
|
|
303
|
+
stdout,
|
|
304
|
+
stderr,
|
|
305
|
+
exitCode: null,
|
|
306
|
+
error: {
|
|
307
|
+
code: ConduitError.InternalError,
|
|
308
|
+
message: message,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Write code to stdin
|
|
314
|
+
child.stdin.write(fullCode);
|
|
315
|
+
child.stdin.end();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async shutdown() {
|
|
320
|
+
for (const child of this.activeProcesses) {
|
|
321
|
+
try {
|
|
322
|
+
child.kill('SIGKILL');
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// Ignore, process might be dead already
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
this.activeProcesses.clear();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async healthCheck(): Promise<{ status: string; detail?: string }> {
|
|
331
|
+
try {
|
|
332
|
+
const { stdout } = await execAsync('deno --version');
|
|
333
|
+
return { status: 'ok', detail: stdout.split('\n')[0] };
|
|
334
|
+
} catch (err: any) {
|
|
335
|
+
return { status: 'error', detail: err.message };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async warmup(): Promise<void> {
|
|
340
|
+
// No-op for Deno
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import ivm from 'isolated-vm';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
4
|
+
import { ResourceLimits } from '../core/config.service.js';
|
|
5
|
+
import { GatewayService } from '../gateway/gateway.service.js';
|
|
6
|
+
import { ConduitError } from '../core/types.js';
|
|
7
|
+
|
|
8
|
+
import { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
9
|
+
|
|
10
|
+
export { ExecutionResult as IsolateExecutionResult };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* IsolateExecutor - In-process V8 isolate execution using isolated-vm.
|
|
14
|
+
*
|
|
15
|
+
* Security model: Capability-based (not OS sandbox)
|
|
16
|
+
* - Only approved functions exposed to isolate
|
|
17
|
+
* - Hard time/memory limits
|
|
18
|
+
* - No process/fs/net access
|
|
19
|
+
*/
|
|
20
|
+
export class IsolateExecutor implements Executor {
|
|
21
|
+
private logger: Logger;
|
|
22
|
+
private gatewayService: GatewayService;
|
|
23
|
+
|
|
24
|
+
constructor(logger: Logger, gatewayService: GatewayService) {
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
this.gatewayService = gatewayService;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async execute(
|
|
30
|
+
code: string,
|
|
31
|
+
limits: ResourceLimits,
|
|
32
|
+
context: ExecutionContext,
|
|
33
|
+
config?: ExecutorConfig
|
|
34
|
+
): Promise<ExecutionResult> {
|
|
35
|
+
const logs: string[] = [];
|
|
36
|
+
const errors: string[] = [];
|
|
37
|
+
let isolate: ivm.Isolate | null = null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Create isolate with memory limit
|
|
41
|
+
isolate = new ivm.Isolate({ memoryLimit: limits.memoryLimitMb });
|
|
42
|
+
const ctx = await isolate.createContext();
|
|
43
|
+
const jail = ctx.global;
|
|
44
|
+
|
|
45
|
+
let currentLogBytes = 0;
|
|
46
|
+
let currentErrorBytes = 0;
|
|
47
|
+
|
|
48
|
+
// Inject console.log/error for output capture
|
|
49
|
+
// Inject console.log/error for output capture
|
|
50
|
+
await jail.set('__log', new ivm.Callback((msg: string) => {
|
|
51
|
+
if (currentLogBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
52
|
+
// Check log entry count limit? We don't track count here yet effectively, but bytes is safer.
|
|
53
|
+
// The interface says maxOutputBytes applies to total output.
|
|
54
|
+
throw new Error('[LIMIT_LOG]');
|
|
55
|
+
}
|
|
56
|
+
if (currentLogBytes < limits.maxOutputBytes) {
|
|
57
|
+
logs.push(msg);
|
|
58
|
+
currentLogBytes += msg.length + 1; // +1 for newline approximation
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
await jail.set('__error', new ivm.Callback((msg: string) => {
|
|
62
|
+
if (currentErrorBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
63
|
+
throw new Error('[LIMIT_OUTPUT]');
|
|
64
|
+
}
|
|
65
|
+
if (currentErrorBytes < limits.maxOutputBytes) {
|
|
66
|
+
errors.push(msg);
|
|
67
|
+
currentErrorBytes += msg.length + 1;
|
|
68
|
+
}
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// Async tool bridge (ID-based to avoid Promise transfer issues)
|
|
72
|
+
let requestIdCounter = 0;
|
|
73
|
+
const pendingToolCalls = new Map<number, Promise<any>>(); // Not used by Host, but Host initiates work
|
|
74
|
+
|
|
75
|
+
await jail.set('__dispatchToolCall', new ivm.Callback((nameStr: string, argsStr: string) => {
|
|
76
|
+
const requestId = ++requestIdCounter;
|
|
77
|
+
const name = nameStr;
|
|
78
|
+
let args = {};
|
|
79
|
+
try {
|
|
80
|
+
args = JSON.parse(argsStr);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Process async
|
|
86
|
+
this.gatewayService.callTool(name, args, context)
|
|
87
|
+
.then(res => {
|
|
88
|
+
// callback to isolate
|
|
89
|
+
return ctx.evalClosure(`resolveRequest($0, $1, null)`, [requestId, JSON.stringify(res)], { arguments: { copy: true } });
|
|
90
|
+
})
|
|
91
|
+
.catch(err => {
|
|
92
|
+
return ctx.evalClosure(`resolveRequest($0, null, $1)`, [requestId, err.message || 'Unknown error'], { arguments: { copy: true } });
|
|
93
|
+
})
|
|
94
|
+
.catch(e => {
|
|
95
|
+
// Ignore errors calling back into isolate (e.g. if disposed)
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return requestId;
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
// Bootstrap code: create console and async handling
|
|
102
|
+
const bootstrap = `
|
|
103
|
+
const requests = new Map();
|
|
104
|
+
|
|
105
|
+
// Host calls this to resolve requests
|
|
106
|
+
globalThis.resolveRequest = (id, resultJson, error) => {
|
|
107
|
+
const req = requests.get(id);
|
|
108
|
+
if (req) {
|
|
109
|
+
requests.delete(id);
|
|
110
|
+
if (error) req.reject(new Error(error));
|
|
111
|
+
else req.resolve(resultJson);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Internal tool call wrapper
|
|
116
|
+
globalThis.__callTool = (name, argsJson) => {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const id = __dispatchToolCall(name, argsJson);
|
|
119
|
+
requests.set(id, { resolve, reject });
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const format = (arg) => {
|
|
124
|
+
if (typeof arg === 'string') return arg;
|
|
125
|
+
if (arg instanceof Error) return arg.stack || arg.message;
|
|
126
|
+
if (typeof arg === 'object' && arg !== null && arg.message && arg.stack) return arg.stack; // Duck typing
|
|
127
|
+
return JSON.stringify(arg);
|
|
128
|
+
};
|
|
129
|
+
const console = {
|
|
130
|
+
log: (...args) => __log(args.map(format).join(' ')),
|
|
131
|
+
error: (...args) => __error(args.map(format).join(' ')),
|
|
132
|
+
};
|
|
133
|
+
`;
|
|
134
|
+
const bootstrapScript = await isolate.compileScript(bootstrap);
|
|
135
|
+
await bootstrapScript.run(ctx, { timeout: 1000 });
|
|
136
|
+
|
|
137
|
+
// Inject SDK (typed tools or fallback)
|
|
138
|
+
const sdkScript = config?.sdkCode || `
|
|
139
|
+
const tools = {
|
|
140
|
+
$raw: async (name, args) => {
|
|
141
|
+
const resStr = await __callTool(name, JSON.stringify(args || {}));
|
|
142
|
+
return JSON.parse(resStr);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
`;
|
|
146
|
+
const compiledSdk = await isolate.compileScript(sdkScript);
|
|
147
|
+
await compiledSdk.run(ctx, { timeout: 1000 });
|
|
148
|
+
|
|
149
|
+
// Compile and run user code
|
|
150
|
+
// Async completion tracking
|
|
151
|
+
let executionPromiseResolve: () => void;
|
|
152
|
+
const executionPromise = new Promise<void>((resolve) => {
|
|
153
|
+
executionPromiseResolve = resolve;
|
|
154
|
+
});
|
|
155
|
+
await jail.set('__done', new ivm.Callback(() => {
|
|
156
|
+
if (executionPromiseResolve) executionPromiseResolve();
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
let scriptFailed = false;
|
|
160
|
+
await jail.set('__setFailed', new ivm.Callback(() => {
|
|
161
|
+
scriptFailed = true;
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
// Compile and run user code
|
|
165
|
+
// Wrap in async IIFE to support top-level await and track completion
|
|
166
|
+
// Use 'void' to ensure the script returns undefined (transferrable) instead of a Promise
|
|
167
|
+
const wrappedCode = `void (async () => {
|
|
168
|
+
try {
|
|
169
|
+
${code}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(err);
|
|
172
|
+
__setFailed();
|
|
173
|
+
} finally {
|
|
174
|
+
__done();
|
|
175
|
+
}
|
|
176
|
+
})()`;
|
|
177
|
+
|
|
178
|
+
const script = await isolate.compileScript(wrappedCode);
|
|
179
|
+
|
|
180
|
+
// NOTE: Two timeouts exist intentionally:
|
|
181
|
+
// 1. script.run timeout (below) - catches infinite synchronous loops
|
|
182
|
+
// 2. Promise.race timeout (after) - catches stuck async operations (tool calls)
|
|
183
|
+
// Tool calls may continue briefly after timeout; isolate.dispose() cleans up.
|
|
184
|
+
|
|
185
|
+
// Start execution with synchronous timeout protection
|
|
186
|
+
await script.run(ctx, { timeout: limits.timeoutMs });
|
|
187
|
+
|
|
188
|
+
// Wait for completion or timeout
|
|
189
|
+
let timedOut = false;
|
|
190
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
timedOut = true;
|
|
193
|
+
reject(new Error('Script execution timed out'));
|
|
194
|
+
}, limits.timeoutMs);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await Promise.race([executionPromise, timeoutPromise]);
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
if (err.message === 'Script execution timed out') {
|
|
201
|
+
return {
|
|
202
|
+
stdout: logs.join('\n'),
|
|
203
|
+
stderr: errors.join('\n'),
|
|
204
|
+
exitCode: null,
|
|
205
|
+
error: {
|
|
206
|
+
code: ConduitError.RequestTimeout,
|
|
207
|
+
message: 'Execution timed out',
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
stdout: logs.join('\n'),
|
|
216
|
+
stderr: errors.join('\n'),
|
|
217
|
+
exitCode: scriptFailed ? 1 : 0,
|
|
218
|
+
};
|
|
219
|
+
} catch (err: any) {
|
|
220
|
+
const message = err.message || 'Unknown error';
|
|
221
|
+
|
|
222
|
+
// Handle specific error types
|
|
223
|
+
if (message.includes('Script execution timed out')) {
|
|
224
|
+
return {
|
|
225
|
+
stdout: logs.join('\n'),
|
|
226
|
+
stderr: errors.join('\n'),
|
|
227
|
+
exitCode: null,
|
|
228
|
+
error: {
|
|
229
|
+
code: ConduitError.RequestTimeout,
|
|
230
|
+
message: 'Execution timed out',
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (message.includes('memory limit') || message.includes('disposed')) {
|
|
236
|
+
return {
|
|
237
|
+
stdout: logs.join('\n'),
|
|
238
|
+
stderr: errors.join('\n'),
|
|
239
|
+
exitCode: null,
|
|
240
|
+
error: {
|
|
241
|
+
code: ConduitError.MemoryLimitExceeded,
|
|
242
|
+
message: 'Memory limit exceeded',
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.logger.error({ err }, 'Isolate execution failed');
|
|
248
|
+
return {
|
|
249
|
+
stdout: logs.join('\n'),
|
|
250
|
+
stderr: message,
|
|
251
|
+
exitCode: 1,
|
|
252
|
+
error: {
|
|
253
|
+
code: ConduitError.InternalError,
|
|
254
|
+
message,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
if (isolate) {
|
|
259
|
+
isolate.dispose();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async shutdown(): Promise<void> {
|
|
265
|
+
// No-op
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async healthCheck(): Promise<{ status: string; detail?: string }> {
|
|
269
|
+
try {
|
|
270
|
+
const isolate = new ivm.Isolate({ memoryLimit: 8 });
|
|
271
|
+
isolate.dispose();
|
|
272
|
+
return { status: 'ok' };
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
return { status: 'error', detail: err.message };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async warmup(): Promise<void> {
|
|
279
|
+
// No-op
|
|
280
|
+
}
|
|
281
|
+
}
|