@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.
Files changed (87) hide show
  1. package/.env.example +13 -0
  2. package/.github/workflows/ci.yml +88 -0
  3. package/.github/workflows/pr-checks.yml +90 -0
  4. package/.tool-versions +2 -0
  5. package/README.md +177 -0
  6. package/conduit.yaml.test +3 -0
  7. package/docs/ARCHITECTURE.md +35 -0
  8. package/docs/CODE_MODE.md +33 -0
  9. package/docs/SECURITY.md +52 -0
  10. package/logo.png +0 -0
  11. package/package.json +74 -0
  12. package/src/assets/deno-shim.ts +93 -0
  13. package/src/assets/python-shim.py +21 -0
  14. package/src/core/asset.utils.ts +42 -0
  15. package/src/core/concurrency.service.ts +70 -0
  16. package/src/core/config.service.ts +147 -0
  17. package/src/core/execution.context.ts +37 -0
  18. package/src/core/execution.service.ts +209 -0
  19. package/src/core/interfaces/app.config.ts +17 -0
  20. package/src/core/interfaces/executor.interface.ts +31 -0
  21. package/src/core/interfaces/middleware.interface.ts +12 -0
  22. package/src/core/interfaces/url.validator.interface.ts +3 -0
  23. package/src/core/logger.ts +64 -0
  24. package/src/core/metrics.service.ts +112 -0
  25. package/src/core/middleware/auth.middleware.ts +56 -0
  26. package/src/core/middleware/error.middleware.ts +21 -0
  27. package/src/core/middleware/logging.middleware.ts +25 -0
  28. package/src/core/middleware/middleware.builder.ts +24 -0
  29. package/src/core/middleware/ratelimit.middleware.ts +31 -0
  30. package/src/core/network.policy.service.ts +106 -0
  31. package/src/core/ops.server.ts +74 -0
  32. package/src/core/otel.service.ts +41 -0
  33. package/src/core/policy.service.ts +77 -0
  34. package/src/core/registries/executor.registry.ts +26 -0
  35. package/src/core/request.controller.ts +297 -0
  36. package/src/core/security.service.ts +68 -0
  37. package/src/core/session.manager.ts +44 -0
  38. package/src/core/types.ts +47 -0
  39. package/src/executors/deno.executor.ts +342 -0
  40. package/src/executors/isolate.executor.ts +281 -0
  41. package/src/executors/pyodide.executor.ts +327 -0
  42. package/src/executors/pyodide.worker.ts +195 -0
  43. package/src/gateway/auth.service.ts +104 -0
  44. package/src/gateway/gateway.service.ts +345 -0
  45. package/src/gateway/schema.cache.ts +46 -0
  46. package/src/gateway/upstream.client.ts +244 -0
  47. package/src/index.ts +92 -0
  48. package/src/sdk/index.ts +2 -0
  49. package/src/sdk/sdk-generator.ts +245 -0
  50. package/src/sdk/tool-binding.ts +86 -0
  51. package/src/transport/socket.transport.ts +203 -0
  52. package/tests/__snapshots__/assets.test.ts.snap +97 -0
  53. package/tests/assets.test.ts +50 -0
  54. package/tests/auth.service.test.ts +78 -0
  55. package/tests/code-mode-lite-execution.test.ts +84 -0
  56. package/tests/code-mode-lite-gateway.test.ts +150 -0
  57. package/tests/concurrency.service.test.ts +50 -0
  58. package/tests/concurrency.test.ts +41 -0
  59. package/tests/config.service.test.ts +70 -0
  60. package/tests/contract.test.ts +43 -0
  61. package/tests/deno.executor.test.ts +68 -0
  62. package/tests/deno_hardening.test.ts +45 -0
  63. package/tests/dynamic.tool.test.ts +237 -0
  64. package/tests/e2e_stdio_upstream.test.ts +197 -0
  65. package/tests/fixtures/stdio-server.ts +42 -0
  66. package/tests/gateway.manifest.test.ts +82 -0
  67. package/tests/gateway.service.test.ts +58 -0
  68. package/tests/gateway.strict.unit.test.ts +74 -0
  69. package/tests/gateway.validation.unit.test.ts +89 -0
  70. package/tests/gateway_validation.test.ts +86 -0
  71. package/tests/hardening.test.ts +139 -0
  72. package/tests/hardening_v1.test.ts +72 -0
  73. package/tests/isolate.executor.test.ts +100 -0
  74. package/tests/log-limit.test.ts +55 -0
  75. package/tests/middleware.test.ts +106 -0
  76. package/tests/ops.server.test.ts +65 -0
  77. package/tests/policy.service.test.ts +90 -0
  78. package/tests/pyodide.executor.test.ts +101 -0
  79. package/tests/reference_mcp.ts +40 -0
  80. package/tests/remediation.test.ts +119 -0
  81. package/tests/routing.test.ts +148 -0
  82. package/tests/schema.cache.test.ts +27 -0
  83. package/tests/sdk/sdk-generator.test.ts +205 -0
  84. package/tests/socket.transport.test.ts +182 -0
  85. package/tests/stdio_upstream.test.ts +54 -0
  86. package/tsconfig.json +25 -0
  87. 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
+ }