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); + }); +});