diff --git a/README.ko.md b/README.ko.md index fca1889..985139c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -81,6 +81,9 @@ | `@pleasedev/core/sandbox` | `defineSandbox`와 백엔드 계약 — 벤더 중립 타입 | | `@pleasedev/core/sandbox/harness` | 그 계약을 AI SDK `HarnessV1SandboxProvider`로 옮긴 것. 모든 백엔드를 위해 한 번만 작성한다 | | `@pleasedev/core/sandbox/docker` | 로컬 Docker 백엔드. **호스트 전용** — `docker` CLI를 실행하므로 Worker 번들에 들어가면 안 된다 | +| `@pleasedev/core/sandbox/local` | 호스트 프로세스 백엔드 — 데몬도 이미지도, **격리도 없다**. 같은 이유로 호스트 전용 | +| `@pleasedev/core/sandbox/just-bash` | [`just-bash`](https://www.npmjs.com/package/just-bash) 위의 가상 셸 백엔드 — 데몬도 이미지도 호스트 프로세스도 없고, **실제 바이너리도 없다**. `just-bash` 는 선택적 peer 의존성 | +| `@pleasedev/core/sandbox/microsandbox` | [`microsandbox`](https://www.npmjs.com/package/microsandbox) 위의 microVM 백엔드 — 네임스페이스가 아니라 하이퍼바이저로 격리한다. 선택적 peer 의존성이며, **타입 검사만 되었고 아직 실행되지 않았다** (아래 참조) | 하네스 변환을 백엔드에서 떼어 둔 덕분에 두 번째 백엔드가 그것을 다시 만들 필요가 없고, 서브패스는 호스트 전용 코드가 그것을 실행할 수 없는 타깃으로 새어 들어가지 않게 막는다. @@ -91,6 +94,15 @@ `skills`, `settingSources` 를 노출하지 않으므로, 그 기능들이 실행에 닿는 길은 디렉터리 하나뿐이기 때문이다. +네 백엔드 중 셋은 전제 조건이 갖춰진 곳에서 실제로 도는 스위트를 갖고 있다 — `local` 과 `just-bash` +는 어디서나, `docker` 는 데몬에 닿을 수 있는 곳에서. **microsandbox** 는 예외이고, 그 사실을 감추지 +않는다: `microsandbox` 는 이 백엔드가 작성된 플랫폼인 `darwin-x64` 용 네이티브 애드온을 제공하지 +않고, Linux CI 러너에서는 애드온은 로드되지만 하이퍼바이저가 없어 게스트가 에이전트 릴레이가 +올라오기 전에 죽는다. 그래서 동작 스위트가 통과하는 것을 아직 한 번도 관측하지 못했고, 게이트는 +import 검사가 아니라 일회용 부팅이다 — 두 호스트 어느 쪽도 돌지 않은 스위트를 초록으로 보고하지 +않도록. 어디서나 검사되는 것은 벤더 타입의 구조적 사본이 벤더의 선언과 여전히 일치하는지다 — +`test/sandbox/microsandbox/vendor-shape.test.ts`, `tsc` 가 강제한다. + 정해진 것: 위 범위 표, 이름, 라이선스(Apache-2.0), 스택([Bun](https://bun.sh), TypeScript, [Turborepo](https://turborepo.com)), 샌드박스 분리, 그리고 선언 문법 — 컴파일러가 필요한 디렉티브가 아니라 `defineAgent` / `defineSandbox`. 근거는 [`docs/project-layout.md`](docs/project-layout.md)에 있다. @@ -136,6 +148,9 @@ packages/ contract/ # 백엔드 계약 harness/ # 그 계약 위의 HarnessV1SandboxProvider docker/ # 로컬 Docker 백엔드 (호스트 전용) + local/ # 호스트 프로세스 백엔드 (호스트 전용, 격리 없음) + just-bash/ # 가상 셸 백엔드 (호스트 프로세스 없음, 실제 바이너리 없음) + microsandbox/ # microVM 백엔드 (호스트 전용, 하이퍼바이저 격리) scripts/ # 런타임을 가정하지 않고 측정하는 프로브 cli/ # @pleasedev/cli — 아직 배포하지 않는다. 명령어가 없다 src/ui/ # 세션이 시작되기 전에 `please dev`가 그리는 부팅 크롬 diff --git a/README.md b/README.md index ddb3e7f..ac20bd7 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ and it runs — [`examples/claude-code-docker`](examples/claude-code-docker) dri | `@pleasedev/core/sandbox` | `defineSandbox`, plus the backend contract — vendor-neutral types | | `@pleasedev/core/sandbox/harness` | the contract rendered as AI SDK `HarnessV1SandboxProvider`, written once for every backend | | `@pleasedev/core/sandbox/docker` | a local Docker backend. **Host-only** — it spawns the `docker` CLI, so it must never reach a Worker bundle | +| `@pleasedev/core/sandbox/local` | a host-process backend — no daemon, no image, **and no isolation**. Host-only for the same reason | +| `@pleasedev/core/sandbox/just-bash` | a virtual-shell backend over [`just-bash`](https://www.npmjs.com/package/just-bash) — no daemon, no image, no host process, and **no real binaries**. `just-bash` is an optional peer dependency | +| `@pleasedev/core/sandbox/microsandbox` | a microVM backend over [`microsandbox`](https://www.npmjs.com/package/microsandbox) — isolation by hypervisor rather than by namespace. Optional peer dependency; **type-checked but not yet run** (see below) | Splitting the harness translation from the backends is what keeps a second backend from re-deriving it, and the subpaths are what keep host-only code out of a target that cannot run it. @@ -99,6 +102,16 @@ because that boundary is the AI SDK's and a wrapper here would only be an obliga And **`workspace` is a declared input**, because no adapter exposes `agents`, `skills` or `settingSources` — a directory is the only route those have into a run. +Three of the four backends are covered by suites that run wherever their prerequisite is present — +`local` and `just-bash` everywhere, `docker` where a daemon is reachable. The **microsandbox** +backend is the exception and says so rather than implying otherwise: `microsandbox` ships no native +addon for `darwin-x64`, which is the platform it was written on, and on the Linux CI runner the +addon loads but the guest dies before its agent relay comes up, for want of a hypervisor. So its +behavioural suite has never been observed to pass, and its gate is a throwaway boot rather than an +import check, so that neither host reports a green suite it never ran. What *is* checked everywhere +is that its structural copies of the vendor's types still match the vendor's own declarations — +`test/sandbox/microsandbox/vendor-shape.test.ts`, enforced by `tsc`. + Decided: the scope table above, the name, the license (Apache-2.0), the stack ([Bun](https://bun.sh), TypeScript, [Turborepo](https://turborepo.com)), the sandbox split, and the declaration syntax — `defineAgent` / `defineSandbox` rather than a compiler-backed directive, @@ -146,6 +159,9 @@ packages/ contract/ # the backend contract harness/ # HarnessV1SandboxProvider over that contract docker/ # local Docker backend (host-only) + local/ # host-process backend (host-only, unisolated) + just-bash/ # virtual-shell backend (no host process, no real binaries) + microsandbox/ # microVM backend (host-only, hypervisor-isolated) scripts/ # probes that measure the runtime rather than assume it cli/ # @pleasedev/cli — unreleased; has no command yet src/ui/ # the boot chrome `please dev` draws before the session starts diff --git a/bun.lock b/bun.lock index cd8da10..9e9cc3e 100644 --- a/bun.lock +++ b/bun.lock @@ -43,9 +43,19 @@ }, "devDependencies": { "@ai-sdk/harness-claude-code": "^1.0.94", + "just-bash": "^3.4.2", + "microsandbox": "^0.6.15", "tsup": "^8", "typescript": "^6", }, + "peerDependencies": { + "just-bash": "^3", + "microsandbox": "^0.6.15", + }, + "optionalPeers": [ + "just-bash", + "microsandbox", + ], }, }, "packages": { @@ -75,6 +85,8 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], @@ -205,6 +217,16 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], + + "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], + + "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], + + "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], + + "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -213,8 +235,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + + "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@ota-meshi/ast-token-store": ["@ota-meshi/ast-token-store@0.3.0", "", {}, "sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg=="], "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], @@ -287,6 +315,20 @@ "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="], + "@superradcompany/microsandbox-darwin-arm64": ["@superradcompany/microsandbox-darwin-arm64@0.6.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-arT8juteFRWc27TcdSk5rGGqjAPOBGsjxYht1DJvPUQzktbU8D/KQbNnV2cujkKJGF2RcwM+FRhKdq1NokIucw=="], + + "@superradcompany/microsandbox-linux-arm64-gnu": ["@superradcompany/microsandbox-linux-arm64-gnu@0.6.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-0P1+TPzC7CB0VcxLR4fRyfXdkz5H3y/eZqHKDuMxS/sjf6frRTLYUSxLDOXU8pCY2XHmoZ4DD+cJJNSJrkV3Wg=="], + + "@superradcompany/microsandbox-linux-x64-gnu": ["@superradcompany/microsandbox-linux-x64-gnu@0.6.15", "", { "os": "linux", "cpu": "x64" }, "sha512-+ljPbTmcG9PCMPspNWDEbTXuyLxvOwI0Y5MJaiIs1IRwhrlrRHVlCOCrrdEIEvoVYo1t4So+OVLWYQIMyi8POQ=="], + + "@superradcompany/microsandbox-win32-arm64-msvc": ["@superradcompany/microsandbox-win32-arm64-msvc@0.6.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-/00pVSb5iRcxfZJHlKhIzmTxH72yG8RgLiBMsyf+olhHviJierV7cDDRB8Pqttz+ait3GgkS3ide6tuoLmy7Nw=="], + + "@superradcompany/microsandbox-win32-x64-msvc": ["@superradcompany/microsandbox-win32-x64-msvc@0.6.15", "", { "os": "win32", "cpu": "x64" }, "sha512-7BuxC/sC790Af/33y2Ti44y5+B8NBd91DYDqjrTWA61TTaEm+8jHVInIUVMios3e7VjTdluYWGXNNhtcrn71OQ=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="], @@ -373,6 +415,8 @@ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "are-docs-informative": ["are-docs-informative@0.0.2", "", {}, "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -381,14 +425,20 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "builtin-modules": ["builtin-modules@5.3.0", "", {}, "sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ=="], "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], @@ -409,13 +459,15 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], "clean-regexp": ["clean-regexp@1.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], @@ -443,16 +495,24 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-indent": ["detect-indent@7.0.2", "", {}, "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-newline": ["detect-newline@4.0.1", "", {}, "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "electron-to-chromium": ["electron-to-chromium@1.5.415", "", {}, "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg=="], @@ -461,6 +521,8 @@ "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -545,6 +607,8 @@ "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -561,12 +625,18 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], @@ -579,6 +649,8 @@ "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -589,6 +661,8 @@ "git-hooks-list": ["git-hooks-list@4.2.1", "", {}, "sha512-WNvqJjOxxs/8ZP9+DWdwWJ7cDsd60NHf39XnD82pDVrKO5q7xfPqpkK6hwEAmBa/ZSEE4IOoR75EzbbIuwGlMw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -607,6 +681,8 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -615,6 +691,8 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -627,6 +705,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -653,6 +733,8 @@ "jsonc-eslint-parser": ["jsonc-eslint-parser@3.3.0", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^5.0.0", "verkit": "^0.3.2" } }, "sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q=="], + "just-bash": ["just-bash@3.4.2", "", { "dependencies": { "diff": "^8.0.4", "fast-xml-parser": "^5.10.1", "file-type": "^21.3.4", "ini": "^6.0.0", "minimatch": "^10.2.6", "modern-tar": "^0.7.7", "papaparse": "^5.6.0", "quickjs-emscripten": "^0.32.0", "re2js": "^1.3.3", "seek-bzip": "^2.0.0", "smol-toml": "^1.8.0", "sprintf-js": "^1.1.3", "sql.js": "^1.14.1", "turndown": "^7.2.4", "undici": "^7.29.0", "yaml": "^2.9.0" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.2.0" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-T0Vpy7YRgCjxJdqG3tkxn0ZnIDLJvVwb8hH4L+6NVdp+Te27jQxjxnszW9ODjEKbWxWujj83rP5S0GQxCSufgg=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -765,10 +847,20 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "microsandbox": ["microsandbox@0.6.15", "", { "optionalDependencies": { "@superradcompany/microsandbox-darwin-arm64": "0.6.15", "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.15", "@superradcompany/microsandbox-linux-x64-gnu": "0.6.15", "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.15", "@superradcompany/microsandbox-win32-x64-msvc": "0.6.15" }, "bin": { "microsandbox": "bin/microsandbox.cjs", "msb": "bin/microsandbox.cjs" } }, "sha512-YomYVgvlownnQTK6we1t5ppAOIbqjGMREuIGsedYwDotDZwj8SCeIkidNVczOoNOTUDbkj/ZGhzPmAvjArO2PA=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + "module-replacements": ["module-replacements@3.3.0", "", {}, "sha512-AVZL23uePazQOZlb/QMGwxsUa1oWA62Cfe6ApPzgasUoF4cdCWAiRPVq4KkaQzXbIDAlpLhLG61Jx8Lju4wjTg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -777,10 +869,20 @@ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], + "node-abi": ["node-abi@3.95.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q=="], + + "node-addon-api": ["node-addon-api@8.9.2", "", {}, "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-liblzma": ["node-liblzma@2.2.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "bin": { "nxz": "lib/cli/nxz.js" } }, "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g=="], + "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], @@ -791,6 +893,8 @@ "object-deep-merge": ["object-deep-merge@2.0.1", "", {}, "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -801,6 +905,8 @@ "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "papaparse": ["papaparse@5.7.0", "", {}, "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-gitignore": ["parse-gitignore@2.0.0", "", {}, "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog=="], @@ -813,6 +919,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -835,14 +943,28 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "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" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "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" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], + + "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "refa": ["refa@0.12.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0" } }, "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g=="], @@ -863,16 +985,26 @@ "rollup": ["rollup@4.63.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.0", "@rollup/rollup-android-arm64": "4.63.0", "@rollup/rollup-darwin-arm64": "4.63.0", "@rollup/rollup-darwin-x64": "4.63.0", "@rollup/rollup-freebsd-arm64": "4.63.0", "@rollup/rollup-freebsd-x64": "4.63.0", "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", "@rollup/rollup-linux-arm-musleabihf": "4.63.0", "@rollup/rollup-linux-arm64-gnu": "4.63.0", "@rollup/rollup-linux-arm64-musl": "4.63.0", "@rollup/rollup-linux-loong64-gnu": "4.63.0", "@rollup/rollup-linux-loong64-musl": "4.63.0", "@rollup/rollup-linux-ppc64-gnu": "4.63.0", "@rollup/rollup-linux-ppc64-musl": "4.63.0", "@rollup/rollup-linux-riscv64-gnu": "4.63.0", "@rollup/rollup-linux-riscv64-musl": "4.63.0", "@rollup/rollup-linux-s390x-gnu": "4.63.0", "@rollup/rollup-linux-x64-gnu": "4.63.0", "@rollup/rollup-linux-x64-musl": "4.63.0", "@rollup/rollup-openbsd-x64": "4.63.0", "@rollup/rollup-openharmony-arm64": "4.63.0", "@rollup/rollup-win32-arm64-msvc": "4.63.0", "@rollup/rollup-win32-ia32-msvc": "4.63.0", "@rollup/rollup-win32-x64-gnu": "4.63.0", "@rollup/rollup-win32-x64-msvc": "4.63.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scslre": ["scslre@0.3.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.0", "regexp-ast-analysis": "^0.7.0" } }, "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ=="], + "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "sort-object-keys": ["sort-object-keys@2.1.0", "", {}, "sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g=="], "sort-package-json": ["sort-package-json@3.7.1", "", { "dependencies": { "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "git-hooks-list": "^4.1.1", "is-plain-obj": "^4.1.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.1", "tinyglobby": "^0.2.15" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-ssk1HG7whF8N/T1IsNAQrtHG5Cbdi0rAgRJZXYBr9hF5xaHnBNzUx/W6LcthEW7FhOwvZssbESZuO+GxssqAyA=="], @@ -889,20 +1021,36 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "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" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -913,6 +1061,8 @@ "to-valid-identifier": ["to-valid-identifier@1.0.0", "", { "dependencies": { "@sindresorhus/base62": "^1.0.0", "reserved-identifiers": "^1.0.0" } }, "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "toml-eslint-parser": ["toml-eslint-parser@1.0.3", "", { "dependencies": { "eslint-visitor-keys": "^5.0.0" } }, "sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -923,14 +1073,20 @@ "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="], + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -965,10 +1121,14 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -1029,8 +1189,12 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "validate-npm-package-license/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], diff --git a/docs/project-layout.md b/docs/project-layout.md index 16fd1d3..2ece486 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -276,6 +276,23 @@ every container it creates — `containerEnv`, which the caller's own `env` can *is* a deliberate sandbox, so the claim is true rather than a way around the check, and the probe no longer sets it: the run is now also the check that the backend does. +The same constraint read from the other side is why `@pleasedev/core/sandbox/local` — a host-process +backend added since, for the cases where no daemon is reachable — deliberately does **not** declare +it. There the claim would be false, and the root check it defeats is the last thing standing between +a bypassed permission prompt and the developer's own home directory. Isolation is what makes the +declaration honest, so only the backend that provides isolation makes it. + +`@pleasedev/core/sandbox/microsandbox` reads the constraint the same way the Docker backend does and +declares `IS_SANDBOX=1` for the same reason, with more room to spare: a microVM is a separate +kernel, so the claim is true by a wider margin than a container's. The caller still wins by passing +`IS_SANDBOX` in `env`. + +`@pleasedev/core/sandbox/just-bash` — a virtual-shell backend added since, over an interpreter with +its own in-memory filesystem — sits outside this question rather than on either side of it. Its +isolation is real, but there is no `getuid()` to gate and no `node` to run: its commands are +interpreted, so the adapter's CLI cannot be launched inside it at all. It is a backend for the parts +of a workflow that are shell work, not for the part that is the agent. + **3. How does `host-tools/` behave across both targets?** A Worker has a per-invocation CPU limit; a Node deployment has a real filesystem and owns its own restart reconciliation. The README says this project absorbs that asymmetry rather than leaking it, and this is the first place that has to be diff --git a/packages/core/package.json b/packages/core/package.json index eae5121..7f58bcd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,21 @@ "types": "./dist/sandbox/harness/index.d.ts", "import": "./dist/sandbox/harness/index.js" }, + "./sandbox/just-bash": { + "bun": "./src/sandbox/just-bash/index.ts", + "types": "./dist/sandbox/just-bash/index.d.ts", + "import": "./dist/sandbox/just-bash/index.js" + }, + "./sandbox/microsandbox": { + "bun": "./src/sandbox/microsandbox/index.ts", + "types": "./dist/sandbox/microsandbox/index.d.ts", + "import": "./dist/sandbox/microsandbox/index.js" + }, + "./sandbox/local": { + "bun": "./src/sandbox/local/index.ts", + "types": "./dist/sandbox/local/index.d.ts", + "import": "./dist/sandbox/local/index.js" + }, "./sandbox/docker": { "bun": "./src/sandbox/docker/index.ts", "types": "./dist/sandbox/docker/index.d.ts", @@ -46,11 +61,25 @@ "clean": "rm -rf dist .turbo", "type-check": "tsc --noEmit" }, + "peerDependencies": { + "just-bash": "^3", + "microsandbox": "^0.6.15" + }, + "peerDependenciesMeta": { + "just-bash": { + "optional": true + }, + "microsandbox": { + "optional": true + } + }, "dependencies": { "@ai-sdk/harness": "^1.0.91" }, "devDependencies": { "@ai-sdk/harness-claude-code": "^1.0.94", + "just-bash": "^3.4.2", + "microsandbox": "^0.6.15", "tsup": "^8", "typescript": "^6" } diff --git a/packages/core/src/sandbox/contract/index.ts b/packages/core/src/sandbox/contract/index.ts index e7d75b9..357d1bc 100644 --- a/packages/core/src/sandbox/contract/index.ts +++ b/packages/core/src/sandbox/contract/index.ts @@ -13,7 +13,11 @@ // structurally instead of re-exported, and why this is a peer of the AI SDK's sandbox // session rather than an extension of it. -export { SandboxNoExitRecordError, SandboxWaitTimeoutError } from './types' +export { + SandboxFileNotFoundError, + SandboxNoExitRecordError, + SandboxWaitTimeoutError, +} from './types' export type { ProcessExit, diff --git a/packages/core/src/sandbox/contract/types.ts b/packages/core/src/sandbox/contract/types.ts index dc8864d..a98b4fa 100644 --- a/packages/core/src/sandbox/contract/types.ts +++ b/packages/core/src/sandbox/contract/types.ts @@ -128,7 +128,7 @@ export interface WaitForExitOptions { /** * The wait ended before the process did. * - * The one runtime value this package exports, deliberately: a caller that must distinguish + * A runtime value rather than a type, deliberately: a caller that must distinguish * "still running, I stopped watching" from "the RPC failed" needs a shared identity to test * against, and a string-matched message is not one. */ @@ -227,6 +227,32 @@ export interface SandboxFileContent { encoding?: 'utf-8' | 'base64' } +/** + * A read named a path the sandbox does not have. + * + * On the contract rather than in each backend, for the reason {@link SandboxWaitTimeoutError} + * gives: a caller that must tell "the file is not there" from "the read failed" needs a shared + * identity to test against, and two classes of the same name in two backends are not one — + * `instanceof` would answer no across them, and a `name` comparison is a string match wearing a + * type's clothes. A backend SHOULD raise this where it applies. + * + * **It is not a promise about the moment after it is raised.** Absence is observed, not held: + * the path may be created a microsecond later, and a read that succeeded may name a path + * already gone. A caller that needs absence as a value rather than a rejection pairs the read + * with {@link SandboxSession.exists} and treats the pair as advisory — which is what + * `../harness/files.ts` does, and why it re-checks rather than trusting either call alone. + */ +export class SandboxFileNotFoundError extends Error { + /** The path as the caller named it, not as the backend resolved it. */ + readonly path: string + + constructor(path: string) { + super(`file '${path}' does not exist in the sandbox`) + this.name = 'SandboxFileNotFoundError' + this.path = path + } +} + /** * Reading and writing files in the sandbox. * @@ -240,7 +266,17 @@ export interface SandboxFileContent { * harness provider — written once, over the contract, for every backend at once. */ export interface SandboxFiles { - /** Rejects when the path does not exist; callers wanting absence-as-value pair it with `exists`. */ + /** + * Rejects when the path does not exist; callers wanting absence-as-value pair it with + * `exists`. + * + * A backend SHOULD reject with {@link SandboxFileNotFoundError}, and both backends here do — + * but the promise is the rejection, not its type. A vendor client satisfies this contract + * structurally and throws whatever it throws, so a caller that must handle every backend + * cannot switch on the class alone. That is why `../harness/files.ts` decides absence by + * re-checking `exists` rather than by matching an error, and why its note saying so is not in + * conflict with the class being named here. + */ readFile: ((path: string, options: { encoding: 'none' }) => Promise) & ((path: string, options?: { encoding?: SandboxFileEncoding }) => Promise) writeFile: ( diff --git a/packages/core/src/sandbox/docker/files.ts b/packages/core/src/sandbox/docker/files.ts index cd806e7..6653e35 100644 --- a/packages/core/src/sandbox/docker/files.ts +++ b/packages/core/src/sandbox/docker/files.ts @@ -14,20 +14,10 @@ import type { * further translation that `../harness/files.ts` already performs for every backend at once. */ import { Buffer } from 'node:buffer' +import { SandboxFileNotFoundError } from '../contract' import { execInContainer, execInContainerBytes, spawnInContainer } from './exec' import { quoteArg } from './shell-quote' -/** Raised when a read names a path the container does not have. */ -export class SandboxFileNotFoundError extends Error { - readonly path: string - - constructor(path: string) { - super(`file '${path}' does not exist in the sandbox`) - this.name = 'SandboxFileNotFoundError' - this.path = path - } -} - function parentDirectory(path: string): string { const index = path.lastIndexOf('/') return index <= 0 ? '/' : path.slice(0, index) diff --git a/packages/core/src/sandbox/docker/index.ts b/packages/core/src/sandbox/docker/index.ts index ed8bcb3..1700637 100644 --- a/packages/core/src/sandbox/docker/index.ts +++ b/packages/core/src/sandbox/docker/index.ts @@ -6,14 +6,17 @@ * reason: importing `@pleasedev/core` or `@pleasedev/core/sandbox` pulls none of it in. */ +// Re-exported so a caller holding this subpath need not also import the contract to catch it. +// The class itself lives on the contract, because its whole purpose is one identity across +// backends — see `../contract/types.ts`. +export { SandboxFileNotFoundError } from '../contract' + export { DOCKER_BIN, DockerCommandError, isDockerAvailable } from './cli' export type { DockerCallOptions, DockerResult } from './cli' export { containerName, createContainerHandle } from './container' export type { ContainerHandle, ContainerOptions } from './container' -export { SandboxFileNotFoundError } from './files' - export { JOURNAL_ROOT, journalPaths } from './journal' export type { JournalMeta, JournalPaths } from './journal' diff --git a/packages/core/src/sandbox/just-bash/env.ts b/packages/core/src/sandbox/just-bash/env.ts new file mode 100644 index 0000000..2428241 --- /dev/null +++ b/packages/core/src/sandbox/just-bash/env.ts @@ -0,0 +1,75 @@ +/** + * Per-command environment, which the interpreter does not otherwise offer. + * + * `runCommand({ env })` is accepted by `just-bash` and then never applied — measured: a command + * run with `env: { PROBE: 'x' }` sees `$PROBE` empty. Only the sandbox *constructor*'s `env` + * reaches a command, and that one is fixed for the sandbox's lifetime, so the contract's + * per-exec `env` needs a mechanism of its own. + * + * The mechanism is a shell that exports the variables and then `exec`s the caller's command. + * Two things make that safe to build: + * + * - **The script is a constant.** Nothing from the caller is ever interpolated into shell text — + * names, values and argv all arrive as positional parameters, so there is no quoting to get + * wrong and no injection surface. Same discipline as `../local/journal.ts`'s wrapper. + * - **`export "K=V"` takes the whole assignment as one word**, so a value may contain spaces, + * quotes, `$`, or a newline without changing how the script parses. + * + * The prefix's own `sh` is not a second interpreter — `just-bash` runs one — and `exec` replaces + * it, so the caller's command keeps the process the handle refers to. Verified to reach a nested + * `sh -c` too, which a bare `env K=V` prefix does not. + */ +import type { SandboxCommand } from '../contract' + +/** + * `$1` is how many `K=V` words follow; the rest is the command. + * + * Counting rather than scanning for a separator is what keeps a value that looks like the + * separator from ending the list early. + */ +export const ENV_WRAPPER_SCRIPT + = 'n=$1; shift; while [ "$n" -gt 0 ]; do export "$1"; shift; n=$((n-1)); done; exec "$@"' + +/** POSIX name: letters, digits and underscore, not starting with a digit. */ +const NAME = /^[A-Z_]\w*$/i + +/** Raised for an env name the shell could not export as one word. */ +export class JustBashEnvNameError extends Error { + readonly key: string + constructor(key: string) { + super(`'${key}' is not a usable environment variable name`) + this.name = 'JustBashEnvNameError' + this.key = key + } +} + +/** + * The argv to hand the interpreter: the caller's own when there is nothing to export, and the + * wrapper in front of it when there is. + * + * An unusable name is rejected rather than dropped or escaped. `export "1BAD=x"` would fail + * inside the wrapper and take the whole command's exit code with it, which reports the caller's + * command as broken instead of the env entry that is. + */ +export function envArgv(argv: SandboxCommand, env?: Record): SandboxCommand { + const entries = Object.entries(env ?? {}) + if (entries.length === 0) { + return argv + } + for (const [key] of entries) { + if (!NAME.test(key)) { + throw new JustBashEnvNameError(key) + } + } + return [ + 'sh', + '-c', + ENV_WRAPPER_SCRIPT, + // `$0` for the wrapper shell. Positional parameters start after it, so the count lands on + // `$1` as the script expects. + 'sh', + String(entries.length), + ...entries.map(([key, value]) => `${key}=${value}`), + ...argv, + ] +} diff --git a/packages/core/src/sandbox/just-bash/files.ts b/packages/core/src/sandbox/just-bash/files.ts new file mode 100644 index 0000000..8e32f87 --- /dev/null +++ b/packages/core/src/sandbox/just-bash/files.ts @@ -0,0 +1,131 @@ +/** + * File access inside the virtual filesystem. + * + * Declared in the contract's Cloudflare-shaped form — positional path, encoding-selected + * overloads — for the reason `SandboxFiles` documents. The vendor's own surface is close enough + * that this is mostly renaming: `writeFiles` takes a map, `readFile` takes the encoding + * positionally, and `mkDir` is spelled with a capital D. + * + * Unlike `../local`, the paths here reach nothing outside the sandbox: the filesystem is the + * interpreter's own, so a path that climbs out with `..` climbs out of a virtual root. That is + * the one dimension in which this backend is stronger than the local one. + * + * **This filesystem stores text, not bytes.** A write is decoded as UTF-8 before it is stored, + * so a byte sequence that is not valid UTF-8 comes back as replacement characters — measured: + * four bytes `00 FF FE 01` written as base64 read back as eight, and `wc -c` inside the sandbox + * agreed with the corrupted length. Base64 is therefore a *transport* encoding here and not a + * binary capability, and {@link JustBashBinaryUnsupportedError} refuses the write rather than + * letting a caller discover the corruption later. + */ +import type { SandboxFileContent, SandboxFileEncoding, SandboxFiles, SandboxFileStream } from '../contract' +import type { JustBashHandle } from './sandbox' +import { Buffer } from 'node:buffer' +import { SandboxFileNotFoundError } from '../contract' + +/** Raised for a write this filesystem would have to corrupt in order to store. */ +export class JustBashBinaryUnsupportedError extends Error { + readonly path: string + constructor(path: string, cause: unknown) { + super( + `'${path}' is not valid UTF-8. The just-bash filesystem stores text, so these bytes ` + + 'cannot be written without corruption — use the docker or local backend for binary files.', + ) + this.name = 'JustBashBinaryUnsupportedError' + this.path = path + this.cause = cause + } +} + +/** + * Reject base64 the filesystem could not store faithfully. + * + * The check is the same decode the vendor performs, run in `fatal` mode so it throws where the + * vendor's substitutes a replacement character. + */ +function assertStorable(path: string, base64: string): void { + try { + new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(base64, 'base64')) + } + catch (cause) { + throw new JustBashBinaryUnsupportedError(path, cause) + } +} + +/** Resolve a relative path against the sandbox's working directory, POSIX-style. */ +export function resolveVirtualPath(cwd: string, path: string): string { + return path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}` +} + +async function read( + handle: JustBashHandle, + path: string, + encoding: SandboxFileEncoding | undefined, +): Promise { + const sandbox = await handle.ready() + const resolved = resolveVirtualPath(handle.cwd, path) + const wanted = encoding === 'base64' ? 'base64' : 'utf-8' + try { + return { content: await sandbox.readFile(resolved, wanted), encoding: wanted } + } + catch (cause) { + // The vendor reports a missing path by throwing, and it is the only failure `readFile` has + // — but it is not the only one it *could* have, so the cause is kept rather than swallowed. + throw Object.assign(new SandboxFileNotFoundError(path), { cause }) + } +} + +/** + * A streamed read, as one chunk. + * + * `readFile` hands back a fully materialised string, so there is nothing to stream from — the + * whole file is in memory before the stream exists. It is offered anyway because the contract's + * `encoding: 'none'` overload selects it, and a caller written against that overload should not + * have to know which backend it is talking to. + */ +async function readStream(handle: JustBashHandle, path: string): Promise { + const decoded = await read(handle, path, 'base64') + const bytes = new Uint8Array(Buffer.from(decoded.content, 'base64')) + return { + content: new ReadableStream({ + start: (controller) => { + controller.enqueue(bytes) + controller.close() + }, + }), + } +} + +async function collect(stream: ReadableStream): Promise { + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()) + return Buffer.from(bytes).toString('base64') +} + +export function createJustBashFiles(handle: JustBashHandle): SandboxFiles { + const readFile = ((path: string, options?: { encoding?: SandboxFileEncoding | 'none' }) => ( + options?.encoding === 'none' + ? readStream(handle, path) + : read(handle, path, options?.encoding) + )) as SandboxFiles['readFile'] + + return { + readFile, + writeFile: async (path, content, options) => { + const sandbox = await handle.ready() + // A stream is collected as base64 rather than decoded as text, so the bytes reach the + // storability check unchanged rather than being quietly mangled on the way to it. + const body = typeof content === 'string' + ? { content, encoding: (options?.encoding === 'base64' ? 'base64' : 'utf-8') as 'utf-8' | 'base64' } + : { content: await collect(content), encoding: 'base64' as const } + if (body.encoding === 'base64') { + assertStorable(path, body.content) + } + await sandbox.writeFiles({ [resolveVirtualPath(handle.cwd, path)]: body }) + }, + mkdir: async (path, options) => { + const sandbox = await handle.ready() + await sandbox.mkDir(resolveVirtualPath(handle.cwd, path), { + recursive: options?.recursive ?? false, + }) + }, + } +} diff --git a/packages/core/src/sandbox/just-bash/index.ts b/packages/core/src/sandbox/just-bash/index.ts new file mode 100644 index 0000000..bc6d571 --- /dev/null +++ b/packages/core/src/sandbox/just-bash/index.ts @@ -0,0 +1,52 @@ +/** + * `@pleasedev/core/sandbox/just-bash` — a virtual-shell backend for the sandbox contract. + * + * **Needs no daemon, no image and no host process**, because it runs no host process: `just-bash` + * interprets the commands over an in-memory filesystem. It is the only one of the three backends + * that could run inside a Worker bundle in principle — but it is still a separate entry point, + * so importing `@pleasedev/core` or `@pleasedev/core/sandbox` pulls none of it in. + * + * `just-bash` is an **optional peer dependency**: install it to use this subpath. It is imported + * dynamically, so its absence is an actionable error rather than a resolution failure — see + * {@link JustBashUnavailableError}. + * + * What this backend cannot do is documented on `./provider.ts`, and none of it is a gap: no real + * binaries, no ports, no live output, and no bytes that are not valid UTF-8. + */ + +// Re-exported so a caller holding this subpath need not also import the contract to catch it. +// The class itself lives on the contract, because its whole purpose is one identity across +// backends — see `../contract/types.ts`. +export { SandboxFileNotFoundError } from '../contract' + +export { ENV_WRAPPER_SCRIPT, envArgv, JustBashEnvNameError } from './env' + +export { + createJustBashFiles, + JustBashBinaryUnsupportedError, + resolveVirtualPath, +} from './files' + +export { createProcessHandle, toProcessStatus } from './process' +export type { ProcessRecord } from './process' + +export { + createJustBashSandbox, + DEFAULT_WORK_DIR, + JustBashPortsUnavailableError, +} from './provider' +export type { JustBashSandboxOptions } from './provider' + +export { defenseInDepthSupported, JustBashUnavailableError, loadJustBash } from './runtime' +export type { + JustBashCommand, + JustBashOutputMessage, + JustBashRunParams, + JustBashSandbox, +} from './runtime' + +export { createJustBashHandle } from './sandbox' +export type { JustBashHandle, JustBashSandboxHandleOptions } from './sandbox' + +export { createJustBashSession } from './session' +export type { JustBashSessionOptions } from './session' diff --git a/packages/core/src/sandbox/just-bash/process.ts b/packages/core/src/sandbox/just-bash/process.ts new file mode 100644 index 0000000..587470c --- /dev/null +++ b/packages/core/src/sandbox/just-bash/process.ts @@ -0,0 +1,238 @@ +/** + * One `just-bash` command, as the contract's {@link SandboxProcessHandle}. + * + * The vendor's `Command` already carries most of the surface — `logs()`, `wait()`, `kill()` — + * so this file is mostly translation. Two things it does not carry, and this does: + * + * **Why a command ended.** `just-bash` reports every cancellation as exit code 124, GNU + * `timeout`'s convention, whether the caller killed it or its own budget expired. That is the + * ambiguity `../docker/journal.ts` refuses to read an exit code for, and the answer here is the + * same in a different form: the timeout is *this* file's `AbortController` and its timer, so + * `timedOut` is set from whether that timer fired rather than from the code the shell returned. + * + * **A wait that ends before the process does.** The vendor's `wait()` has no timeout, and the + * contract requires a bounded one to reject rather than resolve — every caller's `catch` is its + * timeout path, so a synthetic exit would let it act over a command that is still running. + */ +import type { + ProcessExit, + ProcessLogEvent, + ProcessLogsOptions, + ProcessStatus, + SandboxCommand, + SandboxProcessHandle, + WaitForExitOptions, +} from '../contract' +import type { JustBashCommand } from './runtime' +import { SandboxWaitTimeoutError } from '../contract' + +/** What one exec remembers beyond the vendor's own handle. */ +export interface ProcessRecord { + command: JustBashCommand + argv: SandboxCommand + cwd: string + startedAt: string + /** Set by the timeout this backend owns — never inferred from the exit code. */ + timedOut: () => boolean + /** Ends the command's own timeout timer once it has settled. */ + settle: () => void +} + +function toExit(record: ProcessRecord, code: number): ProcessExit { + return { code, timedOut: record.timedOut() } +} + +/** + * Collect the vendor's output messages into contract events. + * + * A cursor is the number of messages already delivered. It is opaque by contract, and this is + * the shape it takes for a backend whose log is a list rather than a byte stream — there is no + * file to take an offset into, and `logs()` re-reads the same list every time. + * + * The list is short: the vendor coalesces a command's output into **one message per stream**, + * so `echo one ; echo two` arrives as a single `stdout` message carrying both lines. A cursor + * therefore advances in units far coarser than the byte offsets `../docker` and `../local` hand + * back — still monotonic and still opaque, but not a fine-grained resume point. + */ +async function collect(record: ProcessRecord): Promise { + const events: ProcessLogEvent[] = [] + const encoder = new TextEncoder() + let index = 0 + for await (const message of record.command.logs()) { + index += 1 + events.push({ + type: message.type, + cursor: String(index), + timestamp: message.timestamp.toISOString(), + data: encoder.encode(message.data), + }) + } + return events +} + +/** + * The work, or `undefined` when the signal fires first. + * + * The work is not cancelled — the vendor's `logs()` is an async iterator with nothing to + * abort — so it runs to completion in the background and its result is dropped. That is the + * whole point: the caller gets its answer at abort time instead of at command-exit time. + */ +function raceAbort(work: Promise, signal: AbortSignal): Promise { + // The listener is removed through its own signal rather than by hand, so a read that + // finishes first leaves nothing attached to the caller's — which may outlive this call and + // be reused for other work. + const listener = new AbortController() + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(undefined), { + once: true, + signal: listener.signal, + }) + }) + return Promise.race([work, aborted]).finally(() => listener.abort()) +} + +/** A stream that is already over, for a read that was abandoned before it could produce one. */ +function closedStream(): ReadableStream { + return new ReadableStream({ start: controller => controller.close() }) +} + +function parseCursor(cursor: string | undefined): number { + const value = Number.parseInt(cursor ?? '', 10) + return Number.isInteger(value) && value > 0 ? value : 0 +} + +/** + * Open the log as a stream of contract events. + * + * `follow` is accepted and does nothing, because there is nothing to follow: `just-bash` buffers + * a command's output and delivers all of it when the command ends — measured, with + * `echo first ; sleep 1 ; echo second` arriving as one batch at the one-second mark. A caller + * streaming a long turn to watch it progress gets everything at the end here, which is a reason + * to choose a different backend rather than something this one can paper over. + * + * `replay` is likewise always in effect: the vendor's `logs()` re-reads the whole list on every + * call, so a read without it cannot start at a live tail that does not exist. + */ +async function logs( + record: ProcessRecord, + options: ProcessLogsOptions = {}, +): Promise> { + // The abort is honoured around the collect, not after it. `collect` drains the vendor's + // `logs()`, which does not yield until the command ends — so checking the signal only once + // the events are in hand would make an aborted read return when the command it gave up on + // finished, which for a long turn is exactly the wait the caller aborted to avoid. + if (options.signal?.aborted === true) { + return closedStream() + } + const events = options.signal === undefined + ? await collect(record) + : await raceAbort(collect(record), options.signal) + if (events === undefined) { + return closedStream() + } + + const from = parseCursor(options.since) + const pending = events.slice(from) + const code = record.command.exitCode + + return new ReadableStream({ + start: (controller) => { + for (const event of pending) { + controller.enqueue(event) + } + if (code !== undefined) { + controller.enqueue({ + type: 'terminal', + state: 'exited', + cursor: String(events.length), + timestamp: new Date().toISOString(), + exit: toExit(record, code), + }) + } + controller.close() + }, + }) +} + +async function waitForExit( + record: ProcessRecord, + options: WaitForExitOptions = {}, +): Promise { + const startedAt = Date.now() + const finished = record.command.wait().then((result) => { + record.settle() + return toExit(record, result.exitCode) + }) + + if (options.timeout === undefined && options.signal === undefined) { + return finished + } + + // The wait is bounded, never the command: an expired wait rejects and leaves the command + // running, which is what the caller's `catch` is written against. + return new Promise((resolve, reject) => { + let done = false + const expire = () => { + if (!done) { + done = true + reject(new SandboxWaitTimeoutError(record.command.cmdId, Date.now() - startedAt)) + } + } + const timer = options.timeout === undefined + ? undefined + : setTimeout(expire, options.timeout) + if (options.signal?.aborted === true) { + expire() + } + options.signal?.addEventListener('abort', expire, { once: true }) + + finished.then( + (exit) => { + if (!done) { + done = true + resolve(exit) + } + }, + (cause: unknown) => { + if (!done) { + done = true + reject(cause instanceof Error ? cause : new Error(String(cause))) + } + }, + ).finally(() => { + if (timer !== undefined) { + clearTimeout(timer) + } + options.signal?.removeEventListener('abort', expire) + }) + }) +} + +export function toProcessStatus(processId: string, record: ProcessRecord): ProcessStatus { + const base = { + id: processId, + // The contract types `pid` as a number and a virtual command has none. Zero is the same + // stand-in `../local` uses for a process whose pid is not yet known — there is no honest + // number to report, and inventing one would suggest the host could signal it. + pid: 0, + command: record.argv, + cwd: record.cwd, + startedAt: record.startedAt, + } + const code = record.command.exitCode + return code === undefined + ? { ...base, state: 'running' } + : { ...base, state: 'exited', exit: toExit(record, code), endedAt: new Date().toISOString() } +} + +export function createProcessHandle(processId: string, record: ProcessRecord): SandboxProcessHandle { + return { + id: processId, + status: async () => toProcessStatus(processId, record), + logs: options => logs(record, options), + waitForExit: options => waitForExit(record, options), + // The vendor's `kill()` takes no signal, so the contract's number is accepted and dropped: + // there is no process to deliver it to, only an interpreter loop to stop. + kill: async () => record.command.kill(), + } +} diff --git a/packages/core/src/sandbox/just-bash/provider.ts b/packages/core/src/sandbox/just-bash/provider.ts new file mode 100644 index 0000000..4139596 --- /dev/null +++ b/packages/core/src/sandbox/just-bash/provider.ts @@ -0,0 +1,156 @@ +/** + * A virtual-shell backend for the sandbox contract, over `just-bash`. + * + * The third of three, and the one that runs no processes at all: `just-bash` is a bash + * interpreter written in TypeScript over an in-memory filesystem, so a command here is parsed + * and evaluated rather than executed. Nothing is installed, nothing is spawned, and nothing + * survives the host process. + * + * That buys the one thing the other two cannot offer together — it runs anywhere, instantly, + * with the sandboxed filesystem genuinely separate from the host's. `../local` has real binaries + * but no separation; `../docker` has both but needs a daemon and an image pull. + * + * **Three limits are structural, not gaps to be filled later.** Each is measured, and each is a + * reason to choose a different backend rather than something this one can grow: + * + * - **No real binaries.** `git --version` exits 127 `command not found`. The command set is the + * interpreter's own, so a turn that runs a package manager, a compiler or `git` cannot run + * here at all. + * - **No ports.** There is no listener to reach, so {@link JustBashPortsUnavailableError} is + * what `portEndpoint` answers — see the note on that class for why it throws rather than + * inventing a URL. + * - **No live output.** The interpreter buffers a command's output and delivers it on + * completion, so `logs({ follow: true })` returns everything at the end rather than as it + * happens. See `./process.ts`. + */ +import type { + SandboxPortEndpoint, + SandboxPortEndpointOptions, + SandboxProvider, + SandboxSession, +} from '../contract' +import type { ProcessRecord } from './process' +import type { JustBashHandle } from './sandbox' +import { createJustBashHandle } from './sandbox' +import { createJustBashSession } from './session' + +/** + * Raised by `portEndpoint`, which this backend cannot answer. + * + * The contract requires `portEndpoint` rather than making it optional, and says why: "a backend + * that cannot say where its ports are cannot run the harness at all, and finding that out at the + * first connect — inside a workflow step — is strictly worse than finding it out at build time." + * This class is that sentence at run time. The alternative — answering `http://127.0.0.1:` + * — would hand back a URL that dials the *host*, which is both wrong and dangerous: a caller + * would connect to whatever happens to be listening there. + * + * So a bridge-backed harness adapter cannot use this backend, and learns it from a named error + * naming the reason. `../docker/provider.ts` already records the same fact from the other side. + */ +export class JustBashPortsUnavailableError extends Error { + readonly port: number + + constructor(port: number) { + super( + `the just-bash backend cannot expose port ${port}: its commands are interpreted, not ` + + 'executed, so nothing in the sandbox ever listens on a socket. Use the docker or local ' + + 'backend for a harness adapter that needs a port.', + ) + this.name = 'JustBashPortsUnavailableError' + this.port = port + } +} + +/** Working directory a sandbox starts in when the caller names none. */ +export const DEFAULT_WORK_DIR = '/work' + +export interface JustBashSandboxOptions { + /** Working directory inside the virtual filesystem. Defaults to {@link DEFAULT_WORK_DIR}. */ + workDir?: string + /** Environment every command starts with. Nothing is inherited from the host. */ + env?: Readonly> + /** + * Passed through to `just-bash`. Defaults to whether the runtime supports it — it is broken + * under Bun, measured; see `./runtime.ts`. + */ + defenseInDepth?: boolean + /** + * Anything else `just-bash`'s own `SandboxOptions` accepts — `network`, `python`, + * `javascript`, `commands`, `customCommands`, `overlayRoot` — passed through untouched. + * + * Deliberately an escape hatch rather than a re-declaration of the vendor's options. Copying + * them here would make this package's surface drift against a dependency it does not own, and + * every one of them is a `just-bash` concept with no meaning to the other backends. + */ + vendorOptions?: Readonly> +} + +export function createJustBashSandbox(options: JustBashSandboxOptions = {}): SandboxProvider { + const handles = new Map() + // Beside the handle rather than inside the session, because `session()` is called per use: a + // registry created per session object would lose every process the previous call started. + const registries = new Map>() + + // Cached per sandbox id: `session()` is called per use rather than held, and a fresh handle + // each time would defeat the lazy acquisition behind it — and, here, would silently hand back + // an empty filesystem to the second caller. + const handleFor = (sandboxId: string): JustBashHandle => { + const existing = handles.get(sandboxId) + if (existing !== undefined) { + return existing + } + const created = createJustBashHandle({ + cwd: options.workDir ?? DEFAULT_WORK_DIR, + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.defenseInDepth === undefined ? {} : { defenseInDepth: options.defenseInDepth }), + ...(options.vendorOptions === undefined ? {} : { vendorOptions: options.vendorOptions }), + }) + handles.set(sandboxId, created) + return created + } + + const registryFor = (sandboxId: string): Map => { + const existing = registries.get(sandboxId) + if (existing !== undefined) { + return existing + } + const created = new Map() + registries.set(sandboxId, created) + return created + } + + const portEndpoint = async ( + _sandboxId: string, + port: number, + _endpointOptions?: SandboxPortEndpointOptions, + ): Promise => { + throw new JustBashPortsUnavailableError(port) + } + + const session = (sandboxId: string): SandboxSession => { + const handle = handleFor(sandboxId) + const created = createJustBashSession({ handle, processes: registryFor(sandboxId) }) + return { + ...created, + destroy: async () => { + // Evicted before the teardown is awaited, not in a `finally` after it. Awaiting first + // leaves a window in which a new `session(id)` finds this handle still registered, + // adopts it, and reacquires — and the identity check would then still pass, so this + // call would delete the *new* session's handle and its process registry on the way + // out. Removing the entry up front makes the next `session(id)` build a fresh handle + // instead of one that is being torn down. + if (handles.get(sandboxId) === handle) { + handles.delete(sandboxId) + registries.delete(sandboxId) + } + await created.destroy() + }, + } + } + + return { + backend: 'just-bash', + session, + portEndpoint, + } satisfies SandboxProvider +} diff --git a/packages/core/src/sandbox/just-bash/runtime.ts b/packages/core/src/sandbox/just-bash/runtime.ts new file mode 100644 index 0000000..b81080b --- /dev/null +++ b/packages/core/src/sandbox/just-bash/runtime.ts @@ -0,0 +1,142 @@ +/** + * Loading `just-bash`, and the two accommodations it needs here. + * + * The package is an **optional peer dependency**, not a dependency: it is ~megabytes of shell + * interpreter that a caller using the Docker or local backend never runs, and eve treats it the + * same way ("The `just-bash` package is not bundled with eve"). So it is imported dynamically + * and its absence is reported as an actionable install instruction rather than a module + * resolution failure from inside a workflow step. + * + * **The types below are structural copies, not imports of the vendor's.** That is the rule + * `../contract/types.ts` states about itself and the reason is the same one: an `import type` + * from `just-bash` would put the package in this subpath's generated `.d.ts`, so a consumer who + * never installed the optional peer would fail to type-check against `@pleasedev/core`. Only the + * surface actually used is copied. + */ + +/** One chunk of a command's output, as `just-bash` reports it. */ +export interface JustBashOutputMessage { + type: 'stdout' | 'stderr' + data: string + timestamp: Date +} + +/** + * A running or finished command. + * + * `logs()` is **replayable** — it can be consumed repeatedly, before and after `wait()` — which + * is what lets this backend answer `logs({ replay: true })` after a process has exited without + * journalling anything. Measured, not assumed. + */ +export interface JustBashCommand { + readonly cmdId: string + readonly cwd: string + readonly startedAt: Date + exitCode: number | undefined + logs: () => AsyncGenerator + wait: () => Promise<{ exitCode: number }> + kill: () => Promise +} + +/** + * The vendor's `env` field is deliberately absent. + * + * It exists on the real parameter type and has no effect — a command run with + * `env: { PROBE: 'x' }` sees `$PROBE` empty. Copying it here would offer a field that silently + * does nothing; `./env.ts` carries the contract's per-exec env instead. + */ +export interface JustBashRunParams { + cmd: string + args?: string[] + cwd?: string + detached?: boolean + signal?: AbortSignal +} + +export interface JustBashSandbox { + runCommand: (params: JustBashRunParams & { detached: true }) => Promise + writeFiles: (files: Record) => Promise + readFile: (path: string, encoding?: 'utf-8' | 'base64') => Promise + mkDir: (path: string, opts?: { recursive?: boolean }) => Promise + stop: () => Promise +} + +export interface JustBashSandboxOptions { + cwd?: string + env?: Record + defenseInDepth?: boolean + [option: string]: unknown +} + +interface JustBashModule { + Sandbox: { create: (options?: JustBashSandboxOptions) => Promise } +} + +/** Raised when the optional peer dependency is not installed. */ +export class JustBashUnavailableError extends Error { + constructor(cause: unknown) { + super( + 'the `just-bash` package is not installed. It is an optional peer dependency of ' + + '@pleasedev/core — run `bun add just-bash` to use @pleasedev/core/sandbox/just-bash.', + ) + this.name = 'JustBashUnavailableError' + this.cause = cause + } +} + +/** + * Whether `just-bash`'s defence-in-depth layer can run on this runtime. + * + * It hardens the embedding process by monkey-patching Node globals, and one of its patches is + * `Module._resolveFilename`. Under Bun that patch fails and the *first command* dies with + * `DefenseInDepthBox: critical patches failed` — not the constructor, so a caller finds out one + * layer away from the cause. Measured on Bun 1.3.14; the same call succeeds on Node 24. + * + * So the default follows the runtime rather than the vendor's, and the caller can always say + * otherwise. Turning it off is not free — it is a layer of protection against a sandboxed + * script reaching host globals — which is why this reports the runtime rather than deciding + * silently for every runtime. + */ +export function defenseInDepthSupported(): boolean { + return (globalThis as { Bun?: unknown }).Bun === undefined +} + +/** + * Whether a failed `import()` means the package is absent, rather than broken. + * + * Only a resolution failure naming this specifier counts. Two other things reach the same + * `catch` and are not the same fact: a module-evaluation error, which means `just-bash` is + * installed and threw while loading, and a resolution failure naming one of *its* dependencies, + * which means the install is incomplete. Reporting either as "not installed" tells a caller to + * run an install it has already run, and buries the real error inside a `cause` nothing reads. + * + * Measured on Bun 1.3.14: a missing package throws `ResolveMessage` with + * `code: 'ERR_MODULE_NOT_FOUND'` and a message naming the specifier. `MODULE_NOT_FOUND` is + * Node's CommonJS spelling of the same condition. + */ +function isMissingPackage(cause: unknown, specifier: string): boolean { + const code = (cause as NodeJS.ErrnoException | undefined)?.code + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return false + } + return String((cause as Error | undefined)?.message ?? '').includes(specifier) +} + +/** + * Import `just-bash`, or explain that it is missing. + * + * The specifier is built at runtime so a bundler cannot decide to resolve the optional peer at + * build time and fail a bundle that was never going to call this backend. + */ +export async function loadJustBash(): Promise { + const specifier = 'just-bash' + try { + return (await import(/* @vite-ignore */ specifier)) as unknown as JustBashModule + } + catch (cause) { + if (isMissingPackage(cause, specifier)) { + throw new JustBashUnavailableError(cause) + } + throw cause + } +} diff --git a/packages/core/src/sandbox/just-bash/sandbox.ts b/packages/core/src/sandbox/just-bash/sandbox.ts new file mode 100644 index 0000000..f4070de --- /dev/null +++ b/packages/core/src/sandbox/just-bash/sandbox.ts @@ -0,0 +1,91 @@ +/** + * One `just-bash` sandbox per id, created on first use. + * + * The analogue of `../docker/container.ts` and `../local/root.ts`, and the smallest of the + * three: a sandbox here is an object in this process's heap, so there is nothing to name, adopt + * or remove — `destroy()` drops it and the virtual filesystem goes with it. + * + * That is also the property a caller has to know about. **A just-bash sandbox cannot outlive the + * host process.** For `../local` that would be a degradation worth working around, and it is + * worked around, with an on-disk journal. Here there is nothing to journal *to*: the filesystem + * the commands ran against was never on disk either, so a resumed handle would address a + * sandbox whose entire contents are gone. `getProcess(id)` answering only within this process is + * the honest shape of that, not a gap in it. + * + * The acquisition is memoised, but **a failed one is not** — the rule the other two backends + * state, for the same reason: a rejected promise left in the latch is replayed by every later + * call, so one transient failure would outlive itself. + */ +import type { JustBashSandbox, JustBashSandboxOptions } from './runtime' +import { defenseInDepthSupported, loadJustBash } from './runtime' + +export interface JustBashSandboxHandleOptions { + /** Working directory inside the virtual filesystem. Relative paths resolve against it. */ + cwd: string + /** Environment every command in this sandbox starts with. */ + env?: Readonly> + /** + * Passed through to `just-bash`. Defaults to whether the runtime supports it — see + * {@link defenseInDepthSupported}, which is a measurement rather than a preference. + */ + defenseInDepth?: boolean + /** Anything else `just-bash`'s own `SandboxOptions` accepts, passed through untouched. */ + vendorOptions?: Readonly> +} + +export interface JustBashHandle { + readonly cwd: string + /** Resolve the sandbox, creating it on first call. */ + readonly ready: () => Promise + /** + * The sandbox if it already exists, without creating one. + * + * Discovery calls use this so that asking whether a sandbox ever ran a process answers + * "nothing here" instead of standing one up as a side effect of the question. + */ + readonly peek: () => JustBashSandbox | undefined + /** Drop the sandbox and its virtual filesystem. Idempotent. */ + readonly destroy: () => Promise +} + +export function createJustBashHandle(options: JustBashSandboxHandleOptions): JustBashHandle { + let acquisition: Promise | undefined + let live: JustBashSandbox | undefined + + const acquire = async (): Promise => { + const { Sandbox } = await loadJustBash() + const vendor: JustBashSandboxOptions = { + ...options.vendorOptions, + cwd: options.cwd, + ...(options.env === undefined ? {} : { env: { ...options.env } }), + defenseInDepth: options.defenseInDepth ?? defenseInDepthSupported(), + } + const created = await Sandbox.create(vendor) + live = created + return created + } + + const ready = (): Promise => (acquisition ??= acquire().catch( + (cause: unknown) => { + acquisition = undefined + throw cause + }, + )) + + return { + cwd: options.cwd, + ready, + peek: () => live, + destroy: async () => { + const existing = live + acquisition = undefined + live = undefined + // A sandbox that was never created is the state `destroy` promises, so there is nothing + // to do — and `stop()` is best-effort past that, because the object is being dropped + // either way and a failed stop must not leave the handle believing it still holds one. + if (existing !== undefined) { + await existing.stop() + } + }, + } +} diff --git a/packages/core/src/sandbox/just-bash/session.ts b/packages/core/src/sandbox/just-bash/session.ts new file mode 100644 index 0000000..996f6ad --- /dev/null +++ b/packages/core/src/sandbox/just-bash/session.ts @@ -0,0 +1,146 @@ +/** + * One virtual sandbox, as the contract's {@link SandboxSession}. + * + * The process registry is a `Map` rather than anything on disk, and that is not a shortcut — see + * `./sandbox.ts` for why a backend whose filesystem is a heap object has nothing to resume from. + * It is held by the *provider* and handed in here, so `getProcess` and `listProcesses` answer for + * the life of that provider rather than for the life of one session object. + */ +import type { + ProcessStatus, + SandboxCommand, + SandboxExecOptions, + SandboxProcessHandle, + SandboxSession, +} from '../contract' +import type { ProcessRecord } from './process' +import type { JustBashHandle } from './sandbox' +import { envArgv } from './env' +import { createJustBashFiles, resolveVirtualPath } from './files' +import { createProcessHandle, toProcessStatus } from './process' + +/** + * Start one command. + * + * `detached: true` is not optional here: the undetached overload resolves only once the command + * has finished, and the contract's `exec` hands back a handle to a process that is still + * running. The vendor's own timeout lives on the sandbox rather than the command, so a per-exec + * budget is expressed with an `AbortSignal` this file owns — which is also what makes + * `timedOut` answerable, since the vendor reports every cancellation as the same exit code. + * + * `options.env` likewise cannot be passed through: the vendor accepts the field and ignores it, + * so `./env.ts` turns it into a wrapper in front of the command. The record keeps the caller's + * own argv, not the wrapped one — the wrapper is this backend's business, and `status()` should + * report the command that was asked for. + */ +async function exec( + handle: JustBashHandle, + processes: Map, + argv: SandboxCommand, + options: SandboxExecOptions = {}, +): Promise { + const sandbox = await handle.ready() + const [executable, ...args] = envArgv(argv, options.env) + + const controller = new AbortController() + let expired = false + + const command = await sandbox.runCommand({ + cmd: executable, + args, + detached: true, + signal: controller.signal, + ...(options.cwd === undefined ? {} : { cwd: resolveVirtualPath(handle.cwd, options.cwd) }), + }) + + // Armed after the command exists, and gated on its exit code, because the timer is what + // `timedOut` is read from: nothing else stands it down. `settle()` clears it, but only a caller + // that awaits `waitForExit` reaches that — a caller that merely asks `status()` or `logs()` does + // not, so a timer left to fire over a command that already exited on its own would report a + // finished, zero-exit process as having been killed by its own budget. + const timer = options.timeout === undefined + ? undefined + : setTimeout(() => { + if (command.exitCode !== undefined) { + return + } + expired = true + controller.abort() + }, options.timeout) + // Unreferenced, so a pending budget is not a reason for the host process to stay alive. + // `settle()` clears the timer, but only a caller that awaits `waitForExit` reaches it — one + // that starts a command and reads `status()` would otherwise keep the process running until + // the timeout elapsed, which for an agent turn's budget is hours after the work is done. + // Measured: a 30s budget held the host open for the full 30s. + timer?.unref() + + const record: ProcessRecord = { + command, + argv, + cwd: options.cwd === undefined ? handle.cwd : resolveVirtualPath(handle.cwd, options.cwd), + startedAt: command.startedAt.toISOString(), + timedOut: () => expired, + settle: () => { + if (timer !== undefined) { + clearTimeout(timer) + } + }, + } + processes.set(command.cmdId, record) + return createProcessHandle(command.cmdId, record) +} + +export interface JustBashSessionOptions { + handle: JustBashHandle + /** + * Where this sandbox's processes are registered. + * + * Owned by the provider rather than created here, because `SandboxProvider.session` is called + * per use rather than held: a registry created per session object would answer `getProcess` and + * `listProcesses` only for the one session that ran the command, and `null` for the next call + * over the same sandbox id. + */ + processes?: Map +} + +export function createJustBashSession(options: JustBashSessionOptions): SandboxSession { + const { handle } = options + const processes = options.processes ?? new Map() + const files = createJustBashFiles(handle) + + return { + ...files, + exec: (command, execOptions) => exec(handle, processes, command, execOptions), + getProcess: async (processId) => { + // Discovery must not create: a sandbox that was never started has run nothing, and asking + // the question should not be what brings it into being. + if (handle.peek() === undefined) { + return null + } + const record = processes.get(processId) + return record === undefined ? null : createProcessHandle(processId, record) + }, + listProcesses: async (): Promise => { + if (handle.peek() === undefined) { + return [] + } + return [...processes].map(([id, record]) => toProcessStatus(id, record)) + }, + exists: async (path) => { + const sandbox = await handle.ready() + // `test -e` rather than a failed read: the contract's `exists` answers for directories too, + // and the vendor's `readFile` does not. + const probe = await sandbox.runCommand({ + cmd: 'test', + args: ['-e', resolveVirtualPath(handle.cwd, path)], + detached: true, + }) + const { exitCode } = await probe.wait() + return { exists: exitCode === 0 } + }, + destroy: async () => { + processes.clear() + await handle.destroy() + }, + } +} diff --git a/packages/core/src/sandbox/local/env.ts b/packages/core/src/sandbox/local/env.ts new file mode 100644 index 0000000..c66463f --- /dev/null +++ b/packages/core/src/sandbox/local/env.ts @@ -0,0 +1,81 @@ +/** + * What a process in a local sandbox inherits from the host, and what it does not. + * + * The Docker backend never needed this file: a container starts from the image's own + * environment, so the only way a host variable reaches a sandboxed command is if the caller + * put it there. On the host there is no such boundary — a naive backend hands `process.env` + * straight to the agent's `bash` tool, and with it every AWS key, `GH_TOKEN` and + * `SSH_AUTH_SOCK` the developer happens to be carrying. + * + * So the default is an allowlist, borrowed from flue's `local()` for the same reason it exists + * there — `DEFAULT_LOCAL_ENV_ALLOWLIST` in `withastro/flue`, at + * `packages/runtime/src/node/local-env.ts`. That path is flue's, not this repository's; naming + * the repository is what makes the provenance checkable rather than a dead reference. Anything else is opt-in through {@link LocalEnvOptions.env}. + */ +import process from 'node:process' + +/** + * Host variables a shell needs to behave like a shell. + * + * **Adding an entry here is a security decision, not a convenience one.** Nothing on this + * list should be sensitive on a typical developer machine: no tokens, no cloud credentials, + * no agent sockets. A caller that wants one of those in the sandbox says so by name. + */ +export const DEFAULT_ENV_ALLOWLIST: readonly string[] = [ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'HOSTNAME', + 'SHELL', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TERM', + 'TMPDIR', + 'TMP', + 'TEMP', +] + +export interface LocalEnvOptions { + /** + * Layered on top of {@link DEFAULT_ENV_ALLOWLIST}. A key set to `undefined` drops the + * inherited default rather than adding an empty one, so a caller can subtract as well as add. + * + * Pass-through is deliberately spelled out rather than offered as a flag: + * + * ```ts + * createLocalSandbox({ root, env: { GH_TOKEN: process.env.GH_TOKEN } }) // one variable + * createLocalSandbox({ root, env: { ...process.env } }) // everything + * ``` + */ + env?: Readonly> +} + +/** + * Snapshot the host environment through the allowlist, then layer the caller's overrides. + * + * Taken once per sandbox and closed over, so every process it starts sees the same + * environment for the sandbox's lifetime. Host mutations to `process.env` after that point + * are deliberately not picked up: a sandbox whose environment changes underneath it makes + * two runs of the same command incomparable. + */ +export function resolveBaseEnv(overrides?: LocalEnvOptions['env']): Record { + const base: Record = {} + for (const key of DEFAULT_ENV_ALLOWLIST) { + const value = process.env[key] + if (value !== undefined) { + base[key] = value + } + } + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value === undefined) { + delete base[key] + } + else { + base[key] = value + } + } + return base +} diff --git a/packages/core/src/sandbox/local/files.ts b/packages/core/src/sandbox/local/files.ts new file mode 100644 index 0000000..2cfc456 --- /dev/null +++ b/packages/core/src/sandbox/local/files.ts @@ -0,0 +1,110 @@ +/** + * File access inside a local sandbox. + * + * Declared in the contract's Cloudflare-shaped form — positional path, encoding-selected + * overloads — rather than the AI SDK harness's options-object form, for the reason + * `SandboxFiles` documents: keeping the contract assignable from `@cloudflare/sandbox`'s own + * client is what lets the incumbent backend cost nothing, and the harness's shape is a further + * translation that `../harness/files.ts` already performs for every backend at once. + * + * **Paths are resolved, not confined.** A relative path resolves against the sandbox's working + * directory; an absolute path is used as given, and so is a relative one that climbs out with + * `..`. That is not an oversight to be fixed with a prefix check — it is the honest shape of + * this backend. `exec` runs real host processes with the caller's own uid, so a command can + * read anything the user can read no matter what this file does, and a guard here would buy a + * feeling of containment rather than containment. Isolation is what the Docker backend is for. + */ +import type { + SandboxFileContent, + SandboxFileEncoding, + SandboxFiles, + SandboxFileStream, +} from '../contract' +import { Buffer } from 'node:buffer' +import { mkdir, stat } from 'node:fs/promises' +import { dirname, isAbsolute, resolve } from 'node:path' +import { SandboxFileNotFoundError } from '../contract' + +function toBytes(content: string, encoding: SandboxFileEncoding | undefined): Uint8Array { + return encoding === 'base64' + ? new Uint8Array(Buffer.from(content, 'base64')) + : new TextEncoder().encode(content) +} + +async function readStream(path: string): Promise { + // Existence is checked first: a streamed read cannot report a missing file through its body, + // and a caller handed an empty stream would read it as an empty file. + if (!await isFile(path)) { + throw new SandboxFileNotFoundError(path) + } + return { content: Bun.file(path).stream() } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } + catch { + return false + } +} + +async function readDecoded( + path: string, + encoding: SandboxFileEncoding | undefined, +): Promise { + if (!await isFile(path)) { + throw new SandboxFileNotFoundError(path) + } + const bytes = await Bun.file(path).bytes() + + if (encoding === 'base64') { + return { content: Buffer.from(bytes).toString('base64'), encoding: 'base64' } + } + return { content: new TextDecoder().decode(bytes), encoding: 'utf-8' } +} + +async function collect(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +async function write( + path: string, + content: string | ReadableStream, + options?: { encoding?: SandboxFileEncoding }, +): Promise { + const bytes = typeof content === 'string' + ? toBytes(content, options?.encoding) + : await collect(content) + + // The parent is created first, so a write to a path whose directory does not exist yet + // succeeds instead of needing the caller to sequence two calls — which is what the Docker + // backend's single `mkdir -p … && cat >` does, in the form the host offers. + await mkdir(dirname(path), { recursive: true }) + await Bun.write(path, bytes) +} + +export interface LocalFilesOptions { + /** What a relative path resolves against. */ + workDir: string +} + +/** The contract's file surface over one sandbox directory. */ +export function createLocalFiles(options: LocalFilesOptions): SandboxFiles { + const resolvePath = (path: string): string => + isAbsolute(path) ? path : resolve(options.workDir, path) + + const readFile = ((path: string, fileOptions?: { encoding?: SandboxFileEncoding | 'none' }) => ( + fileOptions?.encoding === 'none' + ? readStream(resolvePath(path)) + : readDecoded(resolvePath(path), fileOptions?.encoding) + )) as SandboxFiles['readFile'] + + return { + readFile, + writeFile: (path, content, fileOptions) => write(resolvePath(path), content, fileOptions), + mkdir: async (path, fileOptions) => { + await mkdir(resolvePath(path), { recursive: fileOptions?.recursive ?? false }) + }, + } +} diff --git a/packages/core/src/sandbox/local/index.ts b/packages/core/src/sandbox/local/index.ts new file mode 100644 index 0000000..60f358c --- /dev/null +++ b/packages/core/src/sandbox/local/index.ts @@ -0,0 +1,29 @@ +/** + * `@pleasedev/core/sandbox/local` — a host-process backend for the sandbox contract. + * + * **Host-only, and unisolated.** Everything here spawns real processes with the caller's own + * uid on the caller's own filesystem, so this subpath must never be reached from a Cloudflare + * Worker bundle — and must never be chosen for untrusted code. It is a separate entry point + * for the first reason: importing `@pleasedev/core` or `@pleasedev/core/sandbox` pulls none of + * it in. The second is a decision for whoever wires it; see `./provider.ts`. + */ + +// Re-exported so a caller holding this subpath need not also import the contract to catch it. +// The class itself lives on the contract, because its whole purpose is one identity across +// backends — see `../contract/types.ts`. +export { SandboxFileNotFoundError } from '../contract' + +export { DEFAULT_ENV_ALLOWLIST, resolveBaseEnv } from './env' +export type { LocalEnvOptions } from './env' + +export { journalPaths, timeoutSeconds, wrapperArgv } from './journal' +export type { JournalMeta, JournalPaths } from './journal' + +export { createLocalSandbox } from './provider' +export type { LocalSandboxOptions } from './provider' + +export { createSandboxRoot, sandboxDirName } from './root' +export type { RootOptions, SandboxRoot } from './root' + +export { createLocalSession } from './session' +export type { LocalSessionOptions } from './session' diff --git a/packages/core/src/sandbox/local/journal.ts b/packages/core/src/sandbox/local/journal.ts new file mode 100644 index 0000000..d0cecb0 --- /dev/null +++ b/packages/core/src/sandbox/local/journal.ts @@ -0,0 +1,289 @@ +/** + * The process journal — what a host process forgets when it exits, written to disk. + * + * The contract requires a process to outlive the call that started it: `getProcess(id)` and + * `logs({ replay: true })` are read *after* it exits, and `SandboxProvider.session` is called + * per use rather than held, so the reader is frequently not the process that spawned the + * command. An in-memory registry of child handles answers none of that across a restart, so + * each process is instead wrapped in a shell that redirects both streams to files and records + * its own exit status beside them — the same shape `../docker/journal.ts` arrived at, for the + * same reason. + * + * stdout and stderr stay in separate files rather than one interleaved log because + * `ProcessLogEvent` is tagged per stream. + * + * **Why a process ended is journalled separately from what it exited with.** `$?` is one + * integer and every reading of it is ambiguous — `128 + n` is a signalled child but also an + * ordinary exit code in the 129..255 range — so the wrapper writes a `signal` record and the + * watchdog writes a `timeout` marker, and the exit code is never asked to carry either fact. + * + * Two things differ from the Docker wrapper, and both are portability rather than taste: + * + * - **No `setsid`.** macOS ships no such binary. It is not needed here because the wrapper is + * spawned with `detached: true`, which makes it a process-group leader in its own right — + * which is the property `setsid` was bought for, so a group kill still reaches everything + * the command spawned. + * - **Nothing reads `/proc`.** The Docker escalation walks it to SIGKILL the group's survivors + * while sparing itself; there is no `/proc` on macOS. The escalation here writes the exit + * record *first* and then kills the whole group, itself included — which needs no process + * listing at all, and leaves a reader the same facts. + * + * A third difference is not portability: **nothing in the script is string-interpolated.** The + * journal directory, the timeout and the argv all arrive as positional parameters, so the + * script is a constant and no quoting function stands between a caller's argv and `execve`. + * The Docker backend needs `shell-quote.ts` because its wrapper travels as one `sh -c` string + * through `docker exec`; here the arguments travel beside the script instead of inside it. + */ +import type { SandboxCommand } from '../contract' +import { constants } from 'node:os' +import { join } from 'node:path' + +export interface JournalPaths { + dir: string + meta: string + pid: string + stdout: string + stderr: string + exit: string + /** Number of the signal the wrapper saw reach the group, when one did. */ + signal: string + /** Touched by the watchdog when the process outran its timeout. Presence is the fact. */ + timeout: string + /** Written by a caller that gave up before the wrapper launched. Presence cancels it. */ + abandon: string +} + +/** + * Process ids this backend will resolve to a journal directory. + * + * `exec()` mints `crypto.randomUUID()`, which fits — but `getProcess(id)` takes whatever a + * caller passes, and the id becomes a path segment. Without this, `../../../etc` reads a + * journal outside `journalRoot`, and a `remove()`-shaped id reaches outside the tree this + * backend owns. `./root.ts` makes the same argument for sandbox ids, where the answer is a + * lossy sanitiser plus a digest; here rejection is better than repair, because a process id is + * only ever one this backend handed out. + */ +const JOURNAL_ID = /^[\w-]{1,128}$/ + +export function isJournalId(value: string): boolean { + return JOURNAL_ID.test(value) +} + +/** The paths one process's journal is made of. */ +export function journalPaths(journalRoot: string, processId: string): JournalPaths { + const dir = join(journalRoot, processId) + return { + dir, + meta: join(dir, 'meta'), + pid: join(dir, 'pid'), + stdout: join(dir, 'out'), + stderr: join(dir, 'err'), + exit: join(dir, 'exit'), + signal: join(dir, 'signal'), + timeout: join(dir, 'timeout'), + abandon: join(dir, 'abandon'), + } +} + +/** What the journal records so `status()` can answer without the process. */ +export interface JournalMeta { + id: string + command: SandboxCommand + cwd?: string + startedAt: string + /** Present when the process was wrapped in a timeout watchdog. */ + timeoutMs?: number +} + +/** + * Catchable signals the wrapper survives, with the number a POSIX shell reports for them. + * + * The numbers are read from the *host's* own signal table rather than written down, because + * they are not portable: `SIGUSR1` is 10 on Linux and 30 on macOS, so a literal 10 would be + * recorded for a process the shell exits with `128 + 30`, {@link reconcileSignal} would find + * the two disagree, and a signalled process would be reported as having merely returned 158. + * The wrapper runs on this host, so this host's table is the right one. + * + * `SIGKILL` is deliberately absent: it cannot be trapped, so a `kill -9` still produces the + * no-exit-record state — correctly, because nothing observed the process finishing. + */ +const RECORDED_SIGNALS: ReadonlyArray + = (['HUP', 'INT', 'QUIT', 'USR1', 'USR2', 'TERM'] as const) + .map(name => [name, constants.signals[`SIG${name}`]] as const) + +/** + * Seconds the escalation waits after a timeout's `SIGTERM` before forcing the group down. + * + * A command is free to trap or ignore `SIGTERM`, and one that does would otherwise outlive + * the timeout it was given. + */ +const KILL_GRACE_SECONDS = '3' + +/** Read from the host's table for the reason {@link RECORDED_SIGNALS} gives. */ +const SIGTERM = constants.signals.SIGTERM +const SIGKILL = constants.signals.SIGKILL + +/** What the journal records for a group SIGKILLed by the escalation: the shell's `128 + 9`. */ +const SIGKILL_EXIT_CODE = String(128 + SIGKILL) + +const SIGNAL_NAMES = RECORDED_SIGNALS.map(([name]) => name).join(' ') + +/** + * The wrapper, as a constant POSIX shell script. + * + * `$1` is the journal directory, `$2` the timeout in seconds (empty for none), and everything + * after them is the command — so `exec "$@"` runs the caller's argv with no quoting anywhere + * in the path from `SandboxCommand` to `execve`. + * + * **The wrapper records the catchable signals and the command does not.** A group kill reaches + * every member, wrapper included, and a wrapper that died with its child would never reach the + * line that writes the exit record — so an ordinary termination would be indistinguishable + * from a process that vanished. Trapping in the wrapper and clearing those dispositions inside + * the child (trap settings are otherwise inherited across `fork`) leaves the kill doing exactly + * what the caller asked while the exit still gets written. + * + * A trapped signal interrupts `wait`, which is why the wait is a loop: the shell returns early + * to run the handler, and the child is still there to be waited on again. + */ +export const WRAPPER_SCRIPT: string = [ + 'dir=$1', + 'budget=$2', + 'shift 2', + // The wrapper leads its own process group, courtesy of the spawn's `detached`. That is what + // makes `kill -TERM -$wrapper` reach the command's whole tree and not the caller's. + 'wrapper=$$', + 'echo "$wrapper" > "$dir/pid"', + // A caller whose start never confirmed leaves a marker rather than a kill, because there was + // no pid to kill yet. Launching anyway would strand the command with nothing able to reap it. + 'if [ -e "$dir/abandon" ] ; then exit 0 ; fi', + '', + // Born from the TERM handler rather than started up front, and deliberately: a process + // created *after* a group signal never receives it, so this survives the SIGTERM it is + // escalating from without having to ignore it. + 'escalate() {', + ` sleep ${KILL_GRACE_SECONDS}`, + // The exit record, not a pid check: a reaped pid can be reused by then, and this file is the + // journal's own answer to "is it over". + ' if [ -e "$dir/exit" ] ; then return 0 ; fi', + ` printf %s ${SIGKILL} > "$dir/signal"`, + // Written *before* the kill, which is what lets the kill be a plain group kill. The wrapper + // dies with the group and never reaches its own `echo`, so this line is the exit record. + ` echo ${SIGKILL_EXIT_CODE} > "$dir/exit"`, + ' kill -9 "-$wrapper" 2>/dev/null', + '}', + '', + 'on_signal() {', + ' printf %s "$1" > "$dir/signal"', + ` if [ "$1" = ${SIGTERM} ] && [ -e "$dir/timeout" ] ; then`, + ' escalate &', + ' escalator=$!', + ' fi', + '}', + '', + ...RECORDED_SIGNALS.map(([name, number]) => `trap 'on_signal ${number}' ${name}`), + '', + `{ trap - ${SIGNAL_NAMES} ; exec "$@" ; } > "$dir/out" 2> "$dir/err" &`, + 'child=$!', + '', + // Records *why* it fired before it fires, so a reader never has to infer a timeout from an + // exit code, and signals the whole group so grandchildren go with it. It then dies of its + // own signal; the wrapper's TERM handler is what carries the escalation on from here. + 'if [ -n "$budget" ] ; then', + // The nap is the watchdog's own child rather than the watchdog itself, so that standing the + // watchdog down below can take the `sleep` with it. Killing the subshell alone leaves the + // `sleep` orphaned and running out the whole budget — hours, for the timeout a long turn is + // given — after the command it was watching has already exited and been reaped. + ' (', + // A subshell inherits the wrapper's trap *actions*, so this replaces `on_signal` for the + // watchdog: a TERM here is the wrapper saying the deadline is no longer needed, which is a + // different thing from the TERM the watchdog itself sends the group. + // + // **Two traps, because a stand-down can arrive before there is a nap to stand down.** The + // wrapper sends its TERM as soon as it has reaped the child, and for a command that + // finishes instantly that can land before `nap=$!` has run — leaving a handler that kills + // `$nap` with nothing in it, and a `sleep` orphaned for the whole budget. So the first + // trap only records that the stand-down happened; it cannot exit, because exiting is what + // strands the nap. The second replaces it once the pid is in hand, and the check between + // them catches a stand-down that arrived while the first was installed. Every ordering is + // covered: before the fork, between the fork and the assignment, and after. + // + // `jobs -p` would look like the shorter answer and is not one: a command substitution runs + // in a subshell, which does not inherit the job table, so under `dash` — Debian and Ubuntu's + // `/bin/sh` — it expands to nothing and *every* stand-down leaks its nap. + ' standdown=', + ' trap \'standdown=1\' TERM', + ' sleep "$budget" &', + ' nap=$!', + ' trap \'kill -9 "$nap" 2>/dev/null ; exit 0\' TERM', + ' if [ -n "$standdown" ] ; then kill -9 "$nap" 2>/dev/null ; exit 0 ; fi', + ' wait "$nap"', + ' : > "$dir/timeout"', + ' kill -TERM "-$wrapper" 2>/dev/null', + ' ) &', + ' watchdog=$!', + 'fi', + '', + 'wait "$child"', + 'status=$?', + 'while kill -0 "$child" 2>/dev/null ; do', + ' wait "$child"', + ' status=$?', + 'done', + '', + // TERM rather than KILL, because the watchdog has a disposition for it that also reaps its + // nap. A KILL here would be delivered to the subshell alone. + 'if [ -n "$watchdog" ] ; then kill -TERM "$watchdog" 2>/dev/null || true ; fi', + // Best effort only: an escalation that outlives this finds the exit record below and returns + // without touching anything. + 'if [ -n "$escalator" ] ; then kill -9 "$escalator" 2>/dev/null || true ; fi', + 'echo "$status" > "$dir/exit"', +].join('\n') + +/** Seconds, as a literal `sleep` accepts on both GNU and BSD — sub-second budgets included. */ +export function timeoutSeconds(timeoutMs: number): string { + return (Math.max(1, timeoutMs) / 1000).toFixed(3) +} + +/** + * The argv that runs one journalled command. + * + * `sh -c