Skip to content

Commit 6c3bafb

Browse files
authored
feat(runtime): add sandboxed JavaScript execution with QuickJS (#1088)
* feat(runtime): add sandboxed JavaScript execution with QuickJS * fix(runtime): merge sync and resolved promise handling logic * fix(runtime): move param serialization before WASM allocation and wrap promise handling in try-finally * fix(runtime): switch test environment from jsdom to node
1 parent 9bc8e7b commit 6c3bafb

4 files changed

Lines changed: 113 additions & 29 deletions

File tree

common/config/rush/pnpm-lock.yaml

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/runtime/js-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@langchain/openai": "0.5.18",
3434
"@langchain/core": "^0.3.58",
3535
"lodash-es": "^4.17.21",
36+
"quickjs-emscripten": "^0.32.0",
3637
"uuid": "^9.0.0",
3738
"zod": "^3.24.4"
3839
},

packages/runtime/js-core/src/nodes/code/index.ts

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: MIT
44
*/
55

6+
import { getQuickJS, shouldInterruptAfterDeadline } from 'quickjs-emscripten';
67
import {
78
CodeNodeSchema,
89
ExecutionContext,
@@ -48,47 +49,74 @@ export class CodeExecutor implements INodeExecutor {
4849
}
4950

5051
private async javascript(inputs: CodeExecutorInputs): Promise<ExecutionResult> {
51-
// Extract script content and inputs
5252
const { params = {}, script } = inputs;
5353

54+
// Serialize before allocating WASM resources – fails fast on circular references.
55+
const serializedParams = JSON.stringify(params);
56+
57+
const QuickJS = await getQuickJS();
58+
59+
// Each execution gets an isolated context; no host globals are exposed by default.
60+
const context = QuickJS.newContext();
5461
try {
55-
// Create a safe execution environment with basic restrictions
56-
const executeCode = new Function(
57-
'params',
58-
`
59-
'use strict';
62+
// Apply resource limits on the underlying runtime.
63+
const runtime = context.runtime;
64+
runtime.setMemoryLimit(32 * 1024 * 1024); // 32 MB
65+
runtime.setMaxStackSize(512 * 1024); // 512 KB
66+
// Interrupt execution if it runs longer than 1 minute.
67+
runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + 60_000));
6068

61-
${script.content}
69+
// Wrap user code: define main, inject params, call main, return result.
70+
const wrappedCode = `
71+
'use strict';
6272
63-
// Ensure main function exists
64-
if (typeof main !== 'function') {
65-
throw new Error('main function is required in the script');
66-
}
73+
${script.content}
6774
68-
// Execute main function with params
69-
return main({ params });
70-
`
71-
);
75+
if (typeof main !== 'function') {
76+
throw new Error('main function is required in the script');
77+
}
7278
73-
// Execute with timeout protection (1 minute)
74-
const timeoutPromise = new Promise<never>((_, reject) => {
75-
setTimeout(() => {
76-
reject(new Error('Code execution timeout: exceeded 1 minute'));
77-
}, 1000 * 60);
78-
});
79+
const __params__ = ${serializedParams};
80+
main({ params: __params__ });
81+
`;
7982

80-
// Execute the code with input parameters and timeout
81-
const result = await Promise.race([executeCode(params), timeoutPromise]);
83+
const evalResult = context.evalCode(wrappedCode);
84+
const resultHandle = context.unwrapResult(evalResult);
85+
86+
let rawResult: unknown;
87+
88+
try {
89+
const promiseState = context.getPromiseState(resultHandle);
90+
if (promiseState.type === 'fulfilled') {
91+
rawResult = context.dump(promiseState.value);
92+
promiseState.value.dispose();
93+
} else if (promiseState.type === 'rejected') {
94+
const errMsg = context.dump(promiseState.error);
95+
promiseState.error.dispose();
96+
throw new Error(typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg));
97+
} else {
98+
// Pending promise: resolve asynchronously via the QuickJS event loop.
99+
const resolvedResult = await context.resolvePromise(resultHandle);
100+
const resolvedHandle = context.unwrapResult(resolvedResult);
101+
rawResult = context.dump(resolvedHandle);
102+
resolvedHandle.dispose();
103+
}
104+
} finally {
105+
resultHandle.dispose();
106+
}
82107

83-
// Ensure result is an object
108+
// Ensure result is a plain object.
84109
const outputs =
85-
result && typeof result === 'object' && !Array.isArray(result) ? result : { result };
110+
rawResult && typeof rawResult === 'object' && !Array.isArray(rawResult)
111+
? (rawResult as Record<string, unknown>)
112+
: { result: rawResult };
86113

87-
return {
88-
outputs,
89-
};
114+
return { outputs };
90115
} catch (error: any) {
91116
throw new Error(`Code execution failed: ${error.message}`);
117+
} finally {
118+
// Always release WASM memory for this execution context.
119+
context.dispose();
92120
}
93121
}
94122
}

packages/runtime/js-core/vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default defineConfig({
2424
test: {
2525
globals: true,
2626
mockReset: false,
27-
environment: 'jsdom',
27+
environment: 'node',
2828
testTimeout: 15000,
2929
setupFiles: [path.resolve(__dirname, './src/domain/__tests__/setup.ts')],
3030
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],

0 commit comments

Comments
 (0)