diff --git a/.github/scripts/write-test-summary.mjs b/.github/scripts/write-test-summary.mjs
new file mode 100644
index 0000000..d9b7d5e
--- /dev/null
+++ b/.github/scripts/write-test-summary.mjs
@@ -0,0 +1,22 @@
+import fs from 'node:fs';
+
+const report = fs.existsSync('test-output.txt')
+ ? fs.readFileSync('test-output.txt', 'utf8').trimEnd()
+ : 'No test report was generated.';
+
+const passed = process.env.TEST_OUTCOME === 'success';
+
+const summary = [
+ `## ${passed ? '✅ Tests passed' : '❌ Tests failed'}`,
+ '',
+ '',
+ 'Test results
',
+ '',
+ '```text',
+ report,
+ '```',
+ ' ',
+ '',
+].join('\n');
+
+fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
diff --git a/.github/workflows/publish-to-npm.yml b/.github/workflows/publish-to-npm.yml
index 88966d8..96d7372 100644
--- a/.github/workflows/publish-to-npm.yml
+++ b/.github/workflows/publish-to-npm.yml
@@ -6,20 +6,56 @@ on:
- v*
workflow_dispatch:
+permissions:
+ contents: read
+ # required for GitHub to issue the OIDC identity npm uses for Trusted Publishing
+ id-token: write
+
jobs:
publish-to-npm:
runs-on: ubuntu-latest
+ environment: publish
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-node@v3
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
with:
- cache: npm
- node-version: 'lts/*'
- registry-url: 'https://registry.npmjs.org'
- - name: Install Dependencies
- run: npm ci
+ node-version-file: .nvmrc
+ registry-url: https://registry.npmjs.org
+ package-manager-cache: false
+
+ - name: Verify release version
+ run: >-
+ node -e "
+ const { version } = require('./package.json');
+ const expectedTag = \`v\${version}\`;
+ if (process.env.GITHUB_REF_NAME !== expectedTag) {
+ throw new Error(\`Tag \${process.env.GITHUB_REF_NAME} does not match package version \${expectedTag}\`);
+ }
+ "
+
+ - name: Install dependencies
+ run: |
+ npm ci --ignore-scripts
+ # isolated-vm requires its lifecycle scripts to build the native addon.
+ npm rebuild isolated-vm
+
+ - name: Test
+ run: npm test
+
+ - name: Build
+ run: npm run build
+
+ - name: Verify package contents
+ run: npm pack --dry-run
+
- name: Publish
- run: npm publish
- continue-on-error: true
env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ # git tags like `v1.0.0-rc.0` will be tagged as `next` version in NPM instead of `latest`
+ NPM_DIST_TAG: ${{ contains(github.ref_name, '-') && 'next' || 'latest' }}
+ run: npm publish --tag "$NPM_DIST_TAG"
+ run: npm publish
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f13bb03..57c5327 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,25 +8,51 @@ on:
branches:
- develop
tags:
- - v*
+ - 'v*'
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-node@v3
+ - name: Check out repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
with:
- node-version: 'lts/*'
+ node-version-file: .nvmrc
cache: npm
+ cache-dependency-path: package-lock.json
+
- name: Install Dependencies
- run: npm ci
+ run: |
+ npm ci --ignore-scripts
+ # isolated-vm requires its lifecycle scripts to build the native addon.
+ npm rebuild isolated-vm
+
- name: Test
- run: npm test
+ id: test
+ env:
+ NO_COLOR: 1
+ run: npm run test:ci
+
+ - name: Add Test Summary
+ if: always()
+ env:
+ TEST_OUTCOME: ${{ steps.test.outcome }}
+ run: node .github/scripts/write-test-summary.mjs
+
- name: Upload Test Results
if: always()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v7
with:
- name: Test Results
- path: ${{ github.workspace }}/test-report
+ name: test-results-${{ github.run_id }}
+ path: test-output.txt
+ if-no-files-found: warn
+ retention-days: 14
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b2ee01f..6ec87bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ ts-edsl.iml
*.tsbuildinfo
.DS_Store
build
-docs
\ No newline at end of file
+docs
+test-output.txt
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..5bcf9c6
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v24.18.0
diff --git a/README.md b/README.md
index 09d7436..2a23c7c 100644
--- a/README.md
+++ b/README.md
@@ -14,10 +14,12 @@ A simple way to safely run user code written in Typescript.
## Requirements
-NodeJS >= 13.0.0
+NodeJS >= 24.0.0
-Because we are transpiling and running the typescript code as modules in a vm, we need to flag on the vm modules flag at runtime with
-```node --experimental-vm-modules```
+Because this library uses `isolated-vm`, node.js must be started with the `--no-node-snapshot` flag when this module is used:
+```
+node --no-node-snapshot
+```
## Example
diff --git a/package-lock.json b/package-lock.json
index c250f05..f45b1e4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,21 @@
{
"name": "@nasa-jpl/aerie-ts-user-code-runner",
- "version": "0.7.0",
+ "version": "1.0.0-rc0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nasa-jpl/aerie-ts-user-code-runner",
- "version": "0.7.0",
+ "version": "1.0.0-rc0",
"license": "MIT",
"dependencies": {
+ "isolated-vm": "6.0.2",
"source-map": "^0.7.4",
"stack-trace": "^1.0.0-pre1"
},
"devDependencies": {
"@js-temporal/polyfill": "^0.4.3",
- "@types/node": "^18.11.2",
+ "@types/node": "24.13.3",
"@types/stack-trace": "^0.0.30",
"expect": "^29",
"glob": "^10",
@@ -23,6 +24,9 @@
"typedoc": "^0.23.17",
"typescript": "^5"
},
+ "engines": {
+ "node": "24.x"
+ },
"peerDependencies": {
"typescript": "4.x || 5.x"
}
@@ -676,10 +680,14 @@
}
},
"node_modules/@types/node": {
- "version": "18.15.11",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
- "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==",
- "dev": true
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
},
"node_modules/@types/stack-trace": {
"version": "0.0.30",
@@ -744,6 +752,37 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
@@ -756,6 +795,30 @@
"node": ">=8"
}
},
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -778,6 +841,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
"node_modules/ci-info": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz",
@@ -825,6 +894,39 @@
"node": ">= 8"
}
},
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/diff-sequences": {
"version": "29.4.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz",
@@ -846,6 +948,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
@@ -892,6 +1003,15 @@
"node": ">=8"
}
},
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/expect": {
"version": "29.5.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.5.0.tgz",
@@ -948,6 +1068,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -974,6 +1100,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "10.3.9",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.9.tgz",
@@ -1035,6 +1167,38 @@
"node": ">=8"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1059,6 +1223,19 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
+ "node_modules/isolated-vm": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.0.2.tgz",
+ "integrity": "sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg==",
+ "hasInstallScript": true,
+ "license": "ISC",
+ "dependencies": {
+ "prebuild-install": "^7.1.3"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
"node_modules/jackspeak": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.5.tgz",
@@ -1202,6 +1379,27 @@
"node": ">=8.6"
}
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.3.tgz",
@@ -1211,6 +1409,39 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.94.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
+ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -1257,6 +1488,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/prettier": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
@@ -1298,12 +1556,51 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1313,6 +1610,38 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1346,6 +1675,51 @@
"vscode-textmate": "^8.0.0"
}
},
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -1383,6 +1757,15 @@
"node": ">=10"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1437,6 +1820,15 @@
"node": ">=8"
}
},
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -1449,6 +1841,34 @@
"node": ">=8"
}
},
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1503,6 +1923,18 @@
"source-map": "^0.6.0"
}
},
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/typedoc": {
"version": "0.23.28",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.23.28.tgz",
@@ -1561,6 +1993,19 @@
"node": ">=12.20"
}
},
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/vscode-oniguruma": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
@@ -1605,6 +2050,12 @@
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
}
}
}
diff --git a/package.json b/package.json
index a4472aa..57a1a71 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@nasa-jpl/aerie-ts-user-code-runner",
- "version": "0.7.0",
+ "version": "1.0.0-rc0",
"description": "A simple way to safely run user code written in Typescript.",
"main": "build/UserCodeRunner.js",
"type": "module",
@@ -10,10 +10,15 @@
"clean": "rm -rf build && rm -rf docs",
"doc": "typedoc",
"prepare": "npm run build && npm run doc",
- "test": "node --experimental-vm-modules --test --loader tsx `glob 'test/**/*.spec.ts'`",
+ "test": "node --no-node-snapshot --test --loader tsx `glob 'test/**/*.spec.ts'`",
+ "test:ci": "node --no-node-snapshot --test --loader tsx --test-reporter=spec --test-reporter=spec --test-reporter-destination=stdout --test-reporter-destination=test-output.txt `glob 'test/**/*.spec.ts'`",
"watch": "tsc -p tsconfig.json --watch"
},
+ "engines": {
+ "node": "24.x"
+ },
"dependencies": {
+ "isolated-vm": "6.0.2",
"source-map": "^0.7.4",
"stack-trace": "^1.0.0-pre1"
},
@@ -22,7 +27,7 @@
},
"devDependencies": {
"@js-temporal/polyfill": "^0.4.3",
- "@types/node": "^18.11.2",
+ "@types/node": "24.13.3",
"@types/stack-trace": "^0.0.30",
"expect": "^29",
"glob": "^10",
@@ -30,5 +35,10 @@
"tsx": "^3",
"typedoc": "^0.23.17",
"typescript": "^5"
+ },
+ "allowScripts": {
+ "esbuild@0.18.20": true,
+ "fsevents@2.3.3": true,
+ "isolated-vm@6.0.2": true
}
}
diff --git a/src/UserCodeRunner.ts b/src/UserCodeRunner.ts
index 084b59a..95160f5 100644
--- a/src/UserCodeRunner.ts
+++ b/src/UserCodeRunner.ts
@@ -1,5 +1,5 @@
-import vm from 'vm';
-import path from 'path';
+import ivm from 'isolated-vm';
+import path from 'node:path';
import { defaultErrorCodeMessageMappers } from './defaultErrorCodeMessageMappers.js';
import { createMapDiagnosticMessage } from './utils/errorMessageMapping.js';
import ts from 'typescript';
@@ -15,15 +15,53 @@ export { defaultErrorCodeMessageMappers } from './defaultErrorCodeMessageMappers
const EXECUTION_HARNESS_FILENAME = '__execution_harness';
const USER_CODE_FILENAME = '__user_file';
+export type UserCodeGlobals = Record;
+
export interface CacheItem {
jsFileMap: { [key: string]: string };
userCodeSourceMap: string;
}
+// instance options provided by the user when constructing the UserCodeRunner
export interface UserCodeRunnerOptions {
typeErrorCodeMessageMappers?: { [errorCode: number]: (message: string) => string | undefined }; // The error code to message mappers
}
+// optional execution-specific options that can be provided when code is executed
+export interface ResultSerializerOptions {
+ /**
+ * Name of an additional source module whose default export converts the
+ * user's raw result into transferable data that can safely leave the isolate.
+ */
+ moduleName: string;
+
+ /**
+ * TypeScript type returned by the serializer.
+ */
+ outputType?: string;
+}
+export interface UserCodeExecutionOptions {
+ /**
+ * Plain data copied into the guest isolate.
+ */
+ globals?: UserCodeGlobals;
+
+ /**
+ * Maximum guest-isolate heap size in MB.
+ */
+ memoryLimitMb?: number;
+
+ /**
+ * Trusted guest-side serializer included in additionalSourceFiles.
+ */
+ resultSerializer?: ResultSerializerOptions;
+}
+
+export interface ArtifactExecutionOptions {
+ globals?: UserCodeGlobals;
+ memoryLimitMb?: number;
+}
+
export class UserCodeRunner {
private readonly mapDiagnosticMessage: ReturnType;
@@ -33,13 +71,27 @@ export class UserCodeRunner {
);
}
+ /**
+ * Pre-process user code into executable Javascript artifacts by:
+ * - generating a top level Typescript harness which imports the main user code and additional source files
+ * - type-checking and transpiling the module graph
+ * - producing source files and source maps for runtime error mapping
+ * The harness invokes the user module and optionally serializes its result inside the guest environment.
+ *
+ * @param userCode TypeScript source containing the user module's default export.
+ * @param outputType Expected TypeScript return type of the user function.
+ * @param argsTypes TypeScript types corresponding to the user function arguments.
+ * @param additionalSourceFiles Additional virtual TypeScript modules available to the harness and user code.
+ * @param options Optional preprocessing behavior, including guest-side result serialization.
+ * @returns The transpiled module map and user-code source map, or preprocessing errors.
+ */
public async preProcess(
userCode: string,
outputType: string = 'any',
argsTypes: string[] = ['any'],
additionalSourceFiles: ts.SourceFile[] = [],
+ options: Pick = {},
): Promise> {
- // TypeCheck and transpile code
const userSourceFile = ts.createSourceFile(
USER_CODE_FILENAME,
userCode,
@@ -48,26 +100,51 @@ export class UserCodeRunner {
ts.ScriptKind.TS,
);
+ // optional result serializer passed by the user
+ // if passed, will be run on all results of user code before returning to transform them to safe values
+ const serializer = options.resultSerializer;
+ const serializerModuleName = serializer === undefined ? undefined : removeExt(serializer.moduleName);
+ if (
+ serializerModuleName !== undefined &&
+ !additionalSourceFiles.some(file => removeExt(file.fileName) === serializerModuleName)
+ ) {
+ throw new Error(`Result serializer module not found: ${serializerModuleName}`);
+ }
+
+ const serializerImport =
+ serializerModuleName === undefined
+ ? ''
+ : `import __serializeResult from ${JSON.stringify(serializerModuleName)};`;
+
+ const finalOutputType = serializer?.outputType ?? outputType;
+
const executionCode = `
${additionalSourceFiles
.map(file => {
if (file.fileName.endsWith('.d.ts')) return '';
- const filenameSansExt = removeExt(file.fileName);
- return `import '${filenameSansExt}';`;
+ return `import ${JSON.stringify(removeExt(file.fileName))};`;
})
- .join('\n ')}
- import defaultExport from '${USER_CODE_FILENAME}';
-
- declare global {
- const __args: [${argsTypes.join(', ')}];
- let __result: ${outputType} | Promise<${outputType}>;
- }
- __result = defaultExport(...__args);
-
- if ((__result as any) instanceof Promise) {
- __result = await __result;
- }
- `;
+ .join('\n')}
+
+ ${serializerImport}
+
+ import defaultExport from ${JSON.stringify(USER_CODE_FILENAME)};
+
+ declare global {
+ const __args: [${argsTypes.join(', ')}];
+ let __result: ${outputType} | Promise<${outputType}>;
+ }
+ let __finalResult: ${finalOutputType};
+
+ __result = defaultExport(...__args);
+ if ((__result as any) instanceof Promise) {
+ __result = await __result;
+ }
+ const __resolvedResult: ${outputType} = await __result;
+
+ __finalResult = ${serializer === undefined ? '__resolvedResult' : 'await __serializeResult(__resolvedResult)'};
+ (globalThis as any).__finalResult = __finalResult;
+ `;
const executionSourceFile = ts.createSourceFile(
EXECUTION_HARNESS_FILENAME,
@@ -77,16 +154,33 @@ export class UserCodeRunner {
ts.ScriptKind.TS,
);
- const tsFileMap = new Map();
-
- tsFileMap.set(USER_CODE_FILENAME, userSourceFile);
- tsFileMap.set(EXECUTION_HARNESS_FILENAME, executionSourceFile);
+ // Precompiled JavaScript bundles are runtime-only guest modules.
+ // They must bypass TypeScript compilation to avoid re-emission conflicts.
+ const isJavaScriptFile = (fileName: string): boolean => /\.[cm]?js$/.test(fileName);
+ const runtimeJavascriptFiles = additionalSourceFiles.filter(file => isJavaScriptFile(file.fileName));
+
+ // TypeScript and declaration files remain in the virtual compiler program for
+ // type checking, transpilation, diagnostics, and source-map generation.
+ const typescriptSourceFiles = additionalSourceFiles.filter(file => !isJavaScriptFile(file.fileName));
+
+ const tsFileMap = new Map([
+ [USER_CODE_FILENAME, userSourceFile],
+ [EXECUTION_HARNESS_FILENAME, executionSourceFile],
+ ]);
+ for (const typescriptSourceFile of typescriptSourceFiles) {
+ tsFileMap.set(removeExt(typescriptSourceFile.fileName), typescriptSourceFile);
+ }
- for (const additionalSourceFile of additionalSourceFiles) {
- tsFileMap.set(removeExt(additionalSourceFile.fileName), additionalSourceFile);
+ // Seed the runtime module map with precompiled JS guest bundles unchanged.
+ const jsFileMap: Record = {};
+ for (const file of runtimeJavascriptFiles) {
+ const moduleName = removeExt(file.fileName);
+ if (jsFileMap[moduleName] !== undefined) {
+ throw new Error(`Duplicate runtime module: ${moduleName}`);
+ }
+ jsFileMap[moduleName] = file.text;
}
- const jsFileMap = {} as { [key: string]: string };
let userCodeSourceMap: string;
const defaultCompilerHost = ts.createCompilerHost({});
@@ -110,15 +204,14 @@ export class UserCodeRunner {
if (removeExt(filenameSansExt) === USER_CODE_FILENAME) {
userCodeSourceMap = ts.createSourceFile(removeExt(filenameSansExt), data, ts.ScriptTarget.ESNext).text;
}
- } else {
- jsFileMap[filenameSansExt] = ts.createSourceFile(
- filenameSansExt,
- data,
- ts.ScriptTarget.ESNext,
- undefined,
- ts.ScriptKind.JS,
- ).text;
+ return;
+ }
+ // Prevent emitted TypeScript from silently replacing a supplied runtime bundle.
+ if (jsFileMap[filenameSansExt] !== undefined) {
+ throw new Error(`Duplicate emitted module: ${filenameSansExt}`);
}
+ // Add transpiled (now JS) modules to the same map as the untouched precompiled JS bundles.
+ jsFileMap[filenameSansExt] = data;
},
readFile(fileName: string): string | undefined {
const filenameSansExt = removeExt(fileName);
@@ -134,12 +227,18 @@ export class UserCodeRunner {
};
const program = ts.createProgram(
- [...additionalSourceFiles.map(f => f.fileName), EXECUTION_HARNESS_FILENAME],
+ [...typescriptSourceFiles.map(f => f.fileName), EXECUTION_HARNESS_FILENAME],
{
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ES2022,
lib: ['lib.esnext.d.ts'],
sourceMap: true,
+ // allow TS files OR pre-bundled JS files
+ allowJs: true,
+ checkJs: true,
+ // prevent pre-bundled JavaScript inputs from overwriting themselves
+ // The custom compiler host captures these virtual output paths in memory.
+ outDir: '__generated__',
},
customCompilerHost,
);
@@ -184,16 +283,18 @@ export class UserCodeRunner {
});
}
- public async executeUserCode(
+ public async executeUserCode(
userCode: string,
args: ArgsType,
- outputType: string = 'any',
+ outputType = 'any',
argsTypes: string[] = ['any'],
- timeout: number = 5000,
+ timeout = 5000,
additionalSourceFiles: ts.SourceFile[] = [],
- context: vm.Context = vm.createContext(),
- ): Promise> {
- const result = await this.preProcess(userCode, outputType, argsTypes, additionalSourceFiles);
+ options: UserCodeExecutionOptions = {},
+ ): Promise> {
+ const result = await this.preProcess(userCode, outputType, argsTypes, additionalSourceFiles, {
+ resultSerializer: options.resultSerializer,
+ });
if (result.isErr()) {
return result;
@@ -201,54 +302,87 @@ export class UserCodeRunner {
const { jsFileMap, userCodeSourceMap } = result.unwrap();
- return this.executeUserCodeFromArtifacts(jsFileMap, userCodeSourceMap, args, timeout, context);
+ return this.executeUserCodeFromArtifacts(jsFileMap, userCodeSourceMap, args, timeout, {
+ globals: options.globals,
+ memoryLimitMb: options.memoryLimitMb,
+ });
}
- public async executeUserCodeFromArtifacts(
- jsFileMap: { [key: string]: string },
+ public async executeUserCodeFromArtifacts(
+ jsFileMap: Record,
sourceMap: string,
args: ArgsType,
- timeout: number = 5000,
- context: vm.Context = vm.createContext(),
- ): Promise> {
- // Put args and result into context
- context.__args = args;
- context.__result = undefined;
-
- // Create modules for VM
- const moduleCache = new Map();
- for (const [fileName, content] of Object.entries(jsFileMap)) {
- moduleCache.set(
- fileName,
- new vm.SourceTextModule(content, {
- identifier: fileName,
- context,
- }),
- );
- }
- const harnessModule = moduleCache.get(EXECUTION_HARNESS_FILENAME)!;
- await harnessModule.link(specifier => {
- const filenameSansExt = removeExt(specifier);
- if (moduleCache.has(filenameSansExt)) {
- return moduleCache.get(filenameSansExt)!;
- }
- throw new Error(`Unable to resolve dependency: ${specifier}`);
+ timeout = 5000,
+ options: ArtifactExecutionOptions = {},
+ ): Promise> {
+ const isolate = new ivm.Isolate({
+ memoryLimit: options.memoryLimitMb ?? 1024,
});
try {
- await harnessModule.evaluate({
- timeout,
+ const context = isolate.createContextSync();
+ const global = context.global;
+
+ // copy host values into the guest isolate so objects are guest-owned clones,
+ // not live host objects whose prototypes or constructors could expose host capabilities.
+ global.setSync('__args', args, { copy: true });
+ global.setSync('__result', undefined);
+ // global.setSync('__finalResult', undefined);
+
+ for (const [name, value] of Object.entries(options.globals ?? {})) {
+ if (name === '__args' || name === '__result' || name === '__finalResult') {
+ throw new Error(`Reserved global name: ${name}`);
+ }
+
+ global.setSync(name, value, { copy: true });
+ }
+
+ // Create modules for VM
+ const moduleCache = new Map();
+ for (const [fileName, content] of Object.entries(jsFileMap)) {
+ moduleCache.set(
+ fileName,
+ isolate.compileModuleSync(content, {
+ filename: fileName,
+ }),
+ );
+ }
+
+ // the harness module imports and invokes the user module,
+ // keeping execution and result capture inside the isolated context.
+ const harnessModule = moduleCache.get(EXECUTION_HARNESS_FILENAME);
+ if (harnessModule === undefined) {
+ throw new Error('Execution harness module is missing');
+ }
+
+ // recursively resolve & link the harness module’s imports
+ harnessModule.instantiateSync(context, specifier => {
+ // module names currently use a flat namespace; directory paths and extensions are discarded.
+ const module = moduleCache.get(removeExt(specifier));
+ if (module === undefined) {
+ throw new Error(`Unable to resolve dependency: ${specifier}`);
+ }
+ return module;
});
- const result = context.__result;
- delete context.__args;
- delete context.__result;
- return Result.Ok(result);
- } catch (error: any) {
- return Result.Err([UserCodeRuntimeError.new(error as Error, await new SourceMapConsumer(sourceMap))]);
+
+ // evaluate the resolved module
+ await harnessModule.evaluate({ timeout });
+ // copy guest results out as host-owned data; don't expose live guest reference.
+ const value = await global.get('__finalResult', { copy: true });
+
+ return Result.Ok(value as OutputType);
+ } catch (error) {
+ // errors from outside user code are "fatal" and will be re-thrown by new() to bubble up
+ const runtimeErr = UserCodeRuntimeError.new(error as Error, await new SourceMapConsumer(sourceMap));
+ // errors originating in user code are returned to the caller in a Result.Err
+ return Result.Err([runtimeErr]);
+ } finally {
+ isolate.dispose();
}
}
}
+
// Base error type for the User Code Runner
export abstract class UserCodeError {
// Simple Error Message
@@ -363,18 +497,11 @@ export class UserCodeRuntimeError extends UserCodeError {
private readonly sourceMap: SourceMapConsumer;
private readonly stackFrames: StackFrame[];
- protected constructor(error: Error, sourceMap: SourceMapConsumer) {
+ protected constructor(error: Error, sourceMap: SourceMapConsumer, stackFrames: StackFrame[]) {
super();
this.error = error;
this.sourceMap = sourceMap;
- this.stackFrames = parse(this.error);
- const userCodeFrame = this.stackFrames.find(frame => frame.getFileName() === USER_CODE_FILENAME);
- if (userCodeFrame === undefined) {
- this.error.message =
- 'Error: Runtime error detected outside of user code execution path. This is most likely a bug in the additional library source.\nInherited from:\n' +
- this.error.message;
- throw this.error;
- }
+ this.stackFrames = stackFrames;
}
public get message(): string {
@@ -422,7 +549,21 @@ export class UserCodeRuntimeError extends UserCodeError {
}
public static new(error: Error, sourceMap: SourceMapConsumer): UserCodeRuntimeError {
- return new UserCodeRuntimeError(error, sourceMap);
+ const stackFrames = parse(error);
+ const userCodeFrame = stackFrames.find(frame => frame.getFileName() === USER_CODE_FILENAME);
+
+ if (userCodeFrame === undefined) {
+ // errors from *outside* user code are thrown instead of wrapped in a Result.Err(UserCodeRuntimeError)
+ error.message =
+ 'Runtime error detected outside of user code execution path. ' +
+ 'This is most likely a bug in the additional library source.\n' +
+ 'Inherited from:\n' +
+ error.message;
+
+ throw error;
+ }
+
+ return new UserCodeRuntimeError(error, sourceMap, stackFrames);
}
}
diff --git a/test/UserCodeRunner.spec.ts b/test/UserCodeRunner.spec.ts
index 63fb331..3b1c84f 100644
--- a/test/UserCodeRunner.spec.ts
+++ b/test/UserCodeRunner.spec.ts
@@ -444,95 +444,142 @@ describe('behavior', () => {
expect(result.unwrap()).toBe('hello world');
});
- it('should accept additional source files', async () => {
- const userCode = `
- import { importedFunction } from 'other-importable';
+ it('should accept additional source files, and allow user code to import and invoke them', async () => {
+ const userCode = `
+ import { importedFunction } from 'other-importable';
+ export default function myDSLFunction(thing: string): string {
+ return importedFunction(thing + ' world');
+ }
+ `.trimTemplate();
+
+ const runner = new UserCodeRunner();
+
+ const result = await runner.executeUserCode(userCode, ['hello'], 'string', ['string'], 1000, [
+ ts.createSourceFile(
+ 'other-importable.ts',
+ `
+ export function importedFunction(thing: string): string {
+ return thing + ' other';
+ }
+ `.trimTemplate(),
+ ts.ScriptTarget.ESNext,
+ true,
+ ),
+ ]);
+
+ expect(result.unwrap()).toBe('hello world other');
+ });
+
+ it('should reject function globals, since they are non-cloneable and cannot be safely passed in', async () => {
+ const userCode = `
export default function myDSLFunction(thing: string): string {
- return someGlobalFunction(thing) + importedFunction(' world');
+ return someGlobalFunction(thing);
}
- `.trimTemplate();
-
- const runner = new UserCodeRunner();
+ `.trimTemplate();
- const result = await runner.executeUserCode(
- userCode,
- ['hello'],
- 'string',
- ['string'],
- 1000,
- [
- ts.createSourceFile('globals.d.ts', `
- declare global {
- function someGlobalFunction(thing: string): string;
+ const runner = new UserCodeRunner();
+
+ const resultPromise = runner.executeUserCode(
+ userCode,
+ ['hello'],
+ 'string',
+ ['string'],
+ 1000,
+ [
+ ts.createSourceFile(
+ 'globals.d.ts',
+ `
+ declare global {
+ function someGlobalFunction(thing: string): string;
+ }
+ export {};
+ `.trimTemplate(),
+ ts.ScriptTarget.ESNext,
+ true,
+ ),
+ ],
+ {
+ globals: {
+ someGlobalFunction: (thing: string) => `hello ${thing}`,
+ },
+ },
+ );
+
+ await expect(resultPromise).rejects.toThrow(
+ /Runtime error detected outside of user code execution path[\s\S]*could not be cloned/,
+ );
+ });
+
+ it('should serialize the result before copying it out, if serializer is provided', async () => {
+ const serializer = ts.createSourceFile(
+ 'result-serializer.ts',
+ `
+ export default function serializeResult(
+ result: { greet(name: string): string },
+ ): string {
+ return result.greet('world');
}
- export {};
- `.trimTemplate(), ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('other-importable.ts', `
- export function importedFunction(thing: string): string {
- return thing + ' other';
+ `,
+ ts.ScriptTarget.ESNext,
+ undefined,
+ ts.ScriptKind.TS,
+ );
+
+ const result = await new UserCodeRunner().executeUserCode<[], string>(
+ `
+ export default function() {
+ return {
+ greet(name: string): string {
+ return 'hello ' + name;
+ },
+ };
}
- `.trimTemplate(), ts.ScriptTarget.ESNext, true)
- ],
- vm.createContext({
- someGlobalFunction: (thing: string) => 'hello ' + thing, // Implementation injected to global namespace here
- }),
- );
-
- // expect(result.isOk()).toBeTruthy();
- expect(result.unwrap()).toBe('hello hello world other');
- });
-
- it('should handle unnamed arrow function default exports', async () => {
- const userCode = `
- type ExpansionProps = { activity: ActivityType };
-
- export default (props: ExpansionProps): ExpansionReturn => {
- const { activity } = props;
- const { biteSize } = activity.attributes.arguments;
-
- return [
- AVS_DMP_ADC_SNAPSHOT(biteSize)
- ];
- }
- `.trimTemplate()
-
- const runner = new UserCodeRunner();
- const [commandTypes, activityTypes, temporalPolyfill] = await Promise.all([
- fs.promises.readFile(new URL('./inputs/command-types.ts', import.meta.url).pathname, 'utf8'),
- fs.promises.readFile(new URL('./inputs/activity-types.ts', import.meta.url).pathname, 'utf8'),
- fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
- ]);
-
- const context = vm.createContext({
- Temporal,
- });
- const result = await runner.executeUserCode(
- userCode,
- [{ activity: null}],
- 'Command[] | Command | null',
- ['{ activity: ActivityType }'],
- 1000,
- [
- ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
- ],
- context,
- );
-
- expect(result.isErr()).toBeTruthy();
- expect(result.unwrapErr().length).toBe(3);
- expect(result.unwrapErr()[0].message).toBe(`
- TypeError: TS2322 Incorrect return type. Expected: 'Command[] | Command | null | Promise', Actual: 'ExpansionReturn'.
- `.trimTemplate());
- expect(result.unwrapErr()[0].stack).toBe(`
- at (3:1)
- `.trimTemplate())
- expect(result.unwrapErr()[0].location).toMatchObject({
- line: 3,
- column: 1,
- });
- });
+ `,
+ [],
+ '{ greet(name: string): string }',
+ [],
+ 1000,
+ [serializer],
+ {
+ resultSerializer: {
+ moduleName: 'result-serializer',
+ outputType: 'string',
+ },
+ },
+ );
+
+ expect(result.unwrap()).toBe('hello world');
+ });
+
+ it('should handle unnamed arrow function default exports', async () => {
+ const runner = new UserCodeRunner();
+
+ const result = await runner.executeUserCode(
+ `export default (thing: string): string => thing + ' world';`,
+ ['hello'],
+ 'string',
+ ['string'],
+ 1000,
+ );
+
+ expect(result.unwrap()).toBe('hello world');
+ });
+
+ it('should handle unnamed arrow function default exports assignment', async () => {
+ const runner = new UserCodeRunner();
+
+ const result = await runner.executeUserCode(`
+ const myDSLFunction = (thing: string): string => thing + ' world';
+ export default myDSLFunction;
+ `.trimTemplate(),
+ ['hello'],
+ 'string',
+ ['string'],
+ 1000,
+ );
+
+ expect(result.unwrap()).toBe('hello world');
+ });
it('should handle exported variable that references an arrow function', async () => {
const userCode = `
@@ -556,9 +603,6 @@ describe('behavior', () => {
fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({
- Temporal,
- });
const result = await runner.executeUserCode(
userCode,
[{ activity: null}],
@@ -570,7 +614,7 @@ describe('behavior', () => {
ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
],
- context,
+ { globals: { Temporal } },
);
expect(result.isErr()).toBeTruthy();
@@ -609,22 +653,19 @@ describe('behavior', () => {
fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({
- Temporal,
- });
const result = await runner.executeUserCode(
- userCode,
- [{ activity: null}],
- 'Command[] | Command | null',
- ['{ activity: ActivityType }'],
- 1000,
- [
- ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
- ],
- context,
- );
+ userCode,
+ [{ activity: null }],
+ 'Command[] | Command | null',
+ ['{ activity: ActivityType }'],
+ 1000,
+ [
+ ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
+ ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
+ ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
+ ],
+ { globals: { Temporal } },
+ );
expect(result.isErr()).toBeTruthy();
expect(result.unwrapErr().length).toBe(3);
@@ -640,52 +681,6 @@ describe('behavior', () => {
});
});
- it('should handle unnamed arrow function default exports assignment', async () => {
- const userCode = `
- type ExpansionProps = { activity: ActivityType };
-
- const myExpansion = (props: ExpansionProps) => {
- const { activity } = props;
- const { primitiveLong } = activity.attributes.arguments;
-
- if (true) {
- return undefined;
- }
-
- return [
- PREHEAT_OVEN(primitiveLong)
- ];
- };
- export default myExpansion;
- `.trimTemplate()
-
- const runner = new UserCodeRunner();
- const [commandTypes, activityTypes, temporalPolyfill] = await Promise.all([
- fs.promises.readFile(new URL('./inputs/command-types.ts', import.meta.url).pathname, 'utf8'),
- fs.promises.readFile(new URL('./inputs/activity-types.ts', import.meta.url).pathname, 'utf8'),
- fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
- ]);
-
- const context = vm.createContext({
- Temporal,
- });
- const result = await runner.executeUserCode(
- userCode,
- [{ activity: { attributes: { arguments: { primitiveLong: 1 } } } }],
- 'Command[] | Command | null',
- ['{ activity: ActivityType }'],
- 1000,
- [
- ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
- ],
- context,
- );
-
- expect(result.isOk()).toBeTruthy();
- });
-
it('should handle throws in user code but outside default function execution path', async () => {
const userCode = `
export default function MyDSLFunction(thing: string): string {
@@ -718,43 +713,40 @@ describe('behavior', () => {
});
});
- it('should handle throws in library code outside default function execution path with an explicit error', async () => {
- const userCode = `
- export default function MyDSLFunction(thing: string): string {
- return thing + ' world';
- }
- `.trimTemplate();
+ it('should handle throws in library code outside default function execution path with an explicit error', async () => {
+ const userCode = `
+ export default function MyDSLFunction(thing: string): string {
+ return thing + ' world';
+ }
+ `.trimTemplate();
- const runner = new UserCodeRunner();
+ const runner = new UserCodeRunner();
- try {
- await runner.executeUserCode(
- userCode,
- ['hello'],
- 'string',
- ['string'],
- 1000,
- [
- ts.createSourceFile('additionalFile.ts', `
- export {}
- throw new Error('This is a test error');
- `.trimTemplate(), ts.ScriptTarget.ESNext, true),
- ],
- );
- } catch (err: any) {
- expect(err.message).toBe(`
- Error: Runtime error detected outside of user code execution path. This is most likely a bug in the additional library source.
- Inherited from:
- This is a test error
- `.trimTemplate());
- expect(err.stack).toContain(`
- Error: This is a test error
- at additionalFile:1:7
- `.trimTemplate());
- expect(err.stack).toMatch(/at SourceTextModule.evaluate \(node:internal\/vm\/module:\d+:\d+\)/);
- expect(err.stack).toMatch(/at UserCodeRunner\.executeUserCodeFromArtifacts \(\S+src\/UserCodeRunner\.ts:\d+:\d+/);
- }
- });
+ const resultPromise = runner.executeUserCode(userCode, ['hello'], 'string', ['string'], 1000, [
+ ts.createSourceFile(
+ 'additionalFile.ts',
+ `
+ export {};
+ throw new Error('This is a test error');
+ `.trimTemplate(),
+ ts.ScriptTarget.ESNext,
+ true,
+ ),
+ ]);
+
+ const expectedMessage = `
+Runtime error detected outside of user code execution path. This is most likely a bug in the additional library source.
+Inherited from:
+This is a test error
+ `.trimTemplate();
+ await expect(resultPromise).rejects.toThrow(expectedMessage);
+ await expect(resultPromise).rejects.toHaveProperty(
+ 'stack',
+ expect.stringMatching(
+ /Error: Runtime error detected[\s\S]*This is a test error[\s\S]*at additionalFile:\d+:\d+/,
+ ),
+ );
+ });
it('should allow preprocessing of user code and subsequent execution', async () => {
const userCode = `
@@ -1076,22 +1068,36 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({
- Temporal,
- });
const result = await runner.executeUserCode(
- userCode,
- [{ activity: null }],
- 'Command[] | Command | null',
- ['{ activity: ActivityType }'],
- 1000,
- [
- ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
- ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
- ],
- context,
- );
+ userCode,
+ [{ activity: null }],
+ 'Command[] | Command | null',
+ ['{ activity: ActivityType }'],
+ 1000,
+ [
+ ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext, true),
+ ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
+ ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
+ // dependencies like Temporal *must* be passed as sourceFiles, not in context/globals
+ ts.createSourceFile(
+ 'temporal-bundle-stub.ts', `
+ class Duration {
+ static from(value: string) {
+ return new Duration();
+ }
+ }
+
+ Object.defineProperty(globalThis, 'Temporal', {
+ value: { Duration },
+ writable: false,
+ configurable: false,
+ });
+ `.trimTemplate(),
+ ts.ScriptTarget.ESNext,
+ true,
+ ),
+ ]
+ );
expect(result.unwrap()).toMatchObject({
stem: 'BAKE_BREAD',
@@ -1122,9 +1128,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({
- Temporal,
- });
const result = await runner.executeUserCode(
userCode,
[{ activityInstance: null }, {}],
@@ -1136,7 +1139,7 @@ describe('regression tests', () => {
ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
],
- context,
+ {}
);
expect(result.isErr()).toBeTruthy();
@@ -1207,7 +1210,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1219,7 +1221,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {},
);
expect(result.unwrap()).toMatchObject({
@@ -1263,7 +1265,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/dsl-model-specific--2345.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1275,7 +1276,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {},
);
expect(result.isErr()).toBeTruthy();
@@ -1307,20 +1308,19 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
- userCode,
- [],
- 'Goal',
- [],
- undefined,
- [
- ts.createSourceFile('scheduler-ast.ts', schedulerAst, ts.ScriptTarget.ESNext),
- ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
- ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
- ],
- context,
- );
+ userCode,
+ [],
+ 'Goal',
+ [],
+ undefined,
+ [
+ ts.createSourceFile('scheduler-ast.ts', schedulerAst, ts.ScriptTarget.ESNext),
+ ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
+ ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
+ ],
+ {},
+ );
expect(result.isErr()).toBeTruthy();
expect(result.unwrapErr().length).toBe(1);
@@ -1350,7 +1350,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1362,7 +1361,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {}
);
expect(result.isOk()).toBeTruthy();
@@ -1385,7 +1384,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1397,7 +1395,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {},
);
expect(result.isErr()).toBeTruthy();
@@ -1428,7 +1426,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1440,7 +1437,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {}
);
expect(result.isErr()).toBeTruthy();
@@ -1471,9 +1468,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/TemporalPolyfillTypes.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({
- Temporal,
- });
const result = await runner.executeUserCode(
userCode,
[{ activity: null }],
@@ -1485,7 +1479,7 @@ describe('regression tests', () => {
ts.createSourceFile('activity-types.ts', activityTypes, ts.ScriptTarget.ESNext, true),
ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext, true),
],
- context,
+ {},
);
expect(result.isErr()).toBeTruthy();
@@ -1548,7 +1542,6 @@ describe('regression tests', () => {
fs.promises.readFile(new URL('./inputs/mission-model-generated-code.ts', import.meta.url).pathname, 'utf8'),
]);
- const context = vm.createContext({});
const result = await runner.executeUserCode(
userCode,
[],
@@ -1560,7 +1553,7 @@ describe('regression tests', () => {
ts.createSourceFile('scheduler-edsl-fluent-api.ts', schedulerEdsl, ts.ScriptTarget.ESNext),
ts.createSourceFile('mission-model-generated-code.ts', modelSpecific, ts.ScriptTarget.ESNext),
],
- context,
+ {},
);
expect(result.isErr()).toBeTruthy();
@@ -1641,9 +1634,7 @@ describe('regression tests', () => {
ts.createSourceFile('command-types.ts', commandTypes, ts.ScriptTarget.ESNext),
ts.createSourceFile('TemporalPolyfillTypes.ts', temporalPolyfill, ts.ScriptTarget.ESNext),
],
- vm.createContext({
- Temporal,
- }),
+ { globals: { Temporal } },
);
expect(result.isErr()).toBeTruthy();
diff --git a/test/isolation.spec.ts b/test/isolation.spec.ts
new file mode 100644
index 0000000..28e9cf8
--- /dev/null
+++ b/test/isolation.spec.ts
@@ -0,0 +1,128 @@
+import { describe, it } from 'node:test';
+import { expect } from 'expect';
+
+import { UserCodeRunner, UserCodeGlobals } from '../src/UserCodeRunner';
+
+const PROCESS_UNAVAILABLE = 'process unavailable';
+
+/**
+ * security regression tests for the user-code boundary.
+ *
+ * access to the host `process` object enables arbitrary code execution through
+ * node capabilities such as environment variables, filesystem access, networking,
+ * native bindings, and child processes.
+ *
+ * a clean guest realm cannot access `process`, but host objects passed into it
+ * may expose the host `Function` constructor through their constructor chain.
+ * these tests ensure runner- and caller-provided values do not create that bridge.
+ */
+
+interface ExecuteOptions {
+ args?: unknown[];
+ argsTypes?: string[];
+ globals?: UserCodeGlobals;
+}
+
+async function execute(source: string, options: ExecuteOptions = {}): Promise {
+ const args = options.args ?? [];
+ const argsTypes = options.argsTypes ?? args.map(() => 'any');
+
+ const result = await new UserCodeRunner().executeUserCode(source, args, 'any', argsTypes, 1000, [], options.globals);
+
+ return result.unwrap();
+}
+
+describe('UserCodeRunner isolation', () => {
+ it('does not expose process through guest-realm intrinsics', async () => {
+ const value = await execute(`
+ export default function(): string {
+ try {
+ return Function('return process.version')();
+ } catch {
+ return '${PROCESS_UNAVAILABLE}';
+ }
+ }
+ `);
+
+ expect(value).toBe(PROCESS_UNAVAILABLE);
+ });
+
+ it('does not expose process through the internal argument array', async () => {
+ const value = await execute(`
+ export default function(): string {
+ try {
+ const args = (globalThis as any).__args;
+ return args.constructor.constructor(
+ 'return process.version',
+ )();
+ } catch {
+ return '${PROCESS_UNAVAILABLE}';
+ }
+ }
+ `);
+
+ expect(value).toBe(PROCESS_UNAVAILABLE);
+ });
+
+ it('does not expose process through a user-code argument', async () => {
+ const value = await execute(
+ `
+ export default function(props: any): string {
+ try {
+ return props.constructor.constructor(
+ 'return process.version',
+ )();
+ } catch {
+ return '${PROCESS_UNAVAILABLE}';
+ }
+ }
+ `,
+ { args: [{}] },
+ );
+
+ expect(value).toBe(PROCESS_UNAVAILABLE);
+ });
+
+ it('does not expose process through a nested user-code argument', async () => {
+ const value = await execute(
+ `
+ export default function(props: any): string {
+ try {
+ return props.nested.constructor.constructor(
+ 'return process.version',
+ )();
+ } catch {
+ return '${PROCESS_UNAVAILABLE}';
+ }
+ }
+ `,
+ {args: [{ nested: {} }]},
+ );
+
+ expect(value).toBe(PROCESS_UNAVAILABLE);
+ });
+
+ it('does not expose process through an explicitly provided globals value', async () => {
+ const value = await execute(
+ `
+ declare const hostValue: any;
+
+ export default function(): string {
+ try {
+ return hostValue.constructor.constructor(
+ 'return process.version',
+ )();
+ } catch {
+ return '${PROCESS_UNAVAILABLE}';
+ }
+ }
+ `,
+ {
+ args: [],
+ globals: { hostValue: {} },
+ },
+ );
+
+ expect(value).toBe(PROCESS_UNAVAILABLE);
+ });
+});