From 83f83bee0619dd6d689b60fc86ebdde58fcaf303 Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 09:09:59 -0600 Subject: [PATCH 1/6] Implement Sandbox code mode runtime --- README.md | 54 +- package-lock.json | 1136 ++++++++++++++++++++++++++++++++++++++++++- package.json | 5 +- src/index.test.ts | 448 +++++++++++++++++ src/index.ts | 361 +++++++++++++- src/package.test.ts | 21 + 6 files changed, 1999 insertions(+), 26 deletions(-) create mode 100644 src/index.test.ts create mode 100644 src/package.test.ts diff --git a/README.md b/README.md index 4934496..2d16991 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,59 @@ required to use those capabilities together. ## Install ```sh -npm install @torkbot/code-mode-sandbox +npm install @torkbot/code-mode @torkbot/code-mode-sandbox @torkbot/sandbox +``` + +## Usage + +Define the machine with Sandbox, then open a code-mode session for it: + +```ts +import { createClient } from "@torkbot/code-mode"; +import { + openSandboxCodeMode, +} from "@torkbot/code-mode-sandbox"; +import { defineSandbox } from "@torkbot/sandbox"; + +const definition = defineSandbox({ + rootfs: machineRootfs, + resources: { + cpus: 2, + memoryMiB: 2048, + }, +}); + +await using session = await openSandboxCodeMode({ + definition, + boot: { + cwd: "/workspace", + }, + nodePath: "/usr/bin/node", +}); + +const client = createClient({ + toolbox, + runtime: session.runtime, +}); +``` + +The absolute guest working directory and Node.js path are required because they +determine module resolution and the executable used for both validation and +execution. Reusing a persistent root filesystem remains a property of the +supplied Sandbox definition; this package does not infer machine identity or +persistence policy. + +Callers that already own a booted `SandboxInstance` can construct a runtime +without transferring the VM lifecycle: + +```ts +import { SandboxNodeRuntime } from "@torkbot/code-mode-sandbox"; + +const runtime = new SandboxNodeRuntime({ + sandbox, + cwd: "/workspace", + nodePath: "/usr/bin/node", +}); ``` ## Development diff --git a/package-lock.json b/package-lock.json index f8714b6..e9d509d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@torkbot/code-mode-sandbox", "version": "0.0.0-dev", "license": "MIT", + "dependencies": { + "@torkbot/code-mode": "^0.1.0", + "@torkbot/sandbox": "^0.14.0" + }, "devDependencies": { "@types/node": "^24.12.0", "typescript": "^7.0.2" @@ -16,11 +20,188 @@ "node": ">=24.0.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mongodb-js/zstd": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/zstd/-/zstd-7.0.0.tgz", + "integrity": "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@torkbot/code-mode": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@torkbot/code-mode/-/code-mode-0.1.0.tgz", + "integrity": "sha512-Zl8ERUNW+NBJalkeV+fbQOsZfmhrdezXtUJOVe1QB+ELECGsLftFHMIEsIi6DkOGXsDfRBokrJ5RIWLUq7kaEA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/node": "^24.12.0", + "amaro": "^1.1.10", + "bson": "^7.3.1", + "flatted": "^3.4.2", + "typebox": "^1.3.3", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@torkbot/sandbox": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@torkbot/sandbox/-/sandbox-0.14.0.tgz", + "integrity": "sha512-k0/cYoChOKvvp3brhnHfKM+basNjJKF8nA7g7RE3dVXFhk7/j7I8apXer3NHdOqpX/HR5O2Tg92FegNEceUkAQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "bson": "^7.2.0", + "just-bash": "^2.14.5" + }, + "bin": { + "sandbox": "dist/cli.js" + }, + "engines": { + "node": ">=24.0.0" + }, + "optionalDependencies": { + "@torkbot/sandbox-darwin-arm64": "0.14.0", + "@torkbot/sandbox-linux-x64-gnu": "0.14.0" + } + }, + "node_modules/@torkbot/sandbox-darwin-arm64": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@torkbot/sandbox-darwin-arm64/-/sandbox-darwin-arm64-0.14.0.tgz", + "integrity": "sha512-2mKl1yYyKSg1isj9tRWvz/PVBcmK40OLhT50Zt47jWrw5voQfGcDRFyRXsCBfEoPYwyeiKbETc4zMYAdTcStIw==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@torkbot/sandbox-linux-x64-gnu": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@torkbot/sandbox-linux-x64-gnu/-/sandbox-linux-x64-gnu-0.14.0.tgz", + "integrity": "sha512-8D53OVBUjIpN6BO7iruCWs7q5HapRdipM81ObmfXv/WRZ41HXEmrEyZFqbxzL21iy2qbxCa4T8T/N58x8h5mkQ==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@types/node": { "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" @@ -33,7 +214,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -50,7 +230,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -67,7 +246,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -84,7 +262,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -101,7 +278,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -118,7 +294,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -135,7 +310,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -152,7 +326,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -169,7 +342,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -186,7 +358,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -203,7 +374,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -220,7 +390,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -237,7 +406,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -254,7 +422,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -271,7 +438,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -288,7 +454,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -305,7 +470,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -322,7 +486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -339,7 +502,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -356,7 +518,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -366,11 +527,885 @@ "node": ">=16.20.0" } }, + "node_modules/amaro": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/amaro/-/amaro-1.1.10.tgz", + "integrity": "sha512-ceFv+QA3SlhFsn0hu8Q8oyj36YZdIgoJFpyS2sGJGK2dyncwcMWuBlNzhXfc1oLWtbDWM2Ol2rrzOYa+HNyEjg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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", + "optional": true + }, + "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", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bson": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "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", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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", + "optional": true + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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", + "optional": true, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "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", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "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)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.0.tgz", + "integrity": "sha512-SLhnTEqE5QpJHq/6zl9bsmImEP2adv+y6Wy+cJa7nVTRzQh1OZfCe9k29M5xN74LWnu0xa1zrUrq3KnOKl92Fg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "license": "ISC" + }, + "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", + "optional": true + }, + "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", + "optional": true + }, + "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", + "optional": true + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/just-bash": { + "version": "2.14.5", + "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-2.14.5.tgz", + "integrity": "sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g==", + "license": "Apache-2.0", + "dependencies": { + "diff": "^8.0.2", + "fast-xml-parser": "^5.7.3", + "file-type": "^21.2.0", + "ini": "^6.0.0", + "minimatch": "^10.1.1", + "modern-tar": "^0.7.3", + "papaparse": "^5.5.3", + "quickjs-emscripten": "^0.32.0", + "re2js": "^1.2.1", + "seek-bzip": "^2.0.0", + "smol-toml": "^1.6.0", + "sprintf-js": "^1.1.3", + "sql.js": "^1.13.0", + "turndown": "^7.2.2", + "yaml": "^2.8.2" + }, + "bin": { + "just-bash": "dist/bin/just-bash.js", + "just-bash-shell": "dist/bin/shell/shell.js" + }, + "optionalDependencies": { + "@mongodb-js/zstd": "^7.0.0", + "node-liblzma": "^2.0.3" + } + }, + "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", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "optional": true + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "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", + "optional": true + }, + "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", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-liblzma": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-liblzma/-/node-liblzma-2.2.0.tgz", + "integrity": "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==", + "hasInstallScript": true, + "license": "LGPL-3.0", + "optional": true, + "dependencies": { + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "bin": { + "nxz": "lib/cli/nxz.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/oorabona" + } + }, + "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", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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", + "optional": true, + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "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)", + "optional": true, + "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/rc/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", + "optional": true + }, + "node_modules/re2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-1.3.3.tgz", + "integrity": "sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==", + "license": "MIT" + }, + "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", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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", + "optional": true + }, + "node_modules/seek-bzip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", + "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", + "license": "MIT", + "dependencies": { + "commander": "^6.0.0" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "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", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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", + "optional": true + }, + "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", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, + "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", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "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/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "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", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, + "node_modules/typebox": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.6.tgz", + "integrity": "sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc" @@ -401,12 +1436,67 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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", + "optional": true + }, + "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", + "optional": true + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 2d47295..f69e1ae 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "devDependencies": { "@types/node": "^24.12.0", "typescript": "^7.0.2" + }, + "dependencies": { + "@torkbot/code-mode": "^0.1.0", + "@torkbot/sandbox": "^0.14.0" } } - diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..1231208 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,448 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + SandboxBootOptions, + SandboxDefinition, + SandboxExecOptions, + SandboxExecResult, + SandboxInstance, + SandboxProcess, + SandboxProcessExit, + SandboxSignal, + SandboxSpawnOptions, +} from "@torkbot/sandbox"; + +import { + openSandboxCodeMode, + SandboxNodeRuntime, +} from "./index.ts"; + +test("openSandboxCodeMode boots the supplied definition and owns its lifecycle", async () => { + const sandbox = createLifecycleSandbox(); + let observedBoot: SandboxBootOptions | undefined; + const definition: SandboxDefinition = { + environmentFacts: () => [], + async boot(options) { + observedBoot = options; + return sandbox.instance; + }, + }; + + const session = await openSandboxCodeMode({ + definition, + boot: { + cwd: "/workspace", + hostname: "code-mode-test", + }, + nodePath: "/usr/bin/node", + }); + + assert.deepEqual(observedBoot, { + cwd: "/workspace", + hostname: "code-mode-test", + }); + assert.equal(session.sandbox, sandbox.instance); + assert.ok(session.runtime instanceof SandboxNodeRuntime); + + await Promise.all([ + session.close(), + session.close(), + session[Symbol.asyncDispose](), + ]); + assert.equal(sandbox.closeCalls(), 1); +}); + +test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process pipes", async () => { + const process = createSandboxProcess({ + channelOutput: [new TextEncoder().encode("runtime output")], + }); + const sandbox = createRuntimeSandbox(process.instance); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + const signal = AbortSignal.timeout(5_000); + + const instance = await runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() { /* payload sentinel */ }", + }, + signal, + }); + + assert.deepEqual(sandbox.execCalls(), [{ + command: "/usr/bin/node", + args: ["--version"], + options: { + cwd: "/workspace", + signal, + }, + }]); + assert.deepEqual(sandbox.spawnCalls(), [{ + command: "/usr/bin/node", + args: ["--input-type=module"], + options: { + cwd: "/workspace", + pipes: [3], + }, + }]); + assert.equal(process.stdinClosed(), true); + assert.match( + new TextDecoder().decode(joinBytes(process.stdinWrites())), + /payload sentinel/, + ); + assert.deepEqual( + (await collect(instance.channel.incoming)).map((chunk) => ( + new TextDecoder().decode(chunk) + )), + ["runtime output"], + ); + + await instance.channel.outgoing.write(new TextEncoder().encode("host input")); + await Promise.all([ + instance.channel.outgoing.close(), + instance.channel.outgoing.close(), + ]); + assert.equal(process.channelInputClosed(), true); + assert.equal( + new TextDecoder().decode(joinBytes(process.channelInputWrites())), + "host input", + ); + + await Promise.all([ + instance.terminate("test complete"), + instance.terminate("test complete again"), + ]); + assert.deepEqual(process.kills(), ["SIGTERM"]); + assert.deepEqual(await instance.finished, { kind: "closed" }); +}); + +test("SandboxNodeRuntime rejects launch when Sandbox omits the requested pipe", async () => { + const process = createSandboxProcess({ includeChannel: false }); + const sandbox = createRuntimeSandbox(process.instance); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + + await assert.rejects(runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() {}", + }, + signal: AbortSignal.timeout(5_000), + }), /did not create fd 3/); + assert.deepEqual(process.kills(), ["SIGTERM"]); +}); + +test("SandboxNodeRuntime rejects a non-Node-24 guest before spawning", async () => { + const process = createSandboxProcess(); + const sandbox = createRuntimeSandbox(process.instance, { + exitCode: 0, + stdout: "v23.11.0\n", + stderr: "", + }); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + + await assert.rejects(runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() {}", + }, + signal: AbortSignal.timeout(5_000), + }), /requires Node\.js 24/); + assert.deepEqual(sandbox.spawnCalls(), []); +}); + +test("SandboxNodeRuntime cleans up and rejects Sandbox launch failures", async () => { + const process = createSandboxProcess({ + readyError: new Error("guest launch failed"), + }); + const sandbox = createRuntimeSandbox(process.instance); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + + await assert.rejects(runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() {}", + }, + signal: AbortSignal.timeout(5_000), + }), /guest launch failed/); + assert.deepEqual(process.kills(), ["SIGTERM"]); +}); + +test("SandboxNodeRuntime reports unexpected guest process failures with stderr", async () => { + const process = createSandboxProcess({ stderr: "guest exploded" }); + const sandbox = createRuntimeSandbox(process.instance); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + const instance = await runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() {}", + }, + signal: AbortSignal.timeout(5_000), + }); + + process.resolveExit({ exitCode: 1, signal: null }); + const finished = await instance.finished; + assert.equal(finished.kind, "failed"); + if (finished.kind === "failed") { + assert.match(finished.error.message, /exit code 1/); + assert.match(finished.error.message, /guest exploded/); + } +}); + +test("SandboxNodeRuntime aborts a launched process and resolves it as closed", async () => { + const process = createSandboxProcess(); + const sandbox = createRuntimeSandbox(process.instance); + const runtime = new SandboxNodeRuntime({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "/workspace", + }); + const controller = new AbortController(); + const instance = await runtime.start({ + payload: { + kind: "javascript-module", + source: "export async function startProgram() {}", + }, + signal: controller.signal, + }); + + controller.abort(new Error("cancel runtime")); + + assert.deepEqual(await instance.finished, { kind: "closed" }); + assert.deepEqual(process.kills(), ["SIGTERM"]); +}); + +type ObservedExec = { + readonly command: string; + readonly args: readonly string[] | undefined; + readonly options: SandboxExecOptions | undefined; +}; + +type ObservedSpawn = { + readonly command: string; + readonly args: readonly string[] | undefined; + readonly options: SandboxSpawnOptions | undefined; +}; + +function createRuntimeSandbox( + process: SandboxProcess, + versionResult: SandboxExecResult = { + exitCode: 0, + stdout: "v24.12.0\n", + stderr: "", + }, +): { + readonly instance: SandboxInstance; + execCalls(): readonly ObservedExec[]; + spawnCalls(): readonly ObservedSpawn[]; +} { + const execCalls: ObservedExec[] = []; + const spawnCalls: ObservedSpawn[] = []; + const unsupported = (): never => { + throw new Error("not used by this runtime host test"); + }; + + return { + instance: { + fs: { + stat: unsupported, + readDir: unsupported, + readFile: unsupported, + writeFile: unsupported, + mkdir: unsupported, + remove: unsupported, + rename: unsupported, + }, + async environmentFacts() { + return []; + }, + async exec(command, args, options): Promise { + execCalls.push({ command, args, options }); + return versionResult; + }, + spawn(command, args, options) { + spawnCalls.push({ command, args, options }); + return process; + }, + pty: unsupported, + async close() {}, + async [Symbol.asyncDispose]() {}, + }, + execCalls: () => execCalls, + spawnCalls: () => spawnCalls, + }; +} + +function createLifecycleSandbox(): { + readonly instance: SandboxInstance; + closeCalls(): number; +} { + let closeCalls = 0; + const unsupported = (): never => { + throw new Error("not used by this lifecycle test"); + }; + const close = async (): Promise => { + closeCalls += 1; + }; + + return { + instance: { + fs: { + stat: unsupported, + readDir: unsupported, + readFile: unsupported, + writeFile: unsupported, + mkdir: unsupported, + remove: unsupported, + rename: unsupported, + }, + environmentFacts: unsupported, + exec: unsupported, + spawn: unsupported, + pty: unsupported, + close, + [Symbol.asyncDispose]: close, + }, + closeCalls: () => closeCalls, + }; +} + +function createSandboxProcess(options: { + readonly channelOutput?: readonly Uint8Array[]; + readonly includeChannel?: boolean; + readonly readyError?: Error; + readonly stderr?: string; +} = {}): { + readonly instance: SandboxProcess; + channelInputClosed(): boolean; + channelInputWrites(): readonly Uint8Array[]; + stdinClosed(): boolean; + stdinWrites(): readonly Uint8Array[]; + kills(): readonly SandboxSignal[]; + resolveExit(exit: SandboxProcessExit): void; +} { + const stdin = createWritableRecorder(); + const channelInput = createWritableRecorder(); + const exit = deferred(); + const kills: SandboxSignal[] = []; + const includeChannel = options.includeChannel ?? true; + + return { + instance: { + stdin: stdin.stream, + stdout: readableFrom([]), + stderr: readableFrom([ + new TextEncoder().encode(options.stderr ?? ""), + ]), + pipes: includeChannel + ? new Map([[3, { + input: channelInput.stream, + output: readableFrom(options.channelOutput ?? []), + }]]) + : new Map(), + ready: options.readyError === undefined + ? Promise.resolve() + : Promise.reject(options.readyError), + exit: exit.promise, + kill(signal = "SIGTERM") { + kills.push(signal); + exit.resolve({ exitCode: null, signal }); + }, + }, + channelInputClosed: channelInput.closed, + channelInputWrites: channelInput.writes, + stdinClosed: stdin.closed, + stdinWrites: stdin.writes, + kills: () => kills, + resolveExit: exit.resolve, + }; +} + +function createWritableRecorder(): { + readonly stream: WritableStream; + closed(): boolean; + writes(): readonly Uint8Array[]; +} { + const writes: Uint8Array[] = []; + let closed = false; + return { + stream: new WritableStream({ + write(chunk) { + writes.push(chunk.slice()); + }, + close() { + closed = true; + }, + }), + closed: () => closed, + writes: () => writes, + }; +} + +function readableFrom( + chunks: readonly Uint8Array[], +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); +} + +function deferred(): { + readonly promise: Promise; + resolve(value: T): void; +} { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value) { + assert.ok(resolvePromise !== undefined); + resolvePromise(value); + }, + }; +} + +function joinBytes(chunks: readonly Uint8Array[]): Uint8Array { + const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const joined = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.length; + } + return joined; +} + +async function collect(values: AsyncIterable): Promise { + const collected: T[] = []; + for await (const value of values) { + collected.push(value); + } + return collected; +} diff --git a/src/index.ts b/src/index.ts index 638ec62..cb56dd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,361 @@ -export {}; +import { + Node24Runtime, + type Node24RuntimeHost, + type Node24RuntimeLaunchRequest, +} from "@torkbot/code-mode/node"; +import type { + ByteChannel, + ByteWriter, + RuntimeFinished, + RuntimeInstance, +} from "@torkbot/code-mode/runtime"; +import type { + SandboxBootOptions, + SandboxDefinition, + SandboxInstance, + SandboxProcess, + SandboxProcessExit, +} from "@torkbot/sandbox"; +const maximumStderrLength = 64 * 1024; +const terminationGracePeriodMilliseconds = 1_000; + +export interface SandboxNodeRuntimeOptions { + readonly sandbox: SandboxInstance; + readonly nodePath: string; + readonly cwd: string; +} + +/** Runs code-mode payloads with Node.js 24 inside an existing Sandbox VM. */ +export class SandboxNodeRuntime extends Node24Runtime { + constructor(options: SandboxNodeRuntimeOptions) { + super(new SandboxNodeRuntimeHost(options)); + } +} + +export type SandboxCodeModeBootOptions = Omit & { + readonly cwd: string; +}; + +export interface OpenSandboxCodeModeOptions { + readonly definition: SandboxDefinition; + readonly boot: SandboxCodeModeBootOptions; + readonly nodePath: string; +} + +export interface SandboxCodeModeSession extends AsyncDisposable { + readonly sandbox: SandboxInstance; + readonly runtime: SandboxNodeRuntime; + close(): Promise; +} + +/** + * Boots one Sandbox VM and pairs its lifecycle with a code-mode Node runtime. + * The supplied definition remains the authority for persistence, resources, + * mounts, and network access. + */ +export async function openSandboxCodeMode( + options: OpenSandboxCodeModeOptions, +): Promise { + const sandbox = await options.definition.boot(options.boot); + const runtime = new SandboxNodeRuntime({ + sandbox, + nodePath: options.nodePath, + cwd: options.boot.cwd, + }); + + return new OpenSandboxCodeModeSession(sandbox, runtime); +} + +class OpenSandboxCodeModeSession implements SandboxCodeModeSession { + readonly sandbox: SandboxInstance; + readonly runtime: SandboxNodeRuntime; + #closing: Promise | undefined; + + constructor(sandbox: SandboxInstance, runtime: SandboxNodeRuntime) { + this.sandbox = sandbox; + this.runtime = runtime; + } + + close(): Promise { + this.#closing ??= this.sandbox.close(); + return this.#closing; + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +class SandboxNodeRuntimeHost implements Node24RuntimeHost { + readonly #sandbox: SandboxInstance; + readonly #nodePath: string; + readonly #cwd: string; + + constructor(options: SandboxNodeRuntimeOptions) { + this.#sandbox = options.sandbox; + this.#nodePath = options.nodePath; + this.#cwd = options.cwd; + } + + async readNodeVersion(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const result = await this.#sandbox.exec( + this.#nodePath, + ["--version"], + { + cwd: this.#cwd, + signal, + }, + ); + signal.throwIfAborted(); + + if (result.exitCode !== 0) { + throw new Error(formatVersionFailure(result.exitCode, result.stderr)); + } + return result.stdout.trim(); + } + + async launchNode(req: Node24RuntimeLaunchRequest): Promise { + req.signal.throwIfAborted(); + const process = this.#sandbox.spawn( + this.#nodePath, + ["--input-type=module"], + { + cwd: this.#cwd, + pipes: [req.channelFileDescriptor], + }, + ); + const pipe = process.pipes.get(req.channelFileDescriptor); + if (pipe === undefined) { + await stopPartialLaunch(process); + throw new Error( + `Sandbox Node.js runtime did not create fd ${req.channelFileDescriptor}`, + ); + } + + const stdout = drain(process.stdout); + const stderr = readTextTail(process.stderr); + let terminationRequested = false; + let finishedSettled = false; + let forceTerminationTimeout: ReturnType | undefined; + + const requestTermination = (): void => { + if (finishedSettled || terminationRequested) { + return; + } + terminationRequested = true; + process.kill("SIGTERM"); + forceTerminationTimeout ??= setTimeout(() => { + process.kill("SIGKILL"); + }, terminationGracePeriodMilliseconds); + forceTerminationTimeout.unref(); + }; + const abort = (): void => requestTermination(); + + if (req.signal.aborted) { + abort(); + } else { + req.signal.addEventListener("abort", abort, { once: true }); + } + + const finished: Promise = (async () => { + try { + const [launchError, exit, , stderrText] = await Promise.all([ + process.ready.then( + () => null, + (error: unknown) => errorFromUnknown(error), + ), + process.exit, + stdout, + stderr, + ]); + + if (terminationRequested) { + return { kind: "closed" }; + } + if (launchError !== null) { + return { kind: "failed", error: launchError }; + } + if (exit.exitCode === 0 && exit.signal === null) { + return { kind: "closed" }; + } + return { + kind: "failed", + error: new Error(formatProcessFailure(exit, stderrText)), + }; + } catch (error) { + return { + kind: "failed", + error: errorFromUnknown(error), + }; + } finally { + finishedSettled = true; + req.signal.removeEventListener("abort", abort); + if (forceTerminationTimeout !== undefined) { + clearTimeout(forceTerminationTimeout); + } + } + })(); + + try { + await process.ready; + await writeAndClose( + process.stdin, + new TextEncoder().encode(req.bootstrapSource), + ); + } catch (error) { + requestTermination(); + await finished; + if (req.signal.aborted) { + throw req.signal.reason; + } + throw error; + } + + if (req.signal.aborted) { + requestTermination(); + await finished; + throw req.signal.reason; + } + + const channel: ByteChannel = { + incoming: readableChunks(pipe.output), + outgoing: new WebByteWriter(pipe.input), + }; + + return { + channel, + finished, + async terminate(_reason: string): Promise { + requestTermination(); + await finished; + }, + }; + } +} + +class WebByteWriter implements ByteWriter { + readonly #writer: WritableStreamDefaultWriter; + #closing: Promise | undefined; + + constructor(stream: WritableStream) { + this.#writer = stream.getWriter(); + } + + async write(chunk: Uint8Array): Promise { + if (this.#closing !== undefined) { + throw new Error("Sandbox Node.js runtime byte channel is closed"); + } + await this.#writer.write(chunk); + } + + close(): Promise { + this.#closing ??= this.#close(); + return this.#closing; + } + + async #close(): Promise { + try { + await this.#writer.close(); + } finally { + this.#writer.releaseLock(); + } + } +} + +async function stopPartialLaunch(process: SandboxProcess): Promise { + process.kill("SIGTERM"); + const forceTerminationTimeout = setTimeout(() => { + process.kill("SIGKILL"); + }, terminationGracePeriodMilliseconds); + forceTerminationTimeout.unref(); + try { + await Promise.allSettled([ + process.exit, + drain(process.stdout), + drain(process.stderr), + ]); + } finally { + clearTimeout(forceTerminationTimeout); + } +} + +async function writeAndClose( + stream: WritableStream, + contents: Uint8Array, +): Promise { + const writer = stream.getWriter(); + try { + await writer.write(contents); + await writer.close(); + } finally { + writer.releaseLock(); + } +} + +async function* readableChunks( + stream: ReadableStream, +): AsyncIterable { + const reader = stream.getReader(); + try { + while (true) { + const result = await reader.read(); + if (result.done) { + return; + } + yield result.value; + } + } finally { + reader.releaseLock(); + } +} + +async function drain(stream: ReadableStream): Promise { + for await (const _chunk of readableChunks(stream)) { + // Drain output so an unconsumed stream cannot block process completion. + } +} + +async function readTextTail( + stream: ReadableStream, +): Promise { + const decoder = new TextDecoder(); + let text = ""; + for await (const chunk of readableChunks(stream)) { + text = appendTextTail(text, decoder.decode(chunk, { stream: true })); + } + return appendTextTail(text, decoder.decode()); +} + +function appendTextTail(current: string, chunk: string): string { + if (chunk.length >= maximumStderrLength) { + return chunk.slice(-maximumStderrLength); + } + const overflow = current.length + chunk.length - maximumStderrLength; + return overflow > 0 + ? `${current.slice(overflow)}${chunk}` + : `${current}${chunk}`; +} + +function formatVersionFailure(exitCode: number, stderr: string): string { + const status = `Sandbox Node.js version check failed with exit code ${exitCode}`; + const detail = stderr.trim(); + return detail.length === 0 ? status : `${status}: ${detail}`; +} + +function formatProcessFailure( + exit: SandboxProcessExit, + stderr: string, +): string { + const status = [ + "Sandbox Node.js runtime failed with", + `exit code ${exit.exitCode}, signal ${exit.signal}`, + ].join(" "); + const detail = stderr.trim(); + return detail.length === 0 ? status : `${status}: ${detail}`; +} + +function errorFromUnknown(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/package.test.ts b/src/package.test.ts new file mode 100644 index 0000000..8545ce7 --- /dev/null +++ b/src/package.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("package publishes the Sandbox integration against released contracts", async () => { + const packageJson = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { + readonly dependencies: Readonly>; + readonly exports: Readonly>; + readonly files: readonly string[]; + }; + const source = await readFile(new URL("./index.ts", import.meta.url), "utf8"); + + assert.equal(packageJson.dependencies["@torkbot/code-mode"], "^0.1.0"); + assert.equal(packageJson.dependencies["@torkbot/sandbox"], "^0.14.0"); + assert.deepEqual(Object.keys(packageJson.exports), ["."]); + assert.deepEqual(packageJson.files, ["dist"]); + assert.match(source, /from "@torkbot\/code-mode\/node"/); + assert.doesNotMatch(source, /@torkbot\/code-mode\/sandbox-node/); +}); From 40987bc45d03100fdf7fffb763f7e5a47418cadc Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 15:02:13 -0600 Subject: [PATCH 2/6] Observe partial launch readiness failures --- src/index.test.ts | 5 ++++- src/index.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 1231208..2975f9b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -121,7 +121,10 @@ test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process p }); test("SandboxNodeRuntime rejects launch when Sandbox omits the requested pipe", async () => { - const process = createSandboxProcess({ includeChannel: false }); + const process = createSandboxProcess({ + includeChannel: false, + readyError: new Error("guest launch failed without fd 3"), + }); const sandbox = createRuntimeSandbox(process.instance); const runtime = new SandboxNodeRuntime({ sandbox: sandbox.instance, diff --git a/src/index.ts b/src/index.ts index cb56dd7..a933a8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -272,6 +272,7 @@ async function stopPartialLaunch(process: SandboxProcess): Promise { forceTerminationTimeout.unref(); try { await Promise.allSettled([ + process.ready, process.exit, drain(process.stdout), drain(process.stderr), From a8470a0e633caef98f41b4f01c4ddee3284b0328 Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 15:24:27 -0600 Subject: [PATCH 3/6] Prefer caller-owned Sandbox runtimes --- README.md | 54 +++++++++++------------------- src/index.test.ts | 85 +++++------------------------------------------ src/index.ts | 64 ++++------------------------------- 3 files changed, 36 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 2d16991..e8062e0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # @torkbot/code-mode-sandbox Run [`@torkbot/code-mode`](https://github.com/torkbot/code-mode) programs -inside lifecycle-managed +with Node.js 24 inside [`@torkbot/sandbox`](https://github.com/torkbot/sandbox) microVMs. This package owns the integration between code mode's runtime contract and -Sandbox VM execution. It boots a caller-defined Sandbox machine, provides a -code-mode runtime backed by Node.js inside that machine, and couples the runtime -and VM lifecycles so they are closed together. +Sandbox VM execution. It adapts a caller-owned, booted `SandboxInstance` into a +code-mode runtime backed by Node.js inside that machine. -The caller remains responsible for the Sandbox definition, including its image, -persistence, mounts, resources, and network access. The embedding application -remains responsible for machine identity, session reuse, and the runtime facts -presented to an agent. +The caller owns the Sandbox lifecycle. It chooses the image, persistence, +mounts, resources, network access, machine identity, and reuse policy; boots the +machine; keeps it open while the runtime is in use; and closes it afterward. +`SandboxNodeRuntime` owns only the guest processes it launches. This package +does not boot, pool, reuse, or close Sandbox machines. `@torkbot/code-mode` remains responsible for tool declarations, source validation, protocol routing, and telemetry. `@torkbot/sandbox` remains @@ -27,13 +27,11 @@ npm install @torkbot/code-mode @torkbot/code-mode-sandbox @torkbot/sandbox ## Usage -Define the machine with Sandbox, then open a code-mode session for it: +Define and boot the machine with Sandbox, then adapt that machine for code mode: ```ts import { createClient } from "@torkbot/code-mode"; -import { - openSandboxCodeMode, -} from "@torkbot/code-mode-sandbox"; +import { SandboxNodeRuntime } from "@torkbot/code-mode-sandbox"; import { defineSandbox } from "@torkbot/sandbox"; const definition = defineSandbox({ @@ -44,38 +42,26 @@ const definition = defineSandbox({ }, }); -await using session = await openSandboxCodeMode({ - definition, - boot: { - cwd: "/workspace", - }, +await using sandbox = await definition.boot({ + cwd: "/workspace", +}); + +const runtime = new SandboxNodeRuntime({ + sandbox, + cwd: "/workspace", nodePath: "/usr/bin/node", }); const client = createClient({ toolbox, - runtime: session.runtime, + runtime, }); ``` The absolute guest working directory and Node.js path are required because they determine module resolution and the executable used for both validation and -execution. Reusing a persistent root filesystem remains a property of the -supplied Sandbox definition; this package does not infer machine identity or -persistence policy. - -Callers that already own a booted `SandboxInstance` can construct a runtime -without transferring the VM lifecycle: - -```ts -import { SandboxNodeRuntime } from "@torkbot/code-mode-sandbox"; - -const runtime = new SandboxNodeRuntime({ - sandbox, - cwd: "/workspace", - nodePath: "/usr/bin/node", -}); -``` +execution. The Sandbox instance must remain open until every runtime instance +started through the adapter has finished. ## Development diff --git a/src/index.test.ts b/src/index.test.ts index 2975f9b..2140a84 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { - SandboxBootOptions, - SandboxDefinition, SandboxExecOptions, SandboxExecResult, SandboxInstance, @@ -13,45 +11,7 @@ import type { SandboxSpawnOptions, } from "@torkbot/sandbox"; -import { - openSandboxCodeMode, - SandboxNodeRuntime, -} from "./index.ts"; - -test("openSandboxCodeMode boots the supplied definition and owns its lifecycle", async () => { - const sandbox = createLifecycleSandbox(); - let observedBoot: SandboxBootOptions | undefined; - const definition: SandboxDefinition = { - environmentFacts: () => [], - async boot(options) { - observedBoot = options; - return sandbox.instance; - }, - }; - - const session = await openSandboxCodeMode({ - definition, - boot: { - cwd: "/workspace", - hostname: "code-mode-test", - }, - nodePath: "/usr/bin/node", - }); - - assert.deepEqual(observedBoot, { - cwd: "/workspace", - hostname: "code-mode-test", - }); - assert.equal(session.sandbox, sandbox.instance); - assert.ok(session.runtime instanceof SandboxNodeRuntime); - - await Promise.all([ - session.close(), - session.close(), - session[Symbol.asyncDispose](), - ]); - assert.equal(sandbox.closeCalls(), 1); -}); +import { SandboxNodeRuntime } from "./index.ts"; test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process pipes", async () => { const process = createSandboxProcess({ @@ -118,6 +78,7 @@ test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process p ]); assert.deepEqual(process.kills(), ["SIGTERM"]); assert.deepEqual(await instance.finished, { kind: "closed" }); + assert.equal(sandbox.closeCalls(), 0); }); test("SandboxNodeRuntime rejects launch when Sandbox omits the requested pipe", async () => { @@ -257,12 +218,17 @@ function createRuntimeSandbox( readonly instance: SandboxInstance; execCalls(): readonly ObservedExec[]; spawnCalls(): readonly ObservedSpawn[]; + closeCalls(): number; } { const execCalls: ObservedExec[] = []; const spawnCalls: ObservedSpawn[] = []; + let closeCalls = 0; const unsupported = (): never => { throw new Error("not used by this runtime host test"); }; + const close = async (): Promise => { + closeCalls += 1; + }; return { instance: { @@ -287,44 +253,11 @@ function createRuntimeSandbox( return process; }, pty: unsupported, - async close() {}, - async [Symbol.asyncDispose]() {}, - }, - execCalls: () => execCalls, - spawnCalls: () => spawnCalls, - }; -} - -function createLifecycleSandbox(): { - readonly instance: SandboxInstance; - closeCalls(): number; -} { - let closeCalls = 0; - const unsupported = (): never => { - throw new Error("not used by this lifecycle test"); - }; - const close = async (): Promise => { - closeCalls += 1; - }; - - return { - instance: { - fs: { - stat: unsupported, - readDir: unsupported, - readFile: unsupported, - writeFile: unsupported, - mkdir: unsupported, - remove: unsupported, - rename: unsupported, - }, - environmentFacts: unsupported, - exec: unsupported, - spawn: unsupported, - pty: unsupported, close, [Symbol.asyncDispose]: close, }, + execCalls: () => execCalls, + spawnCalls: () => spawnCalls, closeCalls: () => closeCalls, }; } diff --git a/src/index.ts b/src/index.ts index a933a8d..2f0741d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,6 @@ import type { RuntimeInstance, } from "@torkbot/code-mode/runtime"; import type { - SandboxBootOptions, - SandboxDefinition, SandboxInstance, SandboxProcess, SandboxProcessExit, @@ -26,67 +24,19 @@ export interface SandboxNodeRuntimeOptions { readonly cwd: string; } -/** Runs code-mode payloads with Node.js 24 inside an existing Sandbox VM. */ +/** + * Runs code-mode payloads with Node.js 24 inside a caller-owned Sandbox VM. + * + * The caller must keep the Sandbox instance open while the runtime is in use + * and remains responsible for closing it. The runtime owns only the guest + * processes it launches. + */ export class SandboxNodeRuntime extends Node24Runtime { constructor(options: SandboxNodeRuntimeOptions) { super(new SandboxNodeRuntimeHost(options)); } } -export type SandboxCodeModeBootOptions = Omit & { - readonly cwd: string; -}; - -export interface OpenSandboxCodeModeOptions { - readonly definition: SandboxDefinition; - readonly boot: SandboxCodeModeBootOptions; - readonly nodePath: string; -} - -export interface SandboxCodeModeSession extends AsyncDisposable { - readonly sandbox: SandboxInstance; - readonly runtime: SandboxNodeRuntime; - close(): Promise; -} - -/** - * Boots one Sandbox VM and pairs its lifecycle with a code-mode Node runtime. - * The supplied definition remains the authority for persistence, resources, - * mounts, and network access. - */ -export async function openSandboxCodeMode( - options: OpenSandboxCodeModeOptions, -): Promise { - const sandbox = await options.definition.boot(options.boot); - const runtime = new SandboxNodeRuntime({ - sandbox, - nodePath: options.nodePath, - cwd: options.boot.cwd, - }); - - return new OpenSandboxCodeModeSession(sandbox, runtime); -} - -class OpenSandboxCodeModeSession implements SandboxCodeModeSession { - readonly sandbox: SandboxInstance; - readonly runtime: SandboxNodeRuntime; - #closing: Promise | undefined; - - constructor(sandbox: SandboxInstance, runtime: SandboxNodeRuntime) { - this.sandbox = sandbox; - this.runtime = runtime; - } - - close(): Promise { - this.#closing ??= this.sandbox.close(); - return this.#closing; - } - - async [Symbol.asyncDispose](): Promise { - await this.close(); - } -} - class SandboxNodeRuntimeHost implements Node24RuntimeHost { readonly #sandbox: SandboxInstance; readonly #nodePath: string; From 8317b7af0d617cdb39f10d01c5133dca116a3b9f Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 15:41:53 -0600 Subject: [PATCH 4/6] Expose composable Sandbox runtime host --- README.md | 27 +++-- src/index.test.ts | 59 ++++------ src/index.ts | 272 ++++++++++++++++++++++---------------------- src/package.test.ts | 2 + 4 files changed, 174 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index e8062e0..c635315 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,16 @@ with Node.js 24 inside [`@torkbot/sandbox`](https://github.com/torkbot/sandbox) microVMs. This package owns the integration between code mode's runtime contract and -Sandbox VM execution. It adapts a caller-owned, booted `SandboxInstance` into a -code-mode runtime backed by Node.js inside that machine. +Sandbox VM execution. It adapts a caller-owned, booted `SandboxInstance` into +the execution host required by `Node24Runtime`; composing those values produces +a code-mode runtime backed by Node.js inside that machine. The caller owns the Sandbox lifecycle. It chooses the image, persistence, mounts, resources, network access, machine identity, and reuse policy; boots the machine; keeps it open while the runtime is in use; and closes it afterward. -`SandboxNodeRuntime` owns only the guest processes it launches. This package -does not boot, pool, reuse, or close Sandbox machines. +The Sandbox runtime host owns only the guest processes it launches through +`Node24Runtime`. This package does not boot, pool, reuse, or close Sandbox +machines. `@torkbot/code-mode` remains responsible for tool declarations, source validation, protocol routing, and telemetry. `@torkbot/sandbox` remains @@ -31,7 +33,8 @@ Define and boot the machine with Sandbox, then adapt that machine for code mode: ```ts import { createClient } from "@torkbot/code-mode"; -import { SandboxNodeRuntime } from "@torkbot/code-mode-sandbox"; +import { Node24Runtime } from "@torkbot/code-mode/node"; +import { createSandboxNodeRuntimeHost } from "@torkbot/code-mode-sandbox"; import { defineSandbox } from "@torkbot/sandbox"; const definition = defineSandbox({ @@ -46,11 +49,13 @@ await using sandbox = await definition.boot({ cwd: "/workspace", }); -const runtime = new SandboxNodeRuntime({ - sandbox, - cwd: "/workspace", - nodePath: "/usr/bin/node", -}); +const runtime = new Node24Runtime( + createSandboxNodeRuntimeHost({ + sandbox, + cwd: "/workspace", + nodePath: "/usr/bin/node", + }), +); const client = createClient({ toolbox, @@ -61,7 +66,7 @@ const client = createClient({ The absolute guest working directory and Node.js path are required because they determine module resolution and the executable used for both validation and execution. The Sandbox instance must remain open until every runtime instance -started through the adapter has finished. +started through the composed runtime has finished. ## Development diff --git a/src/index.test.ts b/src/index.test.ts index 2140a84..eb91785 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { Node24Runtime } from "@torkbot/code-mode/node"; import type { SandboxExecOptions, SandboxExecResult, @@ -11,18 +12,14 @@ import type { SandboxSpawnOptions, } from "@torkbot/sandbox"; -import { SandboxNodeRuntime } from "./index.ts"; +import { createSandboxNodeRuntimeHost } from "./index.ts"; -test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process pipes", async () => { +test("Sandbox Node runtime host launches the code-mode bootstrap over Sandbox process pipes", async () => { const process = createSandboxProcess({ channelOutput: [new TextEncoder().encode("runtime output")], }); const sandbox = createRuntimeSandbox(process.instance); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); const signal = AbortSignal.timeout(5_000); const instance = await runtime.start({ @@ -81,17 +78,13 @@ test("SandboxNodeRuntime launches the code-mode bootstrap over Sandbox process p assert.equal(sandbox.closeCalls(), 0); }); -test("SandboxNodeRuntime rejects launch when Sandbox omits the requested pipe", async () => { +test("Sandbox Node runtime host rejects launch when Sandbox omits the requested pipe", async () => { const process = createSandboxProcess({ includeChannel: false, readyError: new Error("guest launch failed without fd 3"), }); const sandbox = createRuntimeSandbox(process.instance); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); await assert.rejects(runtime.start({ payload: { @@ -103,18 +96,14 @@ test("SandboxNodeRuntime rejects launch when Sandbox omits the requested pipe", assert.deepEqual(process.kills(), ["SIGTERM"]); }); -test("SandboxNodeRuntime rejects a non-Node-24 guest before spawning", async () => { +test("Sandbox Node runtime host rejects a non-Node-24 guest before spawning", async () => { const process = createSandboxProcess(); const sandbox = createRuntimeSandbox(process.instance, { exitCode: 0, stdout: "v23.11.0\n", stderr: "", }); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); await assert.rejects(runtime.start({ payload: { @@ -126,16 +115,12 @@ test("SandboxNodeRuntime rejects a non-Node-24 guest before spawning", async () assert.deepEqual(sandbox.spawnCalls(), []); }); -test("SandboxNodeRuntime cleans up and rejects Sandbox launch failures", async () => { +test("Sandbox Node runtime host cleans up and rejects Sandbox launch failures", async () => { const process = createSandboxProcess({ readyError: new Error("guest launch failed"), }); const sandbox = createRuntimeSandbox(process.instance); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); await assert.rejects(runtime.start({ payload: { @@ -147,14 +132,10 @@ test("SandboxNodeRuntime cleans up and rejects Sandbox launch failures", async ( assert.deepEqual(process.kills(), ["SIGTERM"]); }); -test("SandboxNodeRuntime reports unexpected guest process failures with stderr", async () => { +test("Sandbox Node runtime host reports unexpected guest process failures with stderr", async () => { const process = createSandboxProcess({ stderr: "guest exploded" }); const sandbox = createRuntimeSandbox(process.instance); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); const instance = await runtime.start({ payload: { kind: "javascript-module", @@ -172,14 +153,10 @@ test("SandboxNodeRuntime reports unexpected guest process failures with stderr", } }); -test("SandboxNodeRuntime aborts a launched process and resolves it as closed", async () => { +test("Sandbox Node runtime host aborts a launched process and resolves it as closed", async () => { const process = createSandboxProcess(); const sandbox = createRuntimeSandbox(process.instance); - const runtime = new SandboxNodeRuntime({ - sandbox: sandbox.instance, - nodePath: "/usr/bin/node", - cwd: "/workspace", - }); + const runtime = createRuntime(sandbox.instance); const controller = new AbortController(); const instance = await runtime.start({ payload: { @@ -195,6 +172,14 @@ test("SandboxNodeRuntime aborts a launched process and resolves it as closed", a assert.deepEqual(process.kills(), ["SIGTERM"]); }); +function createRuntime(sandbox: SandboxInstance): Node24Runtime { + return new Node24Runtime(createSandboxNodeRuntimeHost({ + sandbox, + nodePath: "/usr/bin/node", + cwd: "/workspace", + })); +} + type ObservedExec = { readonly command: string; readonly args: readonly string[] | undefined; diff --git a/src/index.ts b/src/index.ts index 2f0741d..2992dca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ -import { - Node24Runtime, - type Node24RuntimeHost, - type Node24RuntimeLaunchRequest, +import type { + Node24RuntimeHost, + Node24RuntimeLaunchRequest, } from "@torkbot/code-mode/node"; import type { ByteChannel, @@ -18,171 +17,168 @@ import type { const maximumStderrLength = 64 * 1024; const terminationGracePeriodMilliseconds = 1_000; -export interface SandboxNodeRuntimeOptions { +export interface SandboxNodeRuntimeHostOptions { readonly sandbox: SandboxInstance; readonly nodePath: string; readonly cwd: string; } /** - * Runs code-mode payloads with Node.js 24 inside a caller-owned Sandbox VM. + * Creates the Sandbox execution host required by Node24Runtime. * * The caller must keep the Sandbox instance open while the runtime is in use - * and remains responsible for closing it. The runtime owns only the guest - * processes it launches. + * and remains responsible for closing it. The host owns only the guest + * processes it launches through Node24Runtime. */ -export class SandboxNodeRuntime extends Node24Runtime { - constructor(options: SandboxNodeRuntimeOptions) { - super(new SandboxNodeRuntimeHost(options)); - } +export function createSandboxNodeRuntimeHost( + options: SandboxNodeRuntimeHostOptions, +): Node24RuntimeHost { + return { + readNodeVersion: (signal) => readSandboxNodeVersion(options, signal), + launchNode: (req) => launchSandboxNode(options, req), + }; } -class SandboxNodeRuntimeHost implements Node24RuntimeHost { - readonly #sandbox: SandboxInstance; - readonly #nodePath: string; - readonly #cwd: string; +async function readSandboxNodeVersion( + options: SandboxNodeRuntimeHostOptions, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const result = await options.sandbox.exec( + options.nodePath, + ["--version"], + { + cwd: options.cwd, + signal, + }, + ); + signal.throwIfAborted(); - constructor(options: SandboxNodeRuntimeOptions) { - this.#sandbox = options.sandbox; - this.#nodePath = options.nodePath; - this.#cwd = options.cwd; + if (result.exitCode !== 0) { + throw new Error(formatVersionFailure(result.exitCode, result.stderr)); } + return result.stdout.trim(); +} - async readNodeVersion(signal: AbortSignal): Promise { - signal.throwIfAborted(); - const result = await this.#sandbox.exec( - this.#nodePath, - ["--version"], - { - cwd: this.#cwd, - signal, - }, +async function launchSandboxNode( + options: SandboxNodeRuntimeHostOptions, + req: Node24RuntimeLaunchRequest, +): Promise { + req.signal.throwIfAborted(); + const process = options.sandbox.spawn( + options.nodePath, + ["--input-type=module"], + { + cwd: options.cwd, + pipes: [req.channelFileDescriptor], + }, + ); + const pipe = process.pipes.get(req.channelFileDescriptor); + if (pipe === undefined) { + await stopPartialLaunch(process); + throw new Error( + `Sandbox Node.js runtime did not create fd ${req.channelFileDescriptor}`, ); - signal.throwIfAborted(); - - if (result.exitCode !== 0) { - throw new Error(formatVersionFailure(result.exitCode, result.stderr)); - } - return result.stdout.trim(); } - async launchNode(req: Node24RuntimeLaunchRequest): Promise { - req.signal.throwIfAborted(); - const process = this.#sandbox.spawn( - this.#nodePath, - ["--input-type=module"], - { - cwd: this.#cwd, - pipes: [req.channelFileDescriptor], - }, - ); - const pipe = process.pipes.get(req.channelFileDescriptor); - if (pipe === undefined) { - await stopPartialLaunch(process); - throw new Error( - `Sandbox Node.js runtime did not create fd ${req.channelFileDescriptor}`, - ); - } - - const stdout = drain(process.stdout); - const stderr = readTextTail(process.stderr); - let terminationRequested = false; - let finishedSettled = false; - let forceTerminationTimeout: ReturnType | undefined; + const stdout = drain(process.stdout); + const stderr = readTextTail(process.stderr); + let terminationRequested = false; + let finishedSettled = false; + let forceTerminationTimeout: ReturnType | undefined; - const requestTermination = (): void => { - if (finishedSettled || terminationRequested) { - return; - } - terminationRequested = true; - process.kill("SIGTERM"); - forceTerminationTimeout ??= setTimeout(() => { - process.kill("SIGKILL"); - }, terminationGracePeriodMilliseconds); - forceTerminationTimeout.unref(); - }; - const abort = (): void => requestTermination(); - - if (req.signal.aborted) { - abort(); - } else { - req.signal.addEventListener("abort", abort, { once: true }); + const requestTermination = (): void => { + if (finishedSettled || terminationRequested) { + return; } + terminationRequested = true; + process.kill("SIGTERM"); + forceTerminationTimeout ??= setTimeout(() => { + process.kill("SIGKILL"); + }, terminationGracePeriodMilliseconds); + forceTerminationTimeout.unref(); + }; + const abort = (): void => requestTermination(); - const finished: Promise = (async () => { - try { - const [launchError, exit, , stderrText] = await Promise.all([ - process.ready.then( - () => null, - (error: unknown) => errorFromUnknown(error), - ), - process.exit, - stdout, - stderr, - ]); - - if (terminationRequested) { - return { kind: "closed" }; - } - if (launchError !== null) { - return { kind: "failed", error: launchError }; - } - if (exit.exitCode === 0 && exit.signal === null) { - return { kind: "closed" }; - } - return { - kind: "failed", - error: new Error(formatProcessFailure(exit, stderrText)), - }; - } catch (error) { - return { - kind: "failed", - error: errorFromUnknown(error), - }; - } finally { - finishedSettled = true; - req.signal.removeEventListener("abort", abort); - if (forceTerminationTimeout !== undefined) { - clearTimeout(forceTerminationTimeout); - } - } - })(); + if (req.signal.aborted) { + abort(); + } else { + req.signal.addEventListener("abort", abort, { once: true }); + } + const finished: Promise = (async () => { try { - await process.ready; - await writeAndClose( - process.stdin, - new TextEncoder().encode(req.bootstrapSource), - ); + const [launchError, exit, , stderrText] = await Promise.all([ + process.ready.then( + () => null, + (error: unknown) => errorFromUnknown(error), + ), + process.exit, + stdout, + stderr, + ]); + + if (terminationRequested) { + return { kind: "closed" }; + } + if (launchError !== null) { + return { kind: "failed", error: launchError }; + } + if (exit.exitCode === 0 && exit.signal === null) { + return { kind: "closed" }; + } + return { + kind: "failed", + error: new Error(formatProcessFailure(exit, stderrText)), + }; } catch (error) { - requestTermination(); - await finished; - if (req.signal.aborted) { - throw req.signal.reason; + return { + kind: "failed", + error: errorFromUnknown(error), + }; + } finally { + finishedSettled = true; + req.signal.removeEventListener("abort", abort); + if (forceTerminationTimeout !== undefined) { + clearTimeout(forceTerminationTimeout); } - throw error; } + })(); + try { + await process.ready; + await writeAndClose( + process.stdin, + new TextEncoder().encode(req.bootstrapSource), + ); + } catch (error) { + requestTermination(); + await finished; if (req.signal.aborted) { - requestTermination(); - await finished; throw req.signal.reason; } + throw error; + } - const channel: ByteChannel = { - incoming: readableChunks(pipe.output), - outgoing: new WebByteWriter(pipe.input), - }; - - return { - channel, - finished, - async terminate(_reason: string): Promise { - requestTermination(); - await finished; - }, - }; + if (req.signal.aborted) { + requestTermination(); + await finished; + throw req.signal.reason; } + + const channel: ByteChannel = { + incoming: readableChunks(pipe.output), + outgoing: new WebByteWriter(pipe.input), + }; + + return { + channel, + finished, + async terminate(_reason: string): Promise { + requestTermination(); + await finished; + }, + }; } class WebByteWriter implements ByteWriter { diff --git a/src/package.test.ts b/src/package.test.ts index 8545ce7..3f78349 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -17,5 +17,7 @@ test("package publishes the Sandbox integration against released contracts", asy assert.deepEqual(Object.keys(packageJson.exports), ["."]); assert.deepEqual(packageJson.files, ["dist"]); assert.match(source, /from "@torkbot\/code-mode\/node"/); + assert.match(source, /export function createSandboxNodeRuntimeHost/); + assert.doesNotMatch(source, /extends Node24Runtime/); assert.doesNotMatch(source, /@torkbot\/code-mode\/sandbox-node/); }); From 06c61e65ff4d588e203540b1d45752e776e10336 Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 18:06:19 -0600 Subject: [PATCH 5/6] Use Web Streams for runtime channels --- README.md | 3 ++- package-lock.json | 8 +++---- package.json | 2 +- src/index.test.ts | 23 ++++++++++++------ src/index.ts | 58 ++++----------------------------------------- src/package.test.ts | 3 ++- 6 files changed, 30 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index c635315..1b93696 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ machines. `@torkbot/code-mode` remains responsible for tool declarations, source validation, protocol routing, and telemetry. `@torkbot/sandbox` remains responsible for isolated VM execution. This package adds only the integration -required to use those capabilities together. +required to use those capabilities together. The runtime channel is the +Sandbox process pipe's standard readable and writable Web Streams pair. ## Install diff --git a/package-lock.json b/package-lock.json index e9d509d..2aa4a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@torkbot/code-mode": "^0.1.0", + "@torkbot/code-mode": "^0.2.0", "@torkbot/sandbox": "^0.14.0" }, "devDependencies": { @@ -135,9 +135,9 @@ "license": "MIT" }, "node_modules/@torkbot/code-mode": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@torkbot/code-mode/-/code-mode-0.1.0.tgz", - "integrity": "sha512-Zl8ERUNW+NBJalkeV+fbQOsZfmhrdezXtUJOVe1QB+ELECGsLftFHMIEsIi6DkOGXsDfRBokrJ5RIWLUq7kaEA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@torkbot/code-mode/-/code-mode-0.2.0.tgz", + "integrity": "sha512-O/K3HNzdk4LdyUQO/hFSlthCsPZ06QfIqouuxCytm0ZWa3CSPCnSpmssAC6JL0E171huylbNRffFwWGmXOMNGg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", diff --git a/package.json b/package.json index f69e1ae..0f37951 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "typescript": "^7.0.2" }, "dependencies": { - "@torkbot/code-mode": "^0.1.0", + "@torkbot/code-mode": "^0.2.0", "@torkbot/sandbox": "^0.14.0" } } diff --git a/src/index.test.ts b/src/index.test.ts index eb91785..9a73ad8 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -51,18 +51,22 @@ test("Sandbox Node runtime host launches the code-mode bootstrap over Sandbox pr new TextDecoder().decode(joinBytes(process.stdinWrites())), /payload sentinel/, ); + assert.equal(instance.channel.readable, process.channelOutputStream); + assert.equal(instance.channel.writable, process.channelInputStream); assert.deepEqual( - (await collect(instance.channel.incoming)).map((chunk) => ( + (await collect(instance.channel.readable)).map((chunk) => ( new TextDecoder().decode(chunk) )), ["runtime output"], ); - await instance.channel.outgoing.write(new TextEncoder().encode("host input")); - await Promise.all([ - instance.channel.outgoing.close(), - instance.channel.outgoing.close(), - ]); + const writer = instance.channel.writable.getWriter(); + try { + await writer.write(new TextEncoder().encode("host input")); + await writer.close(); + } finally { + writer.releaseLock(); + } assert.equal(process.channelInputClosed(), true); assert.equal( new TextDecoder().decode(joinBytes(process.channelInputWrites())), @@ -254,6 +258,8 @@ function createSandboxProcess(options: { readonly stderr?: string; } = {}): { readonly instance: SandboxProcess; + readonly channelInputStream: WritableStream; + readonly channelOutputStream: ReadableStream; channelInputClosed(): boolean; channelInputWrites(): readonly Uint8Array[]; stdinClosed(): boolean; @@ -263,6 +269,7 @@ function createSandboxProcess(options: { } { const stdin = createWritableRecorder(); const channelInput = createWritableRecorder(); + const channelOutput = readableFrom(options.channelOutput ?? []); const exit = deferred(); const kills: SandboxSignal[] = []; const includeChannel = options.includeChannel ?? true; @@ -277,7 +284,7 @@ function createSandboxProcess(options: { pipes: includeChannel ? new Map([[3, { input: channelInput.stream, - output: readableFrom(options.channelOutput ?? []), + output: channelOutput, }]]) : new Map(), ready: options.readyError === undefined @@ -289,6 +296,8 @@ function createSandboxProcess(options: { exit.resolve({ exitCode: null, signal }); }, }, + channelInputStream: channelInput.stream, + channelOutputStream: channelOutput, channelInputClosed: channelInput.closed, channelInputWrites: channelInput.writes, stdinClosed: stdin.closed, diff --git a/src/index.ts b/src/index.ts index 2992dca..a488978 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,6 @@ import type { Node24RuntimeLaunchRequest, } from "@torkbot/code-mode/node"; import type { - ByteChannel, - ByteWriter, RuntimeFinished, RuntimeInstance, } from "@torkbot/code-mode/runtime"; @@ -166,9 +164,9 @@ async function launchSandboxNode( throw req.signal.reason; } - const channel: ByteChannel = { - incoming: readableChunks(pipe.output), - outgoing: new WebByteWriter(pipe.input), + const channel: RuntimeInstance["channel"] = { + readable: pipe.output, + writable: pipe.input, }; return { @@ -181,35 +179,6 @@ async function launchSandboxNode( }; } -class WebByteWriter implements ByteWriter { - readonly #writer: WritableStreamDefaultWriter; - #closing: Promise | undefined; - - constructor(stream: WritableStream) { - this.#writer = stream.getWriter(); - } - - async write(chunk: Uint8Array): Promise { - if (this.#closing !== undefined) { - throw new Error("Sandbox Node.js runtime byte channel is closed"); - } - await this.#writer.write(chunk); - } - - close(): Promise { - this.#closing ??= this.#close(); - return this.#closing; - } - - async #close(): Promise { - try { - await this.#writer.close(); - } finally { - this.#writer.releaseLock(); - } - } -} - async function stopPartialLaunch(process: SandboxProcess): Promise { process.kill("SIGTERM"); const forceTerminationTimeout = setTimeout(() => { @@ -241,25 +210,8 @@ async function writeAndClose( } } -async function* readableChunks( - stream: ReadableStream, -): AsyncIterable { - const reader = stream.getReader(); - try { - while (true) { - const result = await reader.read(); - if (result.done) { - return; - } - yield result.value; - } - } finally { - reader.releaseLock(); - } -} - async function drain(stream: ReadableStream): Promise { - for await (const _chunk of readableChunks(stream)) { + for await (const _chunk of stream) { // Drain output so an unconsumed stream cannot block process completion. } } @@ -269,7 +221,7 @@ async function readTextTail( ): Promise { const decoder = new TextDecoder(); let text = ""; - for await (const chunk of readableChunks(stream)) { + for await (const chunk of stream) { text = appendTextTail(text, decoder.decode(chunk, { stream: true })); } return appendTextTail(text, decoder.decode()); diff --git a/src/package.test.ts b/src/package.test.ts index 3f78349..778ff09 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -12,12 +12,13 @@ test("package publishes the Sandbox integration against released contracts", asy }; const source = await readFile(new URL("./index.ts", import.meta.url), "utf8"); - assert.equal(packageJson.dependencies["@torkbot/code-mode"], "^0.1.0"); + assert.equal(packageJson.dependencies["@torkbot/code-mode"], "^0.2.0"); assert.equal(packageJson.dependencies["@torkbot/sandbox"], "^0.14.0"); assert.deepEqual(Object.keys(packageJson.exports), ["."]); assert.deepEqual(packageJson.files, ["dist"]); assert.match(source, /from "@torkbot\/code-mode\/node"/); assert.match(source, /export function createSandboxNodeRuntimeHost/); assert.doesNotMatch(source, /extends Node24Runtime/); + assert.doesNotMatch(source, /WebByteWriter|readableChunks/); assert.doesNotMatch(source, /@torkbot\/code-mode\/sandbox-node/); }); From 06dcd930bd34d73c7eb29e41717f93fbbd06110d Mon Sep 17 00:00:00 2001 From: Geoffrey Goodman Date: Wed, 15 Jul 2026 18:09:29 -0600 Subject: [PATCH 6/6] Validate guest runtime paths --- src/index.test.ts | 17 +++++++++++++++++ src/index.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 9a73ad8..0bd1cdb 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -176,6 +176,23 @@ test("Sandbox Node runtime host aborts a launched process and resolves it as clo assert.deepEqual(process.kills(), ["SIGTERM"]); }); +test("Sandbox Node runtime host requires absolute guest paths", () => { + const sandbox = createRuntimeSandbox(createSandboxProcess().instance); + + assert.throws(() => createSandboxNodeRuntimeHost({ + sandbox: sandbox.instance, + nodePath: "/usr/bin/node", + cwd: "workspace", + }), /cwd must be an absolute guest path/); + assert.throws(() => createSandboxNodeRuntimeHost({ + sandbox: sandbox.instance, + nodePath: "node", + cwd: "/workspace", + }), /nodePath must be an absolute guest path/); + assert.deepEqual(sandbox.execCalls(), []); + assert.deepEqual(sandbox.spawnCalls(), []); +}); + function createRuntime(sandbox: SandboxInstance): Node24Runtime { return new Node24Runtime(createSandboxNodeRuntimeHost({ sandbox, diff --git a/src/index.ts b/src/index.ts index a488978..baccc0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,12 +31,21 @@ export interface SandboxNodeRuntimeHostOptions { export function createSandboxNodeRuntimeHost( options: SandboxNodeRuntimeHostOptions, ): Node24RuntimeHost { + requireAbsoluteGuestPath("cwd", options.cwd); + requireAbsoluteGuestPath("nodePath", options.nodePath); + return { readNodeVersion: (signal) => readSandboxNodeVersion(options, signal), launchNode: (req) => launchSandboxNode(options, req), }; } +function requireAbsoluteGuestPath(name: string, value: string): void { + if (!value.startsWith("/")) { + throw new TypeError(`${name} must be an absolute guest path`); + } +} + async function readSandboxNodeVersion( options: SandboxNodeRuntimeHostOptions, signal: AbortSignal,