From a51f83cf73b9d957018e4f758ba8e9f3e666123e Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Tue, 22 Sep 2026 15:56:00 +0200 Subject: [PATCH 01/23] init: initialize TypeScript ADK integration package --- .env.example | 2 + .github/workflows/ci.yml | 19 + .gitignore | 13 + .prettierignore | 7 + .prettierrc.json | 1 + LICENSE | 21 + eslint.config.js | 8 + package.json | 52 + pnpm-lock.yaml | 3515 ++++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 9 + tsconfig.json | 19 + vitest.config.ts | 5 + 12 files changed, 3671 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5cd0a29 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +E2B_API_KEY= +GOOGLE_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb30c9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: [push, pull_request] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3102276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.tgz +.DS_Store +DECISIONS.md +EXECUTION_PLAN.md +VALIDATION.md +HANDOFF.md +work/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..032d76f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +pnpm-lock.yaml +dist +work +DECISIONS.md +EXECUTION_PLAN.md +VALIDATION.md +HANDOFF.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4c29eaa --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "semi": false, "singleQuote": true, "trailingComma": "all" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d4b8c19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FoundryLabs, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f5ba68f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,8 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**', 'work/**'] }, + js.configs.recommended, + ...tseslint.configs.recommended, +) diff --git a/package.json b/package.json new file mode 100644 index 0000000..848056a --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "@e2b/adk", + "version": "0.1.0", + "description": "E2B sandbox tools and code execution for Google ADK.", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@10.34.5", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:live": "node --env-file=.env --import tsx scripts/live.ts", + "lint": "eslint .", + "format:check": "prettier --check .", + "format": "prettier --write .", + "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm test && pnpm build", + "prepack": "pnpm build" + }, + "peerDependencies": { + "@google/adk": "^2.1.0" + }, + "dependencies": { + "@e2b/code-interpreter": "^2.8.0", + "zod": "^4.2.1" + }, + "devDependencies": { + "@google/adk": "2.1.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "@eslint/js": "^9.0.0", + "typescript-eslint": "^8.0.0", + "prettier": "^3.0.0", + "typescript": "~5.9.3", + "tsx": "^4.20.0", + "vitest": "^3.2.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..417765f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3515 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@e2b/code-interpreter': + specifier: ^2.8.0 + version: 2.8.0 + zod: + specifier: ^4.2.1 + version: 4.6.5 + devDependencies: + '@eslint/js': + specifier: ^9.0.0 + version: 9.39.5 + '@google/adk': + specifier: 2.1.0 + version: 2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5) + '@types/node': + specifier: ^22.0.0 + version: 22.20.4 + eslint: + specifier: ^9.0.0 + version: 9.39.5 + prettier: + specifier: ^3.0.0 + version: 3.9.8 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.0.0 + version: 8.70.0(eslint@9.39.5)(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.4)(tsx@4.23.15) + +packages: + + '@a2a-js/sdk@0.3.14': + resolution: {integrity: sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ==} + engines: {node: '>=18'} + peerDependencies: + '@bufbuild/protobuf': ^2.10.2 + '@grpc/grpc-js': ^1.11.0 + express: ^4.21.2 || ^5.1.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + '@grpc/grpc-js': + optional: true + express: + optional: true + + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + + '@bufbuild/protobuf@2.15.0': + resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@colors/colors@1.6.1': + resolution: {integrity: sha512-dTmUJzXSuayBK+hZydEaXd2mhx61qWQwkwaBBY6LyEOVx/L9aQU5ac8eFNEsd9nrD1+zb9zvDCphLSe8g1F4Qw==} + engines: {node: '>=0.1.90'} + + '@connectrpc/connect-web@2.2.0': + resolution: {integrity: sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.2.0 + + '@connectrpc/connect@2.2.0': + resolution: {integrity: sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + + '@dabh/diagnostics@2.0.9': + resolution: {integrity: sha512-R6siwR65Hm+3yfgP7o8DKhNvputQAwfoz9zTc3kyDudnomj2/BcLmD+uGQQPICjuFUp8ounPBU+jmKsocwVVAg==} + + '@e2b/code-interpreter@2.8.0': + resolution: {integrity: sha512-tWFi8IJYV3V6jOVfQ8V5PkzockrYP+ndWB/jKiJvRATTmNAy8+S/7S+iSh2Lb2Hrul869G2adRiN2xW8peJTig==} + engines: {node: '>=20.18.1 <21 || >=22'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@google-cloud/vertexai@1.12.0': + resolution: {integrity: sha512-XMJIk7GIeavFLP5A3YEUlowKa5Y5PZRrnnuTJcqR0k+lFKkv7+IWpdRp+Xbqb8xNDrvQaE2hP2RYPUylyD5EdA==} + engines: {node: '>=18.0.0'} + + '@google/adk@2.1.0': + resolution: {integrity: sha512-+dMsMonJpkOS7iYhOIyUI5RTQtn6Q/Y5GM5zu00uEwKNcOPuv8ZeLexvrMkltwV10bz/BDlIKXOXavM4UHPdVw==} + peerDependencies: + '@google-cloud/opentelemetry-cloud-monitoring-exporter': ^0.21.0 + '@google-cloud/opentelemetry-cloud-trace-exporter': ^3.0.0 + '@google-cloud/storage': ^7.17.1 + '@mikro-orm/mariadb': ^7.2.0 + '@mikro-orm/mssql': ^7.2.0 + '@mikro-orm/mysql': ^7.2.0 + '@mikro-orm/postgresql': ^7.2.0 + '@mikro-orm/sqlite': ^7.2.0 + '@modelcontextprotocol/sdk': ^1.26.0 + express: ^4.22.1 || ^5.1.0 + peerDependenciesMeta: + '@google-cloud/opentelemetry-cloud-monitoring-exporter': + optional: true + '@google-cloud/opentelemetry-cloud-trace-exporter': + optional: true + '@google-cloud/storage': + optional: true + '@mikro-orm/mariadb': + optional: true + '@mikro-orm/mssql': + optional: true + '@mikro-orm/mysql': + optional: true + '@mikro-orm/postgresql': + optional: true + '@mikro-orm/sqlite': + optional: true + '@modelcontextprotocol/sdk': + optional: true + express: + optional: true + + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@google/genai@2.23.0': + resolution: {integrity: sha512-NFMzktoZ7jJg9AA3vUbWZocnkQP6DSGzL21ORwdVcBWkHli3k7v6B9zy07M4bTiKDAyIWfUh2J/oLYxCu0hNpQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grpc/grpc-js@1.14.5': + resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@mikro-orm/core@7.2.1': + resolution: {integrity: sha512-+gtJoScbin/C3wPItSZkj5vlMTq/LphBLIxHyi5brCbeA6VDcYuoMopiqoFUMQxdhXXrZlKNIHXGrhb8r3biVg==} + engines: {node: '>= 22.17.0'} + peerDependencies: + dataloader: 2.2.3 + peerDependenciesMeta: + dataloader: + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@opentelemetry/api-logs@0.222.0': + resolution: {integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.11.0': + resolution: {integrity: sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.222.0': + resolution: {integrity: sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0': + resolution: {integrity: sha512-6Ko+0bgNP6V4G/RsmNBSk5lO1B/lo9z6tvLpFY3ykPPevXNzFmOW3C9hEmTt0zKCvHovyrol3TYlg4HVb5gwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.222.0': + resolution: {integrity: sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.222.0': + resolution: {integrity: sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.222.0': + resolution: {integrity: sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resource-detector-gcp@0.40.3': + resolution: {integrity: sha512-C796YjBA5P1JQldovApYfFA/8bQwFfpxjUbOtGhn1YZkVTLoNQN+kvBwgALfTPWzug6fWsd0xhn9dzeiUcndag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.222.0': + resolution: {integrity: sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.11.0': + resolution: {integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.11.0': + resolution: {integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/ssh2@1.15.6': + resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.70.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.1: + resolution: {integrity: sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + + dockerode@5.0.1: + resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} + engines: {node: '>= 14.17'} + + e2b@2.51.0: + resolution: {integrity: sha512-P+50lx1nOYpgCFyE1xuz6JVN/TqJivMUiH7v+RW0CUSfb4+NCu8IkC5KruT2sNPgp6aexddQQ77+OZKGpZZJ+Q==} + engines: {node: '>=20.18.1 <21 || >=22'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.9: + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonpath-plus@10.4.0: + resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} + engines: {node: '>=18.0.0'} + hasBin: true + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nan@2.29.0: + resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.8: + resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} + engines: {node: '>=14'} + hasBin: true + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-languageserver-textdocument@1.0.14: + resolution: {integrity: sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==} + + vscode-languageserver-types@3.18.3: + resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@a2a-js/sdk@0.3.14(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5)': + dependencies: + uuid: 11.1.1 + optionalDependencies: + '@bufbuild/protobuf': 2.15.0 + '@grpc/grpc-js': 1.14.5 + + '@balena/dockerignore@1.0.2': + optional: true + + '@bufbuild/protobuf@2.15.0': {} + + '@colors/colors@1.6.0': {} + + '@colors/colors@1.6.1': {} + + '@connectrpc/connect-web@2.2.0(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0))': + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@connectrpc/connect': 2.2.0(@bufbuild/protobuf@2.15.0) + + '@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0)': + dependencies: + '@bufbuild/protobuf': 2.15.0 + + '@dabh/diagnostics@2.0.9': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@e2b/code-interpreter@2.8.0': + dependencies: + e2b: 2.51.0 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.7': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@google-cloud/vertexai@1.12.0': + dependencies: + '@google/genai': 1.52.0 + google-auth-library: 9.15.1 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - encoding + - supports-color + - utf-8-validate + + '@google/adk@2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5)': + dependencies: + '@a2a-js/sdk': 0.3.14(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5) + '@google-cloud/vertexai': 1.12.0 + '@google/genai': 2.23.0 + '@mikro-orm/core': 7.2.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/exporter-logs-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.40.3(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.0) + '@types/dockerode': 4.0.1 + adm-zip: 0.5.18 + google-auth-library: 10.9.1 + js-yaml: 4.3.2 + jsonpath-plus: 10.4.0 + lodash-es: 4.18.1 + winston: 3.19.0 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + optionalDependencies: + dockerode: 5.0.1 + transitivePeerDependencies: + - '@bufbuild/protobuf' + - '@grpc/grpc-js' + - bufferutil + - dataloader + - encoding + - supports-color + - utf-8-validate + + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@2.23.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grpc/grpc-js@1.14.5': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + optional: true + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + optional: true + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + optional: true + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@js-sdsl/ordered-map@4.4.2': + optional: true + + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@mikro-orm/core@7.2.1': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@opentelemetry/api-logs@0.222.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/context-async-hooks@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-http@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/resource-detector-gcp@0.40.3(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + gcp-metadata: 6.1.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 22.20.4 + '@types/ssh2': 1.15.6 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 22.20.4 + '@types/ssh2': 1.15.6 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + '@types/retry@0.12.0': {} + + '@types/ssh2@1.15.6': + dependencies: + '@types/node': 18.19.130 + + '@types/triple-beam@1.3.5': {} + + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 9.39.5 + ignore: 7.0.9 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) + '@typescript-eslint/types': 8.70.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.70.0': + dependencies: + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 + + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.70.0': {} + + '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.70.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.70.0': + dependencies: + '@typescript-eslint/types': 8.70.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.4)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.4)(tsx@4.23.15) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + adm-zip@0.5.18: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: + optional: true + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + optional: true + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + optional: true + + bignumber.js@9.3.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + + buildcheck@0.0.7: + optional: true + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + check-error@2.1.3: {} + + chownr@1.1.4: + optional: true + + chownr@3.0.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + optional: true + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.1 + + color-name@1.1.4: {} + + color-name@2.1.1: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.1 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + compare-versions@6.1.1: {} + + concat-map@0.0.1: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.29.0 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + docker-modem@5.0.7: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + optional: true + + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.14 + vscode-languageserver-types: 3.18.3 + + dockerode@5.0.1: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.5 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7 + protobufjs: 7.6.6 + tar-fs: 2.1.5 + transitivePeerDependencies: + - supports-color + optional: true + + e2b@2.51.0: + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@connectrpc/connect': 2.2.0(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-web': 2.2.0(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0)) + chalk: 5.6.2 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 13.0.6 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.22 + undici: 7.29.1 + optionalDependencies: + undici8: undici@8.10.2 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + emoji-regex@8.0.0: + optional: true + + enabled@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: + optional: true + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fecha@4.2.3: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fn.name@1.1.0: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fs-constants@1.0.0: + optional: true + + fsevents@2.3.3: + optional: true + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-caller-file@2.0.5: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@14.0.0: {} + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + + google-logging-utils@1.1.3: {} + + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + has-flag@4.0.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: + optional: true + + ignore@5.3.2: {} + + ignore@7.0.9: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsep@1.4.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonpath-plus@10.4.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kuler@2.0.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + lodash.camelcase@4.3.0: + optional: true + + lodash.merge@4.6.2: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + long@5.3.2: {} + + loupe@3.2.1: {} + + lru-cache@11.5.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp-classic@0.5.3: + optional: true + + ms@2.1.3: {} + + nan@2.29.0: + optional: true + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optional: true + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + platform@1.3.6: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.8: {} + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.20.4 + long: 5.3.2 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + punycode@2.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: + optional: true + + resolve-from@4.0.0: {} + + retry@0.13.1: {} + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: + optional: true + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + split-ca@1.0.1: + optional: true + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.29.0 + optional: true + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + optional: true + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + optional: true + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + text-hex@1.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tr46@0.0.3: {} + + triple-beam@1.4.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@0.14.5: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.70.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@5.26.5: {} + + undici-types@6.21.0: {} + + undici@7.29.1: {} + + undici@8.10.2: + optional: true + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@11.1.1: {} + + uuid@9.0.1: {} + + vite-node@3.2.4(@types/node@22.20.4)(tsx@4.23.15): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.4)(tsx@4.23.15) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.4)(tsx@4.23.15): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@3.2.7(@types/node@22.20.4)(tsx@4.23.15): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.4)(tsx@4.23.15)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.4)(tsx@4.23.15) + vite-node: 3.2.4(@types/node@22.20.4)(tsx@4.23.15) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.4 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-languageserver-textdocument@1.0.14: {} + + vscode-languageserver-types@3.18.3: {} + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.1 + '@dabh/diagnostics': 2.0.9 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + optional: true + + wrappy@1.0.2: + optional: true + + ws@8.21.3: {} + + y18n@5.0.8: + optional: true + + yallist@5.0.0: {} + + yargs-parser@21.1.1: + optional: true + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + optional: true + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@4.6.5: {} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..55be52d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d4f5b83 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "scripts/**/*.ts", + "examples/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f89a083 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { include: ['tests/**/*.test.ts'], restoreMocks: true }, +}) From dc7ea4ad71b6e5434abbea96a9224db5f236737c Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Tue, 22 Sep 2026 16:41:00 +0200 Subject: [PATCH 02/23] feat: add E2B toolset and direct code executor for ADK --- scripts/live.ts | 206 +++++++++++++++++++++++++++++++++++++ src/code-executor.ts | 95 +++++++++++++++++ src/index.ts | 7 ++ src/options.ts | 10 ++ src/results.ts | 21 ++++ src/sandbox-manager.ts | 79 ++++++++++++++ src/toolset.ts | 222 ++++++++++++++++++++++++++++++++++++++++ tests/executor.test.ts | 133 ++++++++++++++++++++++++ tests/helpers.ts | 100 ++++++++++++++++++ tests/lifecycle.test.ts | 133 ++++++++++++++++++++++++ tests/runner.test.ts | 197 +++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 212 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 1415 insertions(+) create mode 100644 scripts/live.ts create mode 100644 src/code-executor.ts create mode 100644 src/index.ts create mode 100644 src/options.ts create mode 100644 src/results.ts create mode 100644 src/sandbox-manager.ts create mode 100644 src/toolset.ts create mode 100644 tests/executor.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/lifecycle.test.ts create mode 100644 tests/runner.test.ts create mode 100644 tests/tools.test.ts diff --git a/scripts/live.ts b/scripts/live.ts new file mode 100644 index 0000000..2311549 --- /dev/null +++ b/scripts/live.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { + CodeExecutionLanguage, + Context, + InMemoryRunner, + InvocationContext, + LlmAgent, + PluginManager, +} from '@google/adk' +import { Sandbox } from '@e2b/code-interpreter' +import { E2BCodeExecutor, E2BToolset } from '../src/index.js' + +assert(process.env.E2B_API_KEY, 'E2B_API_KEY is required') +const runId = randomUUID() +const options = { + sandboxOptions: { timeoutMs: 60_000, metadata: { adkLiveRun: runId } }, +} +const ctx = new InvocationContext({ + invocationId: 'live', + pluginManager: new PluginManager(), + session: { + appName: 'live', + userId: 'live', + id: runId, + state: {}, + events: [], + lastUpdateTime: 0, + }, +}) + +async function remaining() { + const paginator = Sandbox.list({ query: { metadata: { adkLiveRun: runId } } }) + const ids: string[] = [] + while (paginator.hasNext) + ids.push(...(await paginator.nextItems()).map((box) => box.sandboxId)) + return ids +} + +async function directTools() { + const toolset = new E2BToolset(options) + const tools = await toolset.getTools() + async function call(name: string, args: Record) { + const result = (await tools + .find((tool) => tool.name === name)! + .runAsync({ + args, + toolContext: new Context({ invocationContext: ctx }), + })) as Record + assert.equal( + result.success, + true, + `Tool ${name} failed: ${JSON.stringify(result)}`, + ) + return result + } + try { + await call('write_file', { + path: '/home/user/adk-smoke.txt', + content: 'smoke-content', + }) + assert.equal( + (await call('read_file', { path: '/home/user/adk-smoke.txt' })).content, + 'smoke-content', + ) + assert.match( + JSON.stringify(await call('list_files', { path: '/home/user' })), + /adk-smoke.txt/, + ) + assert.equal( + (await call('run_command', { command: 'printf shell-ok; exit 7' })) + .exit_code, + 7, + ) + const samples: Record = { + python: 'print(6 * 7)', + javascript: 'console.log(6 * 7)', + typescript: 'const n: number = 6 * 7; console.log(n)', + r: 'cat(6 * 7)', + java: 'System.out.println(6 * 7);', + bash: 'echo $((6 * 7))', + } + for (const [language, code] of Object.entries(samples)) { + const result = await call('run_code', { + code, + language, + timeoutMs: 20_000, + }) + assert.equal(result.error, '', `${language} execution error`) + assert.match(`${result.stdout}${result.text}`, /42/) + console.log(`PASS run_code ${language}`) + } + await call('run_code', { code: 'adk_value = 17' }) + assert.equal((await call('run_code', { code: 'adk_value + 1' })).text, '18') + const runtimeError = await call('run_code', { + code: 'raise ValueError("expected")', + }) + assert.match(String(runtimeError.error), /ValueError/) + const bg = await call('start_background_command', { + command: 'sleep 30', + port: 3000, + timeoutMs: 0, + }) + assert.equal(typeof bg.pid, 'number') + assert.match(String(bg.preview_url), /^https:\/\//) + assert.equal(bg.readiness, 'unknown') + assert.equal((await remaining()).length, 1) + } finally { + await toolset.close() + } + assert.deepEqual(await remaining(), []) + console.log('PASS six tools, state, errors, background and cleanup') +} + +async function directExecutor() { + const executor = new E2BCodeExecutor(options) + try { + for (const [language, code] of [ + [CodeExecutionLanguage.PYTHON, 'print(21 * 2)'], + [CodeExecutionLanguage.JAVASCRIPT, 'console.log(21 * 2)'], + [ + CodeExecutionLanguage.TYPESCRIPT, + 'const v: number = 21 * 2; console.log(v)', + ], + [CodeExecutionLanguage.SHELL, 'echo $((21 * 2))'], + ] as const) { + const result = await executor.executeCode({ + invocationContext: ctx, + codeExecutionInput: { code, language, inputFiles: [] }, + }) + assert.equal(result.stderr, '', `${language}: ${result.stderr}`) + assert.match(result.stdout, /42/) + console.log(`PASS executor ${language}`) + } + } finally { + await executor.close() + } + assert.deepEqual(await remaining(), []) +} + +async function agentTools() { + const toolset = new E2BToolset(options) + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'sandbox_smoke', + model: 'gemini-2.5-flash', + instruction: + 'Use write_file to save the requested file, then run_code to read it and compute. Always actually call tools. Return the computed answer.', + tools: [toolset], + generateContentConfig: { + temperature: 0, + thinkingConfig: { thinkingBudget: 0 }, + }, + }), + }) + const completed: string[] = [] + let output = '' + try { + for await (const event of runner.runEphemeral({ + userId: 'live', + newMessage: { + role: 'user', + parts: [ + { + text: 'Save the text 6,7 into /home/user/numbers.txt. Read it using Python, multiply the two numbers and report the result.', + }, + ], + }, + runConfig: { maxLlmCalls: 6 }, + })) { + assert(!event.errorCode, `Model error: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.functionResponse) { + completed.push(part.functionResponse.name ?? '') + assert.equal(part.functionResponse.response?.success, true) + } + if (part.text) output += part.text + } + } + assert(completed.includes('write_file')) + assert(completed.includes('run_code')) + assert.match(output, /42/) + assert.deepEqual(await remaining(), [], 'Runner must close the sandbox') + } finally { + await toolset.close() + } + console.log('PASS Gemini tool routing, file analysis and runner cleanup') +} + +try { + const mode = process.argv[2] ?? 'all' + if (mode === 'all' || mode === 'direct') { + await directTools() + await directExecutor() + } + if (mode === 'all' || mode === 'agent') await agentTools() +} finally { + // Only clean up this run's unique metadata-tagged workspaces. + const leaked = await remaining() + for (const id of leaked) await Sandbox.kill(id) + assert.equal( + leaked.length, + 0, + 'Fallback cleanup was needed: investigate lifecycle', + ) +} diff --git a/src/code-executor.ts b/src/code-executor.ts new file mode 100644 index 0000000..621e0ab --- /dev/null +++ b/src/code-executor.ts @@ -0,0 +1,95 @@ +import { + BaseCodeExecutor, + CodeExecutionLanguage, + type CodeExecutionResult, + type InvocationContext, +} from '@google/adk' +import type { E2BCodeExecutorOptions } from './options.js' +import { operationError, truncate } from './results.js' +import { SandboxManager } from './sandbox-manager.js' + +type ExecuteParams = Parameters[0] + +/** Stateful code execution in one E2B workspace, owned by one ADK session. */ +export class E2BCodeExecutor extends BaseCodeExecutor { + private readonly manager: SandboxManager + private sessionKey?: string + + constructor(options: E2BCodeExecutorOptions = {}) { + super() + this.manager = new SandboxManager(options) + this.stateful = true + // ADK's response processor currently labels extracted blocks as Python. + this.codeBlockDelimiters = [ + ['```tool_code\n', '\n```'], + ['```python\n', '\n```'], + ] + } + + override async executeCode({ + invocationContext, + codeExecutionInput: input, + }: ExecuteParams): Promise { + const fail = (stderr: string): CodeExecutionResult => ({ + stdout: '', + stderr, + outputFiles: [], + }) + if ( + input.inputFiles?.length || + (input.args && Object.keys(input.args).length) + ) { + return fail( + 'inputFiles and args are not supported. Use E2BToolset file and command tools.', + ) + } + const language = { + [CodeExecutionLanguage.PYTHON]: 'python', + [CodeExecutionLanguage.JAVASCRIPT]: 'javascript', + [CodeExecutionLanguage.TYPESCRIPT]: 'typescript', + [CodeExecutionLanguage.SHELL]: 'bash', + [CodeExecutionLanguage.UNSPECIFIED]: undefined, + [CodeExecutionLanguage.POWERSHELL]: undefined, + [CodeExecutionLanguage.WINDOWS_CMD]: undefined, + }[input.language] + if (!language) + return fail( + 'Unsupported language. Use python, javascript, typescript or shell.', + ) + if (typeof input.code !== 'string' || !input.code.trim()) + return fail('Code must be a non-empty string.') + const key = this.keyFor(invocationContext) + if (this.sessionKey && this.sessionKey !== key) { + return fail( + 'This executor belongs to another ADK session. Create a separate executor for each session.', + ) + } + this.sessionKey = key + try { + return await this.manager.run(async (sandbox) => { + const execution = await sandbox.runCode(input.code, { language }) + const text = execution.text ? `\n${execution.text}` : '' + const error = execution.error + ? `\n${execution.error.name}: ${execution.error.value}\n${execution.error.traceback}` + : '' + return { + stdout: truncate(execution.logs.stdout.join('') + text), + stderr: truncate(execution.logs.stderr.join('') + error), + outputFiles: [], + } + }) + } catch (error) { + return fail(operationError(error)) + } + } + + private keyFor(context: InvocationContext): string { + const { appName, userId, id } = context.session + return JSON.stringify([appName, userId, id]) + } + + /** Releases the workspace. The instance remains bound to its original session. */ + async close(): Promise { + await this.manager.close() + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7aa11f2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +export { E2BCodeExecutor } from './code-executor.js' +export { E2BToolset } from './toolset.js' +export type { + E2BCodeExecutorOptions, + E2BToolsetOptions, + E2BSandboxOptions, +} from './options.js' diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..c3c4199 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,10 @@ +import type { SandboxOpts } from '@e2b/code-interpreter' + +/** E2B creation options. Unspecified settings retain the SDK defaults. */ +export interface E2BSandboxOptions { + template?: string + sandboxOptions?: Omit +} + +export type E2BCodeExecutorOptions = E2BSandboxOptions +export type E2BToolsetOptions = E2BSandboxOptions diff --git a/src/results.ts b/src/results.ts new file mode 100644 index 0000000..80539c0 --- /dev/null +++ b/src/results.ts @@ -0,0 +1,21 @@ +const OUTPUT_LIMIT = 10_000 +const TRUNCATED = '\n[truncated]' + +export function truncate(value: string): string { + return value.length <= OUTPUT_LIMIT + ? value + : value.slice(0, OUTPUT_LIMIT - TRUNCATED.length) + TRUNCATED +} + +export function failure(error: string) { + return { success: false as const, error } +} + +/** SDK messages may contain request bodies, environment values or credentials. */ +export function operationError(error: unknown): string { + const name = error instanceof Error ? error.name : '' + const category = /^[A-Za-z]{1,60}(Error|Exception)$/.test(name) + ? ` (${name})` + : '' + return `Sandbox operation failed${category}. Check sandbox availability and configuration.` +} diff --git a/src/sandbox-manager.ts b/src/sandbox-manager.ts new file mode 100644 index 0000000..f2c3193 --- /dev/null +++ b/src/sandbox-manager.ts @@ -0,0 +1,79 @@ +import { Sandbox } from '@e2b/code-interpreter' +import type { E2BSandboxOptions } from './options.js' + +/** A serialized, lazy workspace. Expired workspaces are never silently replaced. */ +export class SandboxManager { + private sandbox?: Sandbox + private queue: Promise = Promise.resolve() + private readonly ttl: number + + constructor(private readonly options: E2BSandboxOptions = {}) { + // The cloud default is five minutes. Do not override creation defaults. + this.ttl = options.sandboxOptions?.timeoutMs ?? 300_000 + if (!Number.isFinite(this.ttl) || this.ttl <= 0) { + throw new Error( + 'sandboxOptions.timeoutMs must be a positive finite number.', + ) + } + } + + private enqueue(operation: () => Promise): Promise { + const result = this.queue.then(operation) + this.queue = result.catch(() => undefined) + return result + } + + run(operation: (sandbox: Sandbox) => Promise): Promise { + return this.enqueue(async () => { + if (!this.sandbox) { + this.sandbox = this.options.template + ? await Sandbox.create( + this.options.template, + this.options.sandboxOptions, + ) + : await Sandbox.create(this.options.sandboxOptions) + } + const sandbox = this.sandbox + const refresh = async () => { + try { + await sandbox.setTimeout(this.ttl) + } catch { + // Best-effort maintenance must not replace an operation's result. + } + } + await refresh() + let stopped = false + let timer: ReturnType | undefined + let pending: Promise = Promise.resolve() + const schedule = () => { + timer = setTimeout( + () => { + pending = refresh().then(() => { + if (!stopped) schedule() + }) + }, + Math.max(100, this.ttl / 2), + ) + timer.unref() + } + schedule() + try { + return await operation(sandbox) + } finally { + stopped = true + clearTimeout(timer) + await pending + await refresh() + } + }) + } + + close(): Promise { + return this.enqueue(async () => { + if (!this.sandbox) return + // Retain the handle on failure so an explicit subsequent close can retry. + await this.sandbox.kill() + this.sandbox = undefined + }) + } +} diff --git a/src/toolset.ts b/src/toolset.ts new file mode 100644 index 0000000..81b4f5f --- /dev/null +++ b/src/toolset.ts @@ -0,0 +1,222 @@ +import { + BaseToolset, + FunctionTool, + type BaseTool, + type ToolOptions, +} from '@google/adk' +import { CommandExitError } from '@e2b/code-interpreter' +import { z } from 'zod' +import type { E2BToolsetOptions } from './options.js' +import { failure, operationError, truncate } from './results.js' +import { SandboxManager } from './sandbox-manager.js' + +const path = z.string().min(1).describe('Path inside the sandbox.') +const envs = z + .record(z.string(), z.string()) + .optional() + .describe('Environment variables for this operation only.') +const timeoutMs = z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Execution timeout in milliseconds. Zero disables the SDK execution timeout.', + ) +const command = { + command: z + .string() + .min(1) + .describe('Shell command to execute in the sandbox.'), + cwd: z + .string() + .min(1) + .optional() + .describe('Working directory inside the sandbox.'), + envs, + timeoutMs, +} + +/** Convert validation failures as well as execution failures into tool responses. */ +class SafeFunctionTool extends FunctionTool { + override async runAsync(request: Parameters[0]) { + try { + return await super.runAsync(request) + } catch { + return failure( + 'Invalid tool arguments. Check the declared argument names, types and limits.', + ) + } + } +} + +function tool( + name: string, + description: string, + parameters: T, + execute: ToolOptions['execute'], +): BaseTool { + return new SafeFunctionTool({ + name, + description, + parameters, + execute: async (input, context) => { + try { + return await execute(input, context) + } catch (error) { + return failure(operationError(error)) + } + }, + }) +} + +/** Six agent-callable tools sharing a sandbox until the runner closes the toolset. */ +export class E2BToolset extends BaseToolset { + private readonly manager: SandboxManager + private readonly tools: BaseTool[] + + constructor(options: E2BToolsetOptions = {}) { + super([]) + this.manager = new SandboxManager(options) + this.tools = [ + tool( + 'run_code', + 'Execute code in a stateful E2B kernel. Returns stdout, stderr, the last expression text and runtime errors. Defaults to Python.', + z.object({ + code: z.string().min(1), + language: z + .enum(['python', 'javascript', 'typescript', 'r', 'java', 'bash']) + .default('python'), + envs, + timeoutMs, + }), + (input) => + this.manager.run(async (sandbox) => { + const result = await sandbox.runCode(input.code, { + language: input.language, + envs: input.envs, + timeoutMs: input.timeoutMs, + }) + return { + success: true, + stdout: truncate(result.logs.stdout.join('')), + stderr: truncate(result.logs.stderr.join('')), + text: truncate(result.text ?? ''), + error: result.error + ? truncate( + `${result.error.name}: ${result.error.value}\n${result.error.traceback}`, + ) + : '', + } + }), + ), + tool( + 'run_command', + 'Run a shell command and return stdout, stderr and exit_code. Each command starts a new process; cwd and envs do not persist.', + z.object(command), + (input) => + this.manager.run(async (sandbox) => { + try { + const result = await sandbox.commands.run(input.command, { + cwd: input.cwd, + envs: input.envs, + timeoutMs: input.timeoutMs, + }) + return { + success: true, + stdout: truncate(result.stdout), + stderr: truncate(result.stderr), + exit_code: result.exitCode, + } + } catch (error) { + if (!(error instanceof CommandExitError)) throw error + return { + success: true, + stdout: truncate(error.stdout), + stderr: truncate(error.stderr), + exit_code: error.exitCode, + } + } + }), + ), + tool( + 'write_file', + 'Write a UTF-8 text file inside the sandbox. Creates parent directories.', + z.object({ path, content: z.string() }), + (input) => + this.manager.run(async (sandbox) => { + await sandbox.files.write(input.path, input.content) + return { success: true, path: input.path } + }), + ), + tool( + 'read_file', + 'Read a UTF-8 text file from the sandbox. Large content is truncated.', + z.object({ path }), + (input) => + this.manager.run(async (sandbox) => ({ + success: true, + path: input.path, + content: truncate(await sandbox.files.read(input.path)), + })), + ), + tool( + 'list_files', + 'List entries in a sandbox directory.', + z.object({ path: path.default('.') }), + (input) => + this.manager.run(async (sandbox) => { + const entries = await sandbox.files.list(input.path) + return { + success: true, + entries: entries + .slice(0, 100) + .map(({ name, type }) => ({ name: truncate(name), type })), + truncated: entries.length > 100, + } + }), + ), + tool( + 'start_background_command', + 'Start a sandbox process and return its PID immediately. Optional preview URL is not checked for readiness and expires when the runner invocation ends.', + z.object({ + ...command, + port: z.number().int().min(1).max(65535).optional(), + }), + (input) => + this.manager.run(async (sandbox) => { + const handle = await sandbox.commands.run(input.command, { + cwd: input.cwd, + envs: input.envs, + timeoutMs: input.timeoutMs, + background: true, + }) + // Stop consuming the SDK event stream without killing the process. + await handle.disconnect() + let previewUrl: string | null = null + if (input.port !== undefined) { + try { + previewUrl = `https://${sandbox.getHost(input.port)}` + } catch { + /* Preserve the started process PID. */ + } + } + return { + success: true, + pid: handle.pid, + preview_url: previewUrl, + ...(input.port === undefined ? {} : { readiness: 'unknown' }), + } + }), + ), + ] + } + + override async getTools(): Promise { + return [...this.tools] + } + + override async close(): Promise { + await this.manager.close() + } +} diff --git a/tests/executor.test.ts b/tests/executor.test.ts new file mode 100644 index 0000000..4240577 --- /dev/null +++ b/tests/executor.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { CodeExecutionLanguage as Language } from '@google/adk' +import { E2BCodeExecutor } from '../src/index.js' +import { context, execution, mockSandbox } from './helpers.js' + +describe('code executor', () => { + it.each([ + [Language.PYTHON, 'python'], + [Language.JAVASCRIPT, 'javascript'], + [Language.TYPESCRIPT, 'typescript'], + [Language.SHELL, 'bash'], + ])('maps %s to %s', async (language, expected) => { + const { sandbox } = mockSandbox() + const executor = new E2BCodeExecutor() + expect( + await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: { code: 'code', language, inputFiles: [] }, + }), + ).toEqual({ stdout: 'hello\n\n42', stderr: '', outputFiles: [] }) + expect(sandbox.runCode).toHaveBeenCalledWith('code', { language: expected }) + await executor.close() + }) + + it.each([Language.UNSPECIFIED, Language.WINDOWS_CMD, Language.POWERSHELL])( + 'rejects unsupported %s without creating a workspace', + async (language) => { + const { create } = mockSandbox() + const result = await new E2BCodeExecutor().executeCode({ + invocationContext: context(), + codeExecutionInput: { code: 'x', language, inputFiles: [] }, + }) + expect(result.stderr).toContain('Unsupported language') + expect(create).not.toHaveBeenCalled() + }, + ) + + it('rejects input files and nonempty args before side effects', async () => { + const { create } = mockSandbox() + const executor = new E2BCodeExecutor() + for (const input of [ + { inputFiles: [{ name: 'a', content: 'x', mimeType: 'text/plain' }] }, + { inputFiles: [], args: ['--flag'] }, + { inputFiles: [], args: { flag: true } }, + ]) { + const result = await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: { code: 'x', language: Language.PYTHON, ...input }, + }) + expect(result.stderr).toContain('inputFiles and args are not supported') + } + expect(create).not.toHaveBeenCalled() + expect(executor.optimizeDataFile).toBe(false) + expect(executor.codeBlockDelimiters).toEqual([ + ['```tool_code\n', '\n```'], + ['```python\n', '\n```'], + ]) + }) + + it('retains one workspace and refuses a different session, including another user with the same id', async () => { + const { create, sandbox } = mockSandbox() + const executor = new E2BCodeExecutor() + const input = { code: 'x', language: Language.PYTHON, inputFiles: [] } + await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: input, + }) + await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: { ...input, code: 'x + 1' }, + }) + expect(create).toHaveBeenCalledTimes(1) + for (const ctx of [ + context('different'), + context('session', 'other-user'), + ]) { + expect( + ( + await executor.executeCode({ + invocationContext: ctx, + codeExecutionInput: input, + }) + ).stderr, + ).toContain('another ADK session') + } + expect(sandbox.runCode).toHaveBeenCalledTimes(2) + await executor.close() + await executor.close() + expect(sandbox.kill).toHaveBeenCalledTimes(1) + }) + + it('normalizes runtime errors and truncates all output fields', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockResolvedValue( + execution({ + logs: { stdout: ['x'.repeat(20000)], stderr: [] }, + error: { + name: 'ValueError', + value: 'bad', + traceback: 't'.repeat(20000), + }, + }), + ) + const executor = new E2BCodeExecutor() + const result = await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: { + code: 'x', + language: Language.PYTHON, + inputFiles: [], + }, + }) + expect(result.stdout).toHaveLength(10000) + expect(result.stderr).toHaveLength(10000) + expect(result.stderr).toContain('ValueError: bad') + await executor.close() + }) + + it('does not expose SDK request secrets on transport failures', async () => { + const { create } = mockSandbox() + create.mockRejectedValue(new Error('secret-api-key')) + const result = await new E2BCodeExecutor().executeCode({ + invocationContext: context(), + codeExecutionInput: { + code: 'x', + language: Language.PYTHON, + inputFiles: [], + }, + }) + expect(result.stderr).toContain('Sandbox operation failed') + expect(result.stderr).not.toContain('secret-api-key') + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..3e318f4 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,100 @@ +import { vi } from 'vitest' +import { + Context, + InvocationContext, + PluginManager, + BaseLlm, + type LlmRequest, + type LlmResponse, + type BaseLlmConnection, +} from '@google/adk' +import { Sandbox, type Execution } from '@e2b/code-interpreter' + +export function context(id = 'session', userId = 'user') { + return new InvocationContext({ + invocationId: 'invocation', + pluginManager: new PluginManager(), + session: { + id, + userId, + appName: 'test', + state: {}, + events: [], + lastUpdateTime: 0, + }, + }) +} + +export function toolContext() { + return new Context({ invocationContext: context() }) +} + +export function execution(overrides: Partial = {}): Execution { + return { + logs: { stdout: ['hello\n'], stderr: [] }, + text: '42', + results: [], + ...overrides, + } as Execution +} + +export function fakeSandbox() { + return { + sandboxId: 'fake-sandbox', + runCode: vi.fn().mockResolvedValue(execution()), + commands: { + run: vi.fn().mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 }), + }, + files: { + write: vi.fn().mockResolvedValue({ path: '/home/user/test.txt' }), + read: vi.fn().mockResolvedValue('contents'), + list: vi.fn().mockResolvedValue([{ name: 'test.txt', type: 'file' }]), + }, + getHost: vi.fn().mockReturnValue('3000-fake.e2b.app'), + setTimeout: vi.fn().mockResolvedValue(undefined), + kill: vi.fn().mockResolvedValue(true), + } +} + +export function mockSandbox() { + const sandbox = fakeSandbox() + const create = vi + .spyOn(Sandbox, 'create') + .mockResolvedValue(sandbox as unknown as Sandbox) + // Tests must never contact a model or sandbox endpoint. + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Network disabled in tests'), + ) + return { sandbox, create } +} + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +export class ScriptedModel extends BaseLlm { + readonly requests: LlmRequest[] = [] + private index = 0 + constructor(private readonly responses: Array) { + super({ model: 'scripted' }) + } + override async *generateContentAsync(request: LlmRequest) { + this.requests.push({ + ...request, + contents: structuredClone(request.contents), + }) + const response = this.responses[this.index++] + if (response instanceof Error) throw response + if (!response) throw new Error('Unexpected model call') + yield response + } + override async connect(): Promise { + throw new Error('Live streaming is not used') + } +} diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..8d52d1f --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Sandbox } from '@e2b/code-interpreter' +import { SandboxManager } from '../src/sandbox-manager.js' +import { deferred, mockSandbox } from './helpers.js' + +afterEach(() => vi.useRealTimers()) + +describe('workspace lifecycle', () => { + it('is lazy and forwards only supplied SDK options', async () => { + const { create, sandbox } = mockSandbox() + const manager = new SandboxManager({ + template: 'custom', + sandboxOptions: { + timeoutMs: 1200, + allowInternetAccess: false, + envs: { TEST: 'value' }, + }, + }) + expect(create).not.toHaveBeenCalled() + await manager.run(async () => 1) + expect(create).toHaveBeenCalledWith('custom', { + timeoutMs: 1200, + allowInternetAccess: false, + envs: { TEST: 'value' }, + }) + expect(sandbox.setTimeout).toHaveBeenCalledWith(1200) + await manager.close() + }) + + it('serializes concurrent calls and closes after an in-flight create', async () => { + const { create, sandbox } = mockSandbox() + const pending = deferred() + create.mockReturnValue(pending.promise) + const manager = new SandboxManager() + const order: string[] = [] + const a = manager.run(async () => { + order.push('a') + }) + const b = manager.run(async () => { + order.push('b') + }) + const close = manager.close() + await Promise.resolve() + expect(create).toHaveBeenCalledTimes(1) + expect(sandbox.kill).not.toHaveBeenCalled() + pending.resolve(sandbox as unknown as Sandbox) + await Promise.all([a, b, close]) + expect(order).toEqual(['a', 'b']) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await manager.close() + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await manager.run(async () => undefined) + expect(create).toHaveBeenCalledTimes(2) + await manager.close() + }) + + it('waits for active work before killing and rejects no queued cleanup', async () => { + const { sandbox } = mockSandbox() + const started = deferred() + const finish = deferred() + const manager = new SandboxManager() + const operation = manager.run(async () => { + started.resolve() + await finish.promise + }) + await started.promise + const close = manager.close() + expect(sandbox.kill).not.toHaveBeenCalled() + finish.resolve() + await Promise.all([operation, close]) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + }) + + it('does not silently recreate an expired sandbox or lose a failed kill handle', async () => { + const { create, sandbox } = mockSandbox() + const manager = new SandboxManager() + await manager.run(async () => undefined) + sandbox.runCode.mockRejectedValue(new Error('Expired')) + await expect(manager.run((box) => box.runCode('1'))).rejects.toThrow( + 'Expired', + ) + await expect(manager.run((box) => box.runCode('2'))).rejects.toThrow( + 'Expired', + ) + expect(create).toHaveBeenCalledTimes(1) + sandbox.kill.mockRejectedValueOnce(new Error('Unavailable')) + await expect(manager.close()).rejects.toThrow('Unavailable') + await manager.close() + expect(sandbox.kill).toHaveBeenCalledTimes(2) + }) + + it('can retry a failed creation', async () => { + const { create } = mockSandbox() + create.mockRejectedValueOnce(new Error('Unavailable')) + const manager = new SandboxManager() + await expect(manager.run(async () => undefined)).rejects.toThrow( + 'Unavailable', + ) + await manager.run(async () => undefined) + expect(create).toHaveBeenCalledTimes(2) + await manager.close() + }) + + it('refreshes throughout operations exceeding TTL and cancels the timer on errors', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const manager = new SandboxManager({ sandboxOptions: { timeoutMs: 1000 } }) + const started = deferred() + const finish = deferred() + const work = manager.run(async () => { + started.resolve() + return finish.promise + }) + await started.promise + expect(sandbox.setTimeout).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2500) + expect(sandbox.setTimeout).toHaveBeenCalledTimes(6) + const rejected = expect(work).rejects.toThrow('Execution failed') + finish.reject(new Error('Execution failed')) + await rejected + expect(vi.getTimerCount()).toBe(0) + expect(sandbox.setTimeout).toHaveBeenCalledTimes(7) + await manager.close() + }) + + it('maintenance failures do not change a successful operation', async () => { + const { sandbox } = mockSandbox() + sandbox.setTimeout.mockRejectedValue(new Error('Transient')) + const manager = new SandboxManager() + expect(await manager.run(async () => 42)).toBe(42) + await manager.close() + }) +}) diff --git a/tests/runner.test.ts b/tests/runner.test.ts new file mode 100644 index 0000000..1dabe7c --- /dev/null +++ b/tests/runner.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { + InMemoryRunner, + LlmAgent, + type Event, + type LlmResponse, +} from '@google/adk' +import { E2BCodeExecutor, E2BToolset } from '../src/index.js' +import { mockSandbox, ScriptedModel } from './helpers.js' + +const toolCall: LlmResponse = { + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'run_code', + args: { code: '6 * 7' }, + }, + }, + ], + }, +} +const done: LlmResponse = { + content: { role: 'model', parts: [{ text: '42' }] }, +} +const message = { role: 'user', parts: [{ text: 'Compute the answer.' }] } +async function collect(stream: AsyncGenerator) { + const events: Event[] = [] + for await (const event of stream) events.push(event) + return events +} + +describe('real ADK runner integration', () => { + it('routes tool calls and closes the workspace after each invocation', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + const model = new ScriptedModel([toolCall, done, toolCall, done]) + const runner = new InMemoryRunner({ + agent: new LlmAgent({ name: 'agent', model, tools: [toolset] }), + }) + const events = await collect( + runner.runEphemeral({ userId: 'user', newMessage: message }), + ) + expect(sandbox.runCode).toHaveBeenCalledWith('6 * 7', { + language: 'python', + envs: undefined, + timeoutMs: undefined, + }) + expect( + events + .flatMap((e) => e.content?.parts ?? []) + .some((p) => p.functionResponse?.name === 'run_code'), + ).toBe(true) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await collect(runner.runEphemeral({ userId: 'user', newMessage: message })) + expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.kill).toHaveBeenCalledTimes(2) + }) + + it('closes after a model error following tool execution', async () => { + const { sandbox } = mockSandbox() + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'agent', + model: new ScriptedModel([toolCall, new Error('Model failed')]), + tools: [new E2BToolset()], + }), + }) + const events = await collect( + runner.runEphemeral({ userId: 'user', newMessage: message }), + ) + expect(events.some((event) => event.errorMessage === 'Model failed')).toBe( + true, + ) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + }) + + it('closes when the event consumer stops early', async () => { + const { sandbox } = mockSandbox() + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'agent', + model: new ScriptedModel([toolCall, done]), + tools: [new E2BToolset()], + }), + }) + for await (const event of runner.runEphemeral({ + userId: 'user', + newMessage: message, + })) { + if (event.content?.parts?.some((p) => p.functionResponse)) break + } + expect(sandbox.runCode).toHaveBeenCalledTimes(1) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + }) + + it('closes on cancellation after tool execution', async () => { + const { sandbox } = mockSandbox() + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'agent', + model: new ScriptedModel([toolCall, done]), + tools: [new E2BToolset()], + }), + }) + const session = await runner.sessionService.createSession({ + appName: runner.appName, + userId: 'user', + }) + const abort = new AbortController() + for await (const event of runner.runAsync({ + userId: 'user', + sessionId: session.id, + newMessage: message, + abortSignal: abort.signal, + })) { + if (event.content?.parts?.some((p) => p.functionResponse)) abort.abort() + } + expect(sandbox.kill).toHaveBeenCalledTimes(1) + }) + + it('returns validation errors to the model so it can correct a call', async () => { + const { create } = mockSandbox() + const badCall: LlmResponse = { + content: { + role: 'model', + parts: [ + { functionCall: { id: 'bad', name: 'run_code', args: { code: 1 } } }, + ], + }, + } + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'agent', + model: new ScriptedModel([badCall, toolCall, done]), + tools: [new E2BToolset()], + }), + }) + const events = await collect( + runner.runEphemeral({ userId: 'user', newMessage: message }), + ) + const responses = events + .flatMap((e) => e.content?.parts ?? []) + .filter((p) => p.functionResponse) + expect(responses).toHaveLength(2) + expect(JSON.stringify(responses[0])).toContain('"success":false') + expect(create).toHaveBeenCalledTimes(1) + }) + + it('documents that ADK 2.1.0 does not wire its code execution response processor', async () => { + const { sandbox } = mockSandbox() + const executor = new E2BCodeExecutor() + const model = new ScriptedModel([ + { + content: { role: 'model', parts: [{ text: '```python\n6 * 7\n```' }] }, + }, + done, + ]) + const runner = new InMemoryRunner({ + agent: new LlmAgent({ name: 'agent', model, codeExecutor: executor }), + }) + const events = await collect( + runner.runEphemeral({ userId: 'user', newMessage: message }), + ) + expect(events.filter((event) => event.errorCode)).toEqual([]) + expect(sandbox.runCode).not.toHaveBeenCalled() + expect( + events + .flatMap((e) => e.content?.parts ?? []) + .some((p) => p.codeExecutionResult), + ).toBe(false) + expect(sandbox.kill).not.toHaveBeenCalled() + await executor.close() + expect(sandbox.kill).not.toHaveBeenCalled() + }) + + it('does not reinterpret JavaScript fences as Python', async () => { + const { create } = mockSandbox() + const executor = new E2BCodeExecutor() + const model = new ScriptedModel([ + { + content: { + role: 'model', + parts: [{ text: '```javascript\nconsole.log(42)\n```' }], + }, + }, + ]) + const runner = new InMemoryRunner({ + agent: new LlmAgent({ name: 'agent', model, codeExecutor: executor }), + }) + await collect(runner.runEphemeral({ userId: 'user', newMessage: message })) + expect(create).not.toHaveBeenCalled() + await executor.close() + }) +}) diff --git a/tests/tools.test.ts b/tests/tools.test.ts new file mode 100644 index 0000000..a1f79e2 --- /dev/null +++ b/tests/tools.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest' +import { CommandExitError } from '@e2b/code-interpreter' +import { E2BToolset } from '../src/index.js' +import { execution, mockSandbox, toolContext } from './helpers.js' + +async function invoke( + toolset: E2BToolset, + name: string, + args: Record, +) { + const tool = (await toolset.getTools()).find((tool) => tool.name === name)! + return tool.runAsync({ args, toolContext: toolContext() }) +} + +describe('six tools', () => { + it('exports declarations without creating a sandbox', async () => { + const { create } = mockSandbox() + const tools = await new E2BToolset().getTools() + expect(tools.map((t) => t.name)).toEqual([ + 'run_code', + 'run_command', + 'write_file', + 'read_file', + 'list_files', + 'start_background_command', + ]) + for (const t of tools) expect(t._getDeclaration()?.parameters).toBeDefined() + expect(create).not.toHaveBeenCalled() + }) + + it.each(['python', 'javascript', 'typescript', 'r', 'java', 'bash'])( + 'forwards %s and preserves runtime error semantics', + async (language) => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockResolvedValue( + execution({ + error: { name: 'ValueError', value: 'bad', traceback: 'trace' }, + }), + ) + const toolset = new E2BToolset() + expect( + await invoke(toolset, 'run_code', { + code: 'code', + language, + timeoutMs: 1200, + envs: { X: '1' }, + }), + ).toEqual({ + success: true, + stdout: 'hello\n', + stderr: '', + text: '42', + error: 'ValueError: bad\ntrace', + }) + expect(sandbox.runCode).toHaveBeenCalledWith('code', { + language, + timeoutMs: 1200, + envs: { X: '1' }, + }) + await toolset.close() + }, + ) + + it.each([ + ['run_code', { code: 1 }], + ['run_code', { code: 'x', language: 'cmd' }], + ['run_code', { code: 'x', timeoutMs: -1 }], + ['run_code', { code: 'x', envs: { A: 2 } }], + ['run_command', { command: '' }], + ['write_file', { path: 'x' }], + ['read_file', { path: '' }], + ['list_files', { path: 7 }], + ['start_background_command', { command: 'x', port: 0 }], + ['start_background_command', { command: 'x', port: 65536 }], + ['start_background_command', { command: 'x', port: 1.5 }], + ])('rejects invalid %s input before SDK effects', async (name, args) => { + const { create } = mockSandbox() + expect( + await invoke( + new E2BToolset(), + name as string, + args as Record, + ), + ).toMatchObject({ success: false }) + expect(create).not.toHaveBeenCalled() + }) + + it('uses one workspace for file operations, including empty files', async () => { + const { create, sandbox } = mockSandbox() + const toolset = new E2BToolset() + expect( + await invoke(toolset, 'write_file', { path: 'a.txt', content: '' }), + ).toEqual({ success: true, path: 'a.txt' }) + expect(sandbox.files.write).toHaveBeenCalledWith('a.txt', '') + expect(await invoke(toolset, 'read_file', { path: 'a.txt' })).toEqual({ + success: true, + path: 'a.txt', + content: 'contents', + }) + expect(await invoke(toolset, 'list_files', {})).toEqual({ + success: true, + entries: [{ name: 'test.txt', type: 'file' }], + truncated: false, + }) + expect(create).toHaveBeenCalledTimes(1) + await toolset.close() + }) + + it('normalizes nonzero command exits as completed commands', async () => { + const { sandbox } = mockSandbox() + sandbox.commands.run.mockRejectedValue( + new CommandExitError({ + stdout: 'out', + stderr: 'err', + exitCode: 7, + error: 'failed', + }), + ) + const toolset = new E2BToolset() + expect( + await invoke(toolset, 'run_command', { + command: 'exit 7', + cwd: '/tmp', + envs: { X: 'y' }, + timeoutMs: 0, + }), + ).toEqual({ success: true, stdout: 'out', stderr: 'err', exit_code: 7 }) + expect(sandbox.commands.run).toHaveBeenCalledWith('exit 7', { + cwd: '/tmp', + envs: { X: 'y' }, + timeoutMs: 0, + }) + await toolset.close() + }) + + it('returns safe failures without leaking SDK messages or logging arguments', async () => { + const { sandbox } = mockSandbox() + const log = vi.spyOn(console, 'log') + const debug = vi.spyOn(console, 'debug') + const warn = vi.spyOn(console, 'warn') + sandbox.runCode.mockRejectedValue( + new Error('secret-key raw-code private-file'), + ) + const toolset = new E2BToolset() + const result = await invoke(toolset, 'run_code', { + code: 'raw-code', + envs: { KEY: 'secret-key' }, + }) + expect(result).toMatchObject({ success: false }) + expect(JSON.stringify(result)).not.toMatch( + /secret-key|raw-code|private-file/, + ) + expect(log).not.toHaveBeenCalled() + expect(debug).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + await toolset.close() + }) + + it('bounds code, file and directory outputs', async () => { + const { sandbox } = mockSandbox() + sandbox.files.read.mockResolvedValue('x'.repeat(20000)) + sandbox.files.list.mockResolvedValue( + Array.from({ length: 150 }, () => ({ name: 'x', type: 'file' })), + ) + const toolset = new E2BToolset() + const read = (await invoke(toolset, 'read_file', { path: 'x' })) as { + content: string + } + expect(read.content).toHaveLength(10000) + expect(read.content).toContain('[truncated]') + const list = (await invoke(toolset, 'list_files', {})) as { + entries: unknown[] + truncated: boolean + } + expect(list.entries).toHaveLength(100) + expect(list.truncated).toBe(true) + await toolset.close() + }) + + it('returns the background PID and an unverified URL, disconnecting the stream', async () => { + const { sandbox } = mockSandbox() + const disconnect = vi.fn().mockResolvedValue(undefined) + sandbox.commands.run.mockResolvedValue({ pid: 123, disconnect }) + const toolset = new E2BToolset() + expect( + await invoke(toolset, 'start_background_command', { + command: 'server', + port: 3000, + timeoutMs: 0, + }), + ).toEqual({ + success: true, + pid: 123, + preview_url: 'https://3000-fake.e2b.app', + readiness: 'unknown', + }) + expect(disconnect).toHaveBeenCalledTimes(1) + sandbox.getHost.mockImplementation(() => { + throw new Error('host') + }) + expect( + await invoke(toolset, 'start_background_command', { + command: 'server', + port: 3000, + }), + ).toMatchObject({ success: true, pid: 123, preview_url: null }) + expect( + await invoke(toolset, 'start_background_command', { command: 'server' }), + ).toEqual({ success: true, pid: 123, preview_url: null }) + await toolset.close() + }) +}) From efb589189a25217b3fd352886a9917f0051bc777 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Tue, 22 Sep 2026 17:18:00 +0200 Subject: [PATCH 03/23] fix: reject invalid runtime executor language values --- src/code-executor.ts | 2 +- tests/executor.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/code-executor.ts b/src/code-executor.ts index 621e0ab..303f27a 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -52,7 +52,7 @@ export class E2BCodeExecutor extends BaseCodeExecutor { [CodeExecutionLanguage.POWERSHELL]: undefined, [CodeExecutionLanguage.WINDOWS_CMD]: undefined, }[input.language] - if (!language) + if (typeof language !== 'string') return fail( 'Unsupported language. Use python, javascript, typescript or shell.', ) diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 4240577..791b9de 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -22,7 +22,13 @@ describe('code executor', () => { await executor.close() }) - it.each([Language.UNSPECIFIED, Language.WINDOWS_CMD, Language.POWERSHELL])( + it.each([ + Language.UNSPECIFIED, + Language.WINDOWS_CMD, + Language.POWERSHELL, + '__proto__' as Language, + 'constructor' as Language, + ])( 'rejects unsupported %s without creating a workspace', async (language) => { const { create } = mockSandbox() From 32acf63e0c87eaaaa59ccce5c1f19e9982df2d78 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Tue, 22 Sep 2026 18:00:00 +0200 Subject: [PATCH 04/23] docs: add verified agent and direct executor examples --- README.md | 119 ++++++++++++++++++++++++++++++++++++++ examples/code-executor.ts | 39 +++++++++++++ examples/data-analysis.ts | 50 ++++++++++++++++ package.json | 2 + tsconfig.json | 3 +- 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 examples/code-executor.ts create mode 100644 examples/data-analysis.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cd5536 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# E2B for Google ADK + +Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` creates a shared workspace lazily and releases it when the ADK runner invocation finishes. + +## Install + +Requires Node.js 22 or later and an ESM project (`"type": "module"` in `package.json`). Tested with Google ADK 2.1.0. + +```bash +npm install @google/adk @e2b/adk +npm install --save-dev tsx typescript @types/node +``` + +Set credentials in your application's environment: + +```bash +export E2B_API_KEY="..." +export GOOGLE_API_KEY="..." +``` + +The integration is model-independent. The examples use Gemini 2.5 Flash through ADK's built-in provider. Credentials are used by the local SDKs and are not forwarded into the sandbox unless explicitly supplied in `envs`. + +## Agent tools + +```typescript +import { E2BToolset } from '@e2b/adk' +import { InMemoryRunner, LlmAgent } from '@google/adk' + +const toolset = new E2BToolset() +const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'calculator', + model: 'gemini-2.5-flash', + instruction: 'Use run_code to verify calculations. Check its error field.', + tools: [toolset], + }), +}) + +try { + for await (const event of runner.runEphemeral({ + userId: 'user', + newMessage: { role: 'user', parts: [{ text: 'Compute 1234567 * 89.' }] }, + runConfig: { maxLlmCalls: 6 }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.text && !part.thought) console.log(part.text) + } + } +} finally { + await toolset.close() +} +``` + +Pass the toolset itself in `tools: [toolset]` so ADK can close it. If you call `getTools()` and register individual tools instead, your application must call `close()` explicitly. + +| Tool | Arguments | Result fields besides `success` | +| -------------------------- | ------------------------------------------------------ | ------------------------------------------ | +| `run_code` | `code`, optional `language`, `envs`, `timeoutMs` | `stdout`, `stderr`, `text`, `error` | +| `run_command` | `command`, optional `cwd`, `envs`, `timeoutMs` | `stdout`, `stderr`, `exit_code` | +| `write_file` | `path`, `content` | `path` | +| `read_file` | `path` | `path`, `content` | +| `list_files` | optional `path` (default `.`) | `entries`, `truncated` | +| `start_background_command` | `command`, optional `cwd`, `envs`, `timeoutMs`, `port` | `pid`, `preview_url`, optional `readiness` | + +`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`. Kernel state persists between calls in the same sandbox. Commands start independent processes; their working directory and environment changes do not carry into the next command. + +`success` means the operation ran. Code that raises still returns `success: true` with an `error`; a command exiting nonzero returns `success: true` with its `exit_code`. Invalid arguments and SDK/transport failures return `success: false` with an `error` rather than aborting the agent loop. SDK error messages are sanitized. Each text output is limited to 10,000 characters; directory listings contain at most 100 entries and indicate truncation. + +## Configuration and lifecycle + +```typescript +const toolset = new E2BToolset({ + template: 'your-code-interpreter-template', + sandboxOptions: { + timeoutMs: 300_000, + metadata: { application: 'analyst' }, + allowInternetAccess: false, + }, +}) +``` + +`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use. `sandboxOptions` accepts the E2B SDK's creation options except `template`. Unspecified network, lifecycle, environment and connection options retain the SDK defaults. Sandbox and tool timeout values use milliseconds. An operation's `timeoutMs: 0` disables its SDK execution deadline; the sandbox TTL still applies. + +Creation, operations and teardown are serialized per instance. Active SDK operations refresh the sandbox TTL, including a heartbeat for operations longer than the TTL. The adapter does not silently replace an expired sandbox with an empty one. An explicit `close()` followed by another use creates a new workspace; a failed `close()` retains the handle so cleanup can be retried. + +ADK 2.1.0 closes registered toolsets at the end of each runner invocation, including errors and cancellation. Files, kernel state and background processes are shared only within that invocation. A preview URL is syntactic, has `readiness: "unknown"`, and stops working when the sandbox closes. Use a separate toolset for each concurrent run or user; do not share a global instance between concurrent runner invocations. Finish consuming or explicitly close a runner's async iterator so its `finally` cleanup runs. + +The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. + +## Direct code executor + +`E2BCodeExecutor` implements ADK's `BaseCodeExecutor`. Call `executeCode({ invocationContext, codeExecutionInput })` and close it explicitly in `finally`. See the complete [direct executor example](examples/code-executor.ts). + +**ADK 2.1.0 limitation:** setting `LlmAgent({ codeExecutor })` alone does not execute model responses: ADK does not register its code execution response processor. Use `E2BToolset` for an autonomous agent. This package does not install a custom processor or import ADK internals. + +Direct calls accept ADK's `CodeExecutionLanguage.PYTHON`, `JAVASCRIPT`, `TYPESCRIPT` and `SHELL` (mapped to bash). Unsupported languages, nonempty `inputFiles` and nonempty `args` return a descriptive `stderr` without creating a sandbox. Output contains bounded `stdout`/`stderr` and `outputFiles: []`; automatic file staging and artifact collection are not supported. The executor retains kernel state until `close()` and remains bound to its first ADK session to prevent accidental cross-session sharing. + +## Examples and development + +- [Data analysis agent](examples/data-analysis.ts): save a CSV, calculate results with Python, and report the answer. +- [Direct code executor](examples/code-executor.ts): reuse a stateful Python kernel through the ADK executor contract. + +```bash +pnpm install --frozen-lockfile +pnpm check +``` + +Tests use real ADK runtime objects with mocked models and E2B SDK calls. Network access is blocked by the test fixtures; no API credentials are needed for CI. CI runs on Node 22 and 24. + +For examples and paid live checks, copy `.env.example` to `.env` and supply your credentials locally: + +```bash +pnpm example:analysis +pnpm example:executor +pnpm test:live +``` + +Live checks create real E2B sandboxes and make Gemini requests. They verify tool execution, supported languages and teardown, and clean up only workspaces tagged with their unique test-run ID. They are never part of `pnpm check` or CI. diff --git a/examples/code-executor.ts b/examples/code-executor.ts new file mode 100644 index 0000000..618e506 --- /dev/null +++ b/examples/code-executor.ts @@ -0,0 +1,39 @@ +import { E2BCodeExecutor } from '@e2b/adk' +import { + CodeExecutionLanguage, + InvocationContext, + PluginManager, + createSession, +} from '@google/adk' + +// Direct calls: ADK 2.1.0 does not automatically execute model code blocks +// when only LlmAgent.codeExecutor is configured. Use E2BToolset for agents. +const invocationContext = new InvocationContext({ + invocationId: 'calculation', + pluginManager: new PluginManager(), + session: createSession({ + id: 'calculation', + appName: 'direct_executor', + userId: 'user', + }), +}) +const executor = new E2BCodeExecutor({ + sandboxOptions: { metadata: { example: 'adk-typescript-direct-executor' } }, +}) + +try { + for (const code of ['answer = 6 * 7', 'print(answer)']) { + const result = await executor.executeCode({ + invocationContext, + codeExecutionInput: { + code, + language: CodeExecutionLanguage.PYTHON, + inputFiles: [], + }, + }) + if (result.stderr) throw new Error(result.stderr) + process.stdout.write(result.stdout) + } +} finally { + await executor.close() +} diff --git a/examples/data-analysis.ts b/examples/data-analysis.ts new file mode 100644 index 0000000..b118b90 --- /dev/null +++ b/examples/data-analysis.ts @@ -0,0 +1,50 @@ +import { E2BToolset } from '@e2b/adk' +import { InMemoryRunner, LlmAgent } from '@google/adk' + +const dataset = `month,region,marketing_spend,revenue +2024-01,North,12000,48000 +2024-02,North,15000,60000 +2024-01,South,8000,24000 +2024-02,South,11000,33000` + +const toolset = new E2BToolset({ + sandboxOptions: { metadata: { example: 'adk-typescript-data-analysis' } }, +}) +const agent = new LlmAgent({ + name: 'data_analyst', + model: 'gemini-2.5-flash', + instruction: + 'Use write_file to save the supplied CSV to /home/user/sales.csv. ' + + 'Use run_code with Python to read that file and calculate the answers. ' + + 'Check error and exit_code fields, fix failures and rerun. ' + + 'Never report a number you have not verified in the sandbox.', + tools: [toolset], + generateContentConfig: { + temperature: 0, + thinkingConfig: { thinkingBudget: 0 }, + }, +}) +const runner = new InMemoryRunner({ agent }) + +try { + for await (const event of runner.runEphemeral({ + userId: 'analyst', + newMessage: { + role: 'user', + parts: [ + { + text: `${dataset}\nCalculate total revenue divided by total marketing spend for each region. Which region performs better?`, + }, + ], + }, + runConfig: { maxLlmCalls: 8 }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.text && !part.thought) console.log(part.text) + } + } +} finally { + // Runner closes registered toolsets; this also covers an interrupted caller. + await toolset.close() +} diff --git a/package.json b/package.json index 848056a..b202195 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:live": "node --env-file=.env --import tsx scripts/live.ts", + "example:analysis": "node --env-file=.env --import tsx examples/data-analysis.ts", + "example:executor": "node --env-file=.env --import tsx examples/code-executor.ts", "lint": "eslint .", "format:check": "prettier --check .", "format": "prettier --write .", diff --git a/tsconfig.json b/tsconfig.json index d4f5b83..a9998fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, - "types": ["node"] + "types": ["node"], + "paths": { "@e2b/adk": ["./src/index.ts"] } }, "include": [ "src/**/*.ts", From 92522eac3fafd4bd24d5682ffb91610c7b1dc759 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Wed, 23 Sep 2026 10:14:00 +0200 Subject: [PATCH 05/23] fix: guard invocation isolation and failed sandbox cleanup --- src/results.ts | 8 ++ src/sandbox-manager.ts | 13 ++- src/toolset.ts | 104 +++++++++++++----- tests/isolation.test.ts | 235 ++++++++++++++++++++++++++++++++++++++++ tests/lifecycle.test.ts | 11 +- tests/runner.test.ts | 4 +- tests/tools.test.ts | 6 + 7 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 tests/isolation.test.ts diff --git a/src/results.ts b/src/results.ts index 80539c0..75604f9 100644 --- a/src/results.ts +++ b/src/results.ts @@ -1,6 +1,12 @@ const OUTPUT_LIMIT = 10_000 const TRUNCATED = '\n[truncated]' +export class SandboxCleanupError extends Error { + constructor() { + super('Sandbox cleanup failed. Retry close() before using this instance.') + } +} + export function truncate(value: string): string { return value.length <= OUTPUT_LIMIT ? value @@ -13,6 +19,8 @@ export function failure(error: string) { /** SDK messages may contain request bodies, environment values or credentials. */ export function operationError(error: unknown): string { + if (error instanceof SandboxCleanupError) + return new SandboxCleanupError().message const name = error instanceof Error ? error.name : '' const category = /^[A-Za-z]{1,60}(Error|Exception)$/.test(name) ? ` (${name})` diff --git a/src/sandbox-manager.ts b/src/sandbox-manager.ts index f2c3193..a836a8a 100644 --- a/src/sandbox-manager.ts +++ b/src/sandbox-manager.ts @@ -1,11 +1,13 @@ import { Sandbox } from '@e2b/code-interpreter' import type { E2BSandboxOptions } from './options.js' +import { SandboxCleanupError } from './results.js' /** A serialized, lazy workspace. Expired workspaces are never silently replaced. */ export class SandboxManager { private sandbox?: Sandbox private queue: Promise = Promise.resolve() private readonly ttl: number + private cleanupFailed = false constructor(private readonly options: E2BSandboxOptions = {}) { // The cloud default is five minutes. Do not override creation defaults. @@ -25,6 +27,9 @@ export class SandboxManager { run(operation: (sandbox: Sandbox) => Promise): Promise { return this.enqueue(async () => { + if (this.cleanupFailed) { + throw new SandboxCleanupError() + } if (!this.sandbox) { this.sandbox = this.options.template ? await Sandbox.create( @@ -72,8 +77,14 @@ export class SandboxManager { return this.enqueue(async () => { if (!this.sandbox) return // Retain the handle on failure so an explicit subsequent close can retry. - await this.sandbox.kill() + this.cleanupFailed = true + try { + await this.sandbox.kill() + } catch { + throw new SandboxCleanupError() + } this.sandbox = undefined + this.cleanupFailed = false }) } } diff --git a/src/toolset.ts b/src/toolset.ts index 81b4f5f..e54dcd5 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -3,6 +3,8 @@ import { FunctionTool, type BaseTool, type ToolOptions, + type InvocationContext, + type ReadonlyContext, } from '@google/adk' import { CommandExitError } from '@e2b/code-interpreter' import { z } from 'zod' @@ -50,36 +52,24 @@ class SafeFunctionTool extends FunctionTool { } } -function tool( - name: string, - description: string, - parameters: T, - execute: ToolOptions['execute'], -): BaseTool { - return new SafeFunctionTool({ - name, - description, - parameters, - execute: async (input, context) => { - try { - return await execute(input, context) - } catch (error) { - return failure(operationError(error)) - } - }, - }) -} - /** Six agent-callable tools sharing a sandbox until the runner closes the toolset. */ export class E2BToolset extends BaseToolset { private readonly manager: SandboxManager private readonly tools: BaseTool[] + private owner?: string + private conflicted = false + private readonly activeSessions = new Set() + private activeInvocationId?: string + private readonly closedInvocations = new WeakMap< + InvocationContext['session'], + Set + >() constructor(options: E2BToolsetOptions = {}) { super([]) this.manager = new SandboxManager(options) this.tools = [ - tool( + this.tool( 'run_code', 'Execute code in a stateful E2B kernel. Returns stdout, stderr, the last expression text and runtime errors. Defaults to Python.', z.object({ @@ -110,7 +100,7 @@ export class E2BToolset extends BaseToolset { } }), ), - tool( + this.tool( 'run_command', 'Run a shell command and return stdout, stderr and exit_code. Each command starts a new process; cwd and envs do not persist.', z.object(command), @@ -139,7 +129,7 @@ export class E2BToolset extends BaseToolset { } }), ), - tool( + this.tool( 'write_file', 'Write a UTF-8 text file inside the sandbox. Creates parent directories.', z.object({ path, content: z.string() }), @@ -149,7 +139,7 @@ export class E2BToolset extends BaseToolset { return { success: true, path: input.path } }), ), - tool( + this.tool( 'read_file', 'Read a UTF-8 text file from the sandbox. Large content is truncated.', z.object({ path }), @@ -160,7 +150,7 @@ export class E2BToolset extends BaseToolset { content: truncate(await sandbox.files.read(input.path)), })), ), - tool( + this.tool( 'list_files', 'List entries in a sandbox directory.', z.object({ path: path.default('.') }), @@ -176,7 +166,7 @@ export class E2BToolset extends BaseToolset { } }), ), - tool( + this.tool( 'start_background_command', 'Start a sandbox process and return its PID immediately. Optional preview URL is not checked for readiness and expires when the runner invocation ends.', z.object({ @@ -212,11 +202,71 @@ export class E2BToolset extends BaseToolset { ] } - override async getTools(): Promise { + private admissionError(context?: InvocationContext): string | undefined { + if (!context) return 'An ADK invocation context is required.' + if (this.conflicted) + return 'This toolset was shared by overlapping invocations. Create a separate E2BToolset for each run.' + const { session, invocationId } = context + if (this.closedInvocations.get(session)?.has(invocationId)) { + return 'The workspace for this invocation was closed. It cannot be reopened; start a new run with a separate E2BToolset.' + } + const key = JSON.stringify([ + session.appName, + session.userId, + session.id, + invocationId, + ]) + if (this.owner && this.owner !== key) { + this.conflicted = true + return 'This toolset was shared by overlapping invocations. Create a separate E2BToolset for each run.' + } + this.owner = key + this.activeInvocationId = invocationId + this.activeSessions.add(session) + } + + private tool( + name: string, + description: string, + parameters: T, + execute: ToolOptions['execute'], + ): BaseTool { + return new SafeFunctionTool({ + name, + description, + parameters, + execute: async (input, context) => { + const admissionError = this.admissionError(context?.invocationContext) + if (admissionError) return failure(admissionError) + try { + return await execute(input, context) + } catch (error) { + return failure(operationError(error)) + } + }, + }) + } + + override async getTools(context?: ReadonlyContext): Promise { + if (context) { + const error = this.admissionError(context.invocationContext) + if (error) throw new Error(error) + } return [...this.tools] } override async close(): Promise { + // Invalidate before awaiting cleanup: ADK close() has no invocation context. + if (this.activeInvocationId !== undefined) { + for (const session of this.activeSessions) { + const closed = this.closedInvocations.get(session) ?? new Set() + closed.add(this.activeInvocationId) + this.closedInvocations.set(session, closed) + } + } + this.activeSessions.clear() + this.owner = undefined + this.activeInvocationId = undefined await this.manager.close() } } diff --git a/tests/isolation.test.ts b/tests/isolation.test.ts new file mode 100644 index 0000000..789b74a --- /dev/null +++ b/tests/isolation.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest' +import type { Sandbox } from '@e2b/code-interpreter' +import { + Context, + InMemoryRunner, + LlmAgent, + type Event, + type LlmResponse, +} from '@google/adk' +import { E2BToolset } from '../src/index.js' +import { context, mockSandbox, ScriptedModel } from './helpers.js' + +const done: LlmResponse = { + content: { role: 'model', parts: [{ text: 'done' }] }, +} +const call = (name: string, args: Record): LlmResponse => ({ + content: { role: 'model', parts: [{ functionCall: { name, args } }] }, +}) +const write = call('write_file', { path: '/secret.txt', content: 'alice-data' }) +const read = call('read_file', { path: '/secret.txt' }) +const message = { role: 'user', parts: [{ text: 'test' }] } +function runner(toolset: E2BToolset, responses: LlmResponse[]) { + return new InMemoryRunner({ + agent: new LlmAgent({ + name: 'agent', + model: new ScriptedModel(responses), + tools: [toolset], + }), + }) +} +async function response(stream: AsyncGenerator) { + for (;;) { + const event = await stream.next() + if (event.done) throw new Error('Missing tool response') + const result = event.value.content?.parts?.find((p) => p.functionResponse) + ?.functionResponse?.response + if (result) return result + } +} +async function drain(stream: AsyncGenerator) { + const events: Event[] = [] + for await (const event of stream) events.push(event) + return events +} + +describe('invocation isolation', () => { + it('allows sequential invocations in the same session with fresh workspaces', async () => { + const { create, sandbox } = mockSandbox() + const app = runner(new E2BToolset(), [write, done, write, done]) + const session = await app.sessionService.createSession({ + appName: app.appName, + userId: 'alice', + }) + for (let i = 0; i < 2; i++) { + await drain( + app.runAsync({ + userId: 'alice', + sessionId: session.id, + newMessage: message, + }), + ) + } + expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.kill).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])( + 'fails closed for overlapping runs (same session: %s)', + async (sameSession) => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + const app = runner(toolset, [write, read, done]) + const session = await app.sessionService.createSession({ + appName: app.appName, + userId: 'alice', + }) + const alice = app.runAsync({ + userId: 'alice', + sessionId: session.id, + newMessage: message, + }) + expect((await response(alice)).success).toBe(true) + const bob = sameSession + ? app.runAsync({ + userId: 'alice', + sessionId: session.id, + newMessage: message, + }) + : app.runEphemeral({ userId: 'bob', newMessage: message }) + // Discovery rejects the second invocation even if it never calls a tool. + await expect(drain(bob)).rejects.toThrow('overlapping invocations') + expect(sandbox.files.read).not.toHaveBeenCalled() + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await expect(drain(alice)).rejects.toThrow('overlapping invocations') + expect(create).toHaveBeenCalledTimes(1) + await toolset.close() + await expect( + drain(app.runEphemeral({ userId: 'later', newMessage: message })), + ).rejects.toThrow('overlapping invocations') + expect(create).toHaveBeenCalledTimes(1) + }, + ) + + it('does not expose a previous user workspace after Runner swallows a failed kill', async () => { + const { sandbox, create } = mockSandbox() + sandbox.kill.mockRejectedValueOnce(new Error('secret SDK request')) + const toolset = new E2BToolset() + const app = runner(toolset, [write, done, read, done, write, done]) + await drain(app.runEphemeral({ userId: 'alice', newMessage: message })) + const bob = app.runEphemeral({ userId: 'bob', newMessage: message }) + expect(await response(bob)).toMatchObject({ + success: false, + error: expect.stringContaining('Retry close()'), + }) + expect(sandbox.files.read).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + await drain(bob) // Cleanup retries the retained handle successfully. + expect(sandbox.kill).toHaveBeenCalledTimes(2) + await drain(app.runEphemeral({ userId: 'carol', newMessage: message })) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('does not reopen after an unrelated runner closes the toolset without discovering tools', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + const aliceRunner = runner(toolset, [write, read, done]) + const alice = aliceRunner.runEphemeral({ + userId: 'alice', + newMessage: message, + }) + await response(alice) + const bobRunner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'short_circuit', + model: new ScriptedModel([]), + tools: [toolset], + beforeAgentCallback: () => done.content, + }), + }) + await drain(bobRunner.runEphemeral({ userId: 'bob', newMessage: message })) + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await expect(drain(alice)).rejects.toThrow('was closed') + expect(sandbox.files.read).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + }) + + it('rejects late calls and cloned contexts after close without reopening or disturbing a newer run', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + const tools = await toolset.getTools() + const readTool = tools.find((t) => t.name === 'read_file')! + const alice = context('alice') + const invoke = (ctx: typeof alice) => + readTool.runAsync({ + args: { path: '/secret.txt' }, + toolContext: new Context({ invocationContext: ctx }), + }) + await invoke(alice) + await toolset.close() + await invoke(context('bob')) + await toolset.close() + expect(await invoke(alice.clone())).toMatchObject({ + success: false, + error: expect.stringContaining('was closed'), + }) + expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.files.read).toHaveBeenCalledTimes(2) + }) + + it('guards individual tools even when getTools is called without a context', async () => { + const { create, sandbox } = mockSandbox() + const toolset = new E2BToolset() + const readTool = (await toolset.getTools()).find( + (t) => t.name === 'read_file', + )! + const invoke = (user: string) => + readTool.runAsync({ + args: { path: '/secret.txt' }, + toolContext: new Context({ invocationContext: context(user, user) }), + }) + expect(await invoke('alice')).toMatchObject({ success: true }) + expect(await invoke('bob')).toMatchObject({ + success: false, + error: expect.stringContaining('overlapping invocations'), + }) + expect(await invoke('alice')).toMatchObject({ success: false }) + expect(sandbox.files.read).toHaveBeenCalledTimes(1) + expect(create).toHaveBeenCalledTimes(1) + await toolset.close() + }) + + it('keeps separate concurrent instances independent', async () => { + const { sandbox, create } = mockSandbox() + // Separate in-memory SDK workspaces make data isolation observable. + create.mockImplementation(async () => { + const files = new Map() + let killed = false + return { + ...sandbox, + files: { + ...sandbox.files, + write: async (path: string, value: string) => { + expect(killed).toBe(false) + files.set(path, value) + }, + read: async (path: string) => { + expect(killed).toBe(false) + if (!files.has(path)) throw new Error('Not found') + return files.get(path)! + }, + }, + kill: async () => { + killed = true + return true + }, + } as unknown as Sandbox + }) + const aliceRunner = runner(new E2BToolset(), [write, read, done]) + const bobRunner = runner(new E2BToolset(), [read, done]) + const alice = aliceRunner.runEphemeral({ + userId: 'alice', + newMessage: message, + }) + await response(alice) + const bob = bobRunner.runEphemeral({ userId: 'bob', newMessage: message }) + expect((await response(bob)).success).toBe(false) + await drain(bob) + expect(await response(alice)).toMatchObject({ + success: true, + content: 'alice-data', + }) + await drain(alice) + expect(create).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 8d52d1f..2e9b8be 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -84,9 +84,18 @@ describe('workspace lifecycle', () => { ) expect(create).toHaveBeenCalledTimes(1) sandbox.kill.mockRejectedValueOnce(new Error('Unavailable')) - await expect(manager.close()).rejects.toThrow('Unavailable') + await expect(manager.close()).rejects.toThrow('Retry close()') + const workAfterFailure = vi.fn() + const renewals = sandbox.setTimeout.mock.calls.length + await expect(manager.run(workAfterFailure)).rejects.toThrow('Retry close()') + expect(workAfterFailure).not.toHaveBeenCalled() + expect(sandbox.setTimeout).toHaveBeenCalledTimes(renewals) + expect(create).toHaveBeenCalledTimes(1) await manager.close() expect(sandbox.kill).toHaveBeenCalledTimes(2) + await manager.run(async () => undefined) + expect(create).toHaveBeenCalledTimes(2) + await manager.close() }) it('can retry a failed creation', async () => { diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 1dabe7c..339358f 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -98,10 +98,11 @@ describe('real ADK runner integration', () => { it('closes on cancellation after tool execution', async () => { const { sandbox } = mockSandbox() + const model = new ScriptedModel([toolCall, done]) const runner = new InMemoryRunner({ agent: new LlmAgent({ name: 'agent', - model: new ScriptedModel([toolCall, done]), + model, tools: [new E2BToolset()], }), }) @@ -119,6 +120,7 @@ describe('real ADK runner integration', () => { if (event.content?.parts?.some((p) => p.functionResponse)) abort.abort() } expect(sandbox.kill).toHaveBeenCalledTimes(1) + expect(model.requests).toHaveLength(1) }) it('returns validation errors to the model so it can correct a call', async () => { diff --git a/tests/tools.test.ts b/tests/tools.test.ts index a1f79e2..5854a22 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -195,6 +195,12 @@ describe('six tools', () => { readiness: 'unknown', }) expect(disconnect).toHaveBeenCalledTimes(1) + expect(sandbox.commands.run).toHaveBeenCalledWith('server', { + background: true, + timeoutMs: 0, + cwd: undefined, + envs: undefined, + }) sandbox.getHost.mockImplementation(() => { throw new Error('host') }) From e87e9379fce6f9a5d56a9c13d6ebb06e4b9ae392 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Wed, 23 Sep 2026 10:49:00 +0200 Subject: [PATCH 06/23] docs: demonstrate per-request toolsets and cleanup boundaries --- README.md | 57 ++++++++++++++++------------ examples/data-analysis.ts | 80 +++++++++++++++++++++------------------ 2 files changed, 76 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 0cd5536..5fa1dfd 100644 --- a/README.md +++ b/README.md @@ -26,33 +26,40 @@ The integration is model-independent. The examples use Gemini 2.5 Flash through import { E2BToolset } from '@e2b/adk' import { InMemoryRunner, LlmAgent } from '@google/adk' -const toolset = new E2BToolset() -const runner = new InMemoryRunner({ - agent: new LlmAgent({ - name: 'calculator', - model: 'gemini-2.5-flash', - instruction: 'Use run_code to verify calculations. Check its error field.', - tools: [toolset], - }), -}) - -try { - for await (const event of runner.runEphemeral({ - userId: 'user', - newMessage: { role: 'user', parts: [{ text: 'Compute 1234567 * 89.' }] }, - runConfig: { maxLlmCalls: 6 }, - })) { - if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) - for (const part of event.content?.parts ?? []) { - if (part.text && !part.thought) console.log(part.text) +async function calculate(userId: string, question: string): Promise { + const toolset = new E2BToolset() + const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'calculator', + model: 'gemini-2.5-flash', + instruction: + 'Use run_code to verify calculations. Check its error field.', + tools: [toolset], + }), + }) + + let answer = '' + try { + for await (const event of runner.runEphemeral({ + userId, + newMessage: { role: 'user', parts: [{ text: question }] }, + runConfig: { maxLlmCalls: 6 }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.text && !part.thought) answer += part.text + } } + } finally { + await toolset.close() } -} finally { - await toolset.close() + return answer } + +console.log(await calculate('user', 'Compute 1234567 * 89.')) ``` -Pass the toolset itself in `tools: [toolset]` so ADK can close it. If you call `getTools()` and register individual tools instead, your application must call `close()` explicitly. +Create the toolset, agent and runner inside the request function, as above. Concurrent calls to `calculate()` then own separate sandboxes. Pass the toolset itself in `tools: [toolset]` so ADK's `runAsync()` / `runEphemeral()` can close it. If you register individual tools from `getTools()`, your application must call `close()` explicitly. | Tool | Arguments | Result fields besides `success` | | -------------------------- | ------------------------------------------------------ | ------------------------------------------ | @@ -82,9 +89,11 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use. `sandboxOptions` accepts the E2B SDK's creation options except `template`. Unspecified network, lifecycle, environment and connection options retain the SDK defaults. Sandbox and tool timeout values use milliseconds. An operation's `timeoutMs: 0` disables its SDK execution deadline; the sandbox TTL still applies. -Creation, operations and teardown are serialized per instance. Active SDK operations refresh the sandbox TTL, including a heartbeat for operations longer than the TTL. The adapter does not silently replace an expired sandbox with an empty one. An explicit `close()` followed by another use creates a new workspace; a failed `close()` retains the handle so cleanup can be retried. +Creation, operations and teardown are serialized per instance. Active SDK operations refresh the sandbox TTL, including a heartbeat for operations longer than the TTL. The adapter does not silently replace an expired sandbox with an empty one. After successful `close()`, a new invocation can create a new workspace; late calls from the closed invocation are rejected. If cleanup fails, the handle is retained for retry and sandbox operations are blocked until `close()` succeeds. Keep the explicit `finally` cleanup: ADK 2.1.0 suppresses automatic toolset cleanup errors, whereas an explicit `close()` reports a safe error to your application. + +ADK 2.1.0 closes registered toolsets at the end of `runAsync()` / `runEphemeral()`, including errors and cancellation. `runLive()` does not automatically close toolsets; always call `close()` in `finally` for that mode. Files, kernel state and background processes are shared within a single invocation. A preview URL is syntactic, has `readiness: "unknown"`, and stops working when the sandbox closes. Finish consuming or explicitly close a runner's async iterator so its `finally` cleanup runs. -ADK 2.1.0 closes registered toolsets at the end of each runner invocation, including errors and cancellation. Files, kernel state and background processes are shared only within that invocation. A preview URL is syntactic, has `readiness: "unknown"`, and stops working when the sandbox closes. Use a separate toolset for each concurrent run or user; do not share a global instance between concurrent runner invocations. Finish consuming or explicitly close a runner's async iterator so its `finally` cleanup runs. +Overlapping invocations must use separate toolsets, even within the same user session. Detecting an overlap permanently invalidates the shared instance; subsequent discovery or tool calls fail, and `close()` still releases its sandbox. Because ADK calls `close()` without identifying the invocation, a competing runner's cleanup can interrupt the original run. This guard prevents cross-invocation access and silent workspace recreation; it does not make shared concurrent use supported. Create a new toolset after such a conflict. The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. diff --git a/examples/data-analysis.ts b/examples/data-analysis.ts index b118b90..070e882 100644 --- a/examples/data-analysis.ts +++ b/examples/data-analysis.ts @@ -7,44 +7,50 @@ const dataset = `month,region,marketing_spend,revenue 2024-01,South,8000,24000 2024-02,South,11000,33000` -const toolset = new E2BToolset({ - sandboxOptions: { metadata: { example: 'adk-typescript-data-analysis' } }, -}) -const agent = new LlmAgent({ - name: 'data_analyst', - model: 'gemini-2.5-flash', - instruction: - 'Use write_file to save the supplied CSV to /home/user/sales.csv. ' + - 'Use run_code with Python to read that file and calculate the answers. ' + - 'Check error and exit_code fields, fix failures and rerun. ' + - 'Never report a number you have not verified in the sandbox.', - tools: [toolset], - generateContentConfig: { - temperature: 0, - thinkingConfig: { thinkingBudget: 0 }, - }, -}) -const runner = new InMemoryRunner({ agent }) - -try { - for await (const event of runner.runEphemeral({ - userId: 'analyst', - newMessage: { - role: 'user', - parts: [ - { - text: `${dataset}\nCalculate total revenue divided by total marketing spend for each region. Which region performs better?`, - }, - ], +async function analyze(userId: string, csv: string): Promise { + const toolset = new E2BToolset({ + sandboxOptions: { metadata: { example: 'adk-typescript-data-analysis' } }, + }) + const agent = new LlmAgent({ + name: 'data_analyst', + model: 'gemini-2.5-flash', + instruction: + 'Use write_file to save the supplied CSV to /home/user/sales.csv. ' + + 'Use run_code with Python to read that file and calculate the answers. ' + + 'Check error and exit_code fields, fix failures and rerun. ' + + 'Never report a number you have not verified in the sandbox.', + tools: [toolset], + generateContentConfig: { + temperature: 0, + thinkingConfig: { thinkingBudget: 0 }, }, - runConfig: { maxLlmCalls: 8 }, - })) { - if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) - for (const part of event.content?.parts ?? []) { - if (part.text && !part.thought) console.log(part.text) + }) + const runner = new InMemoryRunner({ agent }) + + let answer = '' + try { + for await (const event of runner.runEphemeral({ + userId, + newMessage: { + role: 'user', + parts: [ + { + text: `${csv}\nCalculate total revenue divided by total marketing spend for each region. Which region performs better?`, + }, + ], + }, + runConfig: { maxLlmCalls: 8 }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.text && !part.thought) answer += part.text + } } + } finally { + // Runner closes registered toolsets; this also covers an interrupted caller. + await toolset.close() } -} finally { - // Runner closes registered toolsets; this also covers an interrupted caller. - await toolset.close() + return answer } + +console.log(await analyze('analyst', dataset)) From dfa267f6009cfee1cdb860248c21cf70bcd11929 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Wed, 23 Sep 2026 13:14:00 +0200 Subject: [PATCH 07/23] feat: session-scoped workspaces with bounded, stoppable operations Each ADK session gets its own lazily created sandbox, so a module-level agent serves concurrent users and keeps files and kernels across messages and confirmation pauses. ADK close() no longer kills anything; the application releases workspaces with destroySession() and destroyAll(). - Operations of one session are serialized; a cancelled waiter has no SDK side effects and never lets later work overtake the active one. - run_command runs in its own process group and is killed as a group on deadline or abort; run_code restarts its per-language kernel. - timeoutMs is 1..maxOperationMs (default 10 minutes); the deadline starts when the operation runs. - A paused sandbox is resumed; a missing one is reported once with workspace_expired, then a new workspace is created. - Background commands no longer die at the SDK's 60 s default. - The build cleans dist before compiling. --- package.json | 2 +- src/code-executor.ts | 49 +++-- src/options.ts | 8 +- src/results.ts | 45 +++- src/sandbox-manager.ts | 90 -------- src/toolset.ts | 395 ++++++++++++++++++++-------------- src/workspace.ts | 376 ++++++++++++++++++++++++++++++++ tests/executor.test.ts | 34 ++- tests/helpers.ts | 129 ++++++++++- tests/isolation.test.ts | 466 ++++++++++++++++++++++++---------------- tests/lifecycle.test.ts | 142 ------------ tests/runner.test.ts | 90 ++++---- tests/tools.test.ts | 251 +++++++++++++++++----- tests/workspace.test.ts | 387 +++++++++++++++++++++++++++++++++ 14 files changed, 1756 insertions(+), 708 deletions(-) delete mode 100644 src/sandbox-manager.ts create mode 100644 src/workspace.ts delete mode 100644 tests/lifecycle.test.ts create mode 100644 tests/workspace.test.ts diff --git a/package.json b/package.json index b202195..4fabba0 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", "test:live": "node --env-file=.env --import tsx scripts/live.ts", diff --git a/src/code-executor.ts b/src/code-executor.ts index 303f27a..0f36515 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -6,18 +6,24 @@ import { } from '@google/adk' import type { E2BCodeExecutorOptions } from './options.js' import { operationError, truncate } from './results.js' -import { SandboxManager } from './sandbox-manager.js' +import { operationLimit, Workspace } from './workspace.js' type ExecuteParams = Parameters[0] /** Stateful code execution in one E2B workspace, owned by one ADK session. */ export class E2BCodeExecutor extends BaseCodeExecutor { - private readonly manager: SandboxManager + private readonly workspace: Workspace + private readonly timeoutMs: number private sessionKey?: string constructor(options: E2BCodeExecutorOptions = {}) { super() - this.manager = new SandboxManager(options) + // Executor calls carry no limit: keep the SDK's 60 s default, capped by maxOperationMs. + this.timeoutMs = Math.min(60_000, operationLimit(options)) + this.workspace = new Workspace( + options, + 'Retry close() before using this executor.', + ) this.stateful = true // ADK's response processor currently labels extracted blocks as Python. this.codeBlockDelimiters = [ @@ -66,18 +72,27 @@ export class E2BCodeExecutor extends BaseCodeExecutor { } this.sessionKey = key try { - return await this.manager.run(async (sandbox) => { - const execution = await sandbox.runCode(input.code, { language }) - const text = execution.text ? `\n${execution.text}` : '' - const error = execution.error - ? `\n${execution.error.name}: ${execution.error.value}\n${execution.error.traceback}` - : '' - return { - stdout: truncate(execution.logs.stdout.join('') + text), - stderr: truncate(execution.logs.stderr.join('') + error), - outputFiles: [], - } - }) + const outcome = await this.workspace.runCode( + input.code, + language, + undefined, + { created: false }, + { signal: invocationContext.abortSignal, timeoutMs: this.timeoutMs }, + ) + if ('stopped' in outcome) + return fail( + `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. The kernel was restarted; its variables and imports were lost.`, + ) + const { execution } = outcome + const text = execution.text ? `\n${execution.text}` : '' + const error = execution.error + ? `\n${execution.error.name}: ${execution.error.value}\n${execution.error.traceback}` + : '' + return { + stdout: truncate(execution.logs.stdout.join('') + text), + stderr: truncate(execution.logs.stderr.join('') + error), + outputFiles: [], + } } catch (error) { return fail(operationError(error)) } @@ -88,8 +103,8 @@ export class E2BCodeExecutor extends BaseCodeExecutor { return JSON.stringify([appName, userId, id]) } - /** Releases the workspace. The instance remains bound to its original session. */ + /** Releases the workspace; a later call starts a new one. The instance stays bound to its original session. */ async close(): Promise { - await this.manager.close() + await this.workspace.destroy() } } diff --git a/src/options.ts b/src/options.ts index c3c4199..78070db 100644 --- a/src/options.ts +++ b/src/options.ts @@ -6,5 +6,9 @@ export interface E2BSandboxOptions { sandboxOptions?: Omit } -export type E2BCodeExecutorOptions = E2BSandboxOptions -export type E2BToolsetOptions = E2BSandboxOptions +export interface E2BToolsetOptions extends E2BSandboxOptions { + /** Longest a single code or command execution may run, in milliseconds. Default 600_000. */ + maxOperationMs?: number +} + +export type E2BCodeExecutorOptions = E2BToolsetOptions diff --git a/src/results.ts b/src/results.ts index 75604f9..803b2da 100644 --- a/src/results.ts +++ b/src/results.ts @@ -2,8 +2,33 @@ const OUTPUT_LIMIT = 10_000 const TRUNCATED = '\n[truncated]' export class SandboxCleanupError extends Error { + constructor(hint = '') { + super(`Sandbox cleanup failed. ${hint}`.trim()) + } +} + +/** The sandbox disappeared (TTL expiry or external kill); its state is gone. */ +export class WorkspaceExpiredError extends Error { + constructor() { + super( + 'The sandbox for this conversation expired or was deleted; its files, kernels and processes are gone. The next call starts a new, empty workspace.', + ) + } +} + +export class WorkspaceDestroyedError extends Error { + constructor() { + super( + 'The application released this workspace while the operation was pending.', + ) + } +} + +export class WorkspaceBlockedError extends Error {} + +export class OperationAbortedError extends Error { constructor() { - super('Sandbox cleanup failed. Retry close() before using this instance.') + super('The operation was cancelled before it started.') } } @@ -13,17 +38,23 @@ export function truncate(value: string): string { : value.slice(0, OUTPUT_LIMIT - TRUNCATED.length) + TRUNCATED } -export function failure(error: string) { - return { success: false as const, error } +export function failure(error: string, fields: Record = {}) { + return { success: false as const, error, ...fields } } -/** SDK messages may contain request bodies, environment values or credentials. */ +/** Adapter errors carry safe text; SDK messages may contain bodies or credentials. */ export function operationError(error: unknown): string { - if (error instanceof SandboxCleanupError) - return new SandboxCleanupError().message + if ( + error instanceof SandboxCleanupError || + error instanceof WorkspaceExpiredError || + error instanceof WorkspaceDestroyedError || + error instanceof WorkspaceBlockedError || + error instanceof OperationAbortedError + ) + return error.message const name = error instanceof Error ? error.name : '' const category = /^[A-Za-z]{1,60}(Error|Exception)$/.test(name) ? ` (${name})` : '' - return `Sandbox operation failed${category}. Check sandbox availability and configuration.` + return `Sandbox operation failed${category}.` } diff --git a/src/sandbox-manager.ts b/src/sandbox-manager.ts deleted file mode 100644 index a836a8a..0000000 --- a/src/sandbox-manager.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Sandbox } from '@e2b/code-interpreter' -import type { E2BSandboxOptions } from './options.js' -import { SandboxCleanupError } from './results.js' - -/** A serialized, lazy workspace. Expired workspaces are never silently replaced. */ -export class SandboxManager { - private sandbox?: Sandbox - private queue: Promise = Promise.resolve() - private readonly ttl: number - private cleanupFailed = false - - constructor(private readonly options: E2BSandboxOptions = {}) { - // The cloud default is five minutes. Do not override creation defaults. - this.ttl = options.sandboxOptions?.timeoutMs ?? 300_000 - if (!Number.isFinite(this.ttl) || this.ttl <= 0) { - throw new Error( - 'sandboxOptions.timeoutMs must be a positive finite number.', - ) - } - } - - private enqueue(operation: () => Promise): Promise { - const result = this.queue.then(operation) - this.queue = result.catch(() => undefined) - return result - } - - run(operation: (sandbox: Sandbox) => Promise): Promise { - return this.enqueue(async () => { - if (this.cleanupFailed) { - throw new SandboxCleanupError() - } - if (!this.sandbox) { - this.sandbox = this.options.template - ? await Sandbox.create( - this.options.template, - this.options.sandboxOptions, - ) - : await Sandbox.create(this.options.sandboxOptions) - } - const sandbox = this.sandbox - const refresh = async () => { - try { - await sandbox.setTimeout(this.ttl) - } catch { - // Best-effort maintenance must not replace an operation's result. - } - } - await refresh() - let stopped = false - let timer: ReturnType | undefined - let pending: Promise = Promise.resolve() - const schedule = () => { - timer = setTimeout( - () => { - pending = refresh().then(() => { - if (!stopped) schedule() - }) - }, - Math.max(100, this.ttl / 2), - ) - timer.unref() - } - schedule() - try { - return await operation(sandbox) - } finally { - stopped = true - clearTimeout(timer) - await pending - await refresh() - } - }) - } - - close(): Promise { - return this.enqueue(async () => { - if (!this.sandbox) return - // Retain the handle on failure so an explicit subsequent close can retry. - this.cleanupFailed = true - try { - await this.sandbox.kill() - } catch { - throw new SandboxCleanupError() - } - this.sandbox = undefined - this.cleanupFailed = false - }) - } -} diff --git a/src/toolset.ts b/src/toolset.ts index e54dcd5..76ef997 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -2,29 +2,39 @@ import { BaseToolset, FunctionTool, type BaseTool, + type Session, type ToolOptions, - type InvocationContext, - type ReadonlyContext, } from '@google/adk' -import { CommandExitError } from '@e2b/code-interpreter' import { z } from 'zod' import type { E2BToolsetOptions } from './options.js' -import { failure, operationError, truncate } from './results.js' -import { SandboxManager } from './sandbox-manager.js' +import { + failure, + OperationAbortedError, + operationError, + SandboxCleanupError, + truncate, + WorkspaceExpiredError, +} from './results.js' +import { operationLimit, Workspace, type RunInfo } from './workspace.js' + +/** SDK default for code and command execution when the model sets no limit. */ +const DEFAULT_TIMEOUT_MS = 60_000 +const CLEANUP_HINT = + 'The application must retry destroySession() before this workspace can be used.' + +type SessionRef = Pick +type Execute = ( + input: z.infer, + workspace: Workspace, + info: RunInfo, + signal?: AbortSignal, +) => Promise> const path = z.string().min(1).describe('Path inside the sandbox.') const envs = z .record(z.string(), z.string()) .optional() .describe('Environment variables for this operation only.') -const timeoutMs = z - .number() - .int() - .nonnegative() - .optional() - .describe( - 'Execution timeout in milliseconds. Zero disables the SDK execution timeout.', - ) const command = { command: z .string() @@ -36,7 +46,6 @@ const command = { .optional() .describe('Working directory inside the sandbox.'), envs, - timeoutMs, } /** Convert validation failures as well as execution failures into tool responses. */ @@ -52,26 +61,31 @@ class SafeFunctionTool extends FunctionTool { } } -/** Six agent-callable tools sharing a sandbox until the runner closes the toolset. */ +const key = ({ appName, userId, id }: SessionRef) => + JSON.stringify([appName, userId, id]) + +/** + * Six agent-callable tools. Each ADK session gets its own lazily created + * sandbox, kept until the application calls destroySession()/destroyAll() or + * the sandbox timeout releases it. ADK's automatic close() leaves it running. + */ export class E2BToolset extends BaseToolset { - private readonly manager: SandboxManager + private readonly workspaces = new Map() private readonly tools: BaseTool[] - private owner?: string - private conflicted = false - private readonly activeSessions = new Set() - private activeInvocationId?: string - private readonly closedInvocations = new WeakMap< - InvocationContext['session'], - Set - >() - constructor(options: E2BToolsetOptions = {}) { + constructor(private readonly options: E2BToolsetOptions = {}) { super([]) - this.manager = new SandboxManager(options) + const max = operationLimit(options) + const limit = z.number().int().min(1).max(max).optional() + const timeoutMs = limit.describe( + `Execution limit in milliseconds, 1 to ${max}. Defaults to ${Math.min(DEFAULT_TIMEOUT_MS, max)}.`, + ) + const deadline = (value?: number) => + value ?? Math.min(DEFAULT_TIMEOUT_MS, max) this.tools = [ this.tool( 'run_code', - 'Execute code in a stateful E2B kernel. Returns stdout, stderr, the last expression text and runtime errors. Defaults to Python.', + 'Execute code in a stateful kernel, one per language, shared by the whole conversation while its workspace lives. Returns stdout, stderr, the last expression text and runtime errors. Defaults to Python.', z.object({ code: z.string().min(1), language: z @@ -80,193 +94,240 @@ export class E2BToolset extends BaseToolset { envs, timeoutMs, }), - (input) => - this.manager.run(async (sandbox) => { - const result = await sandbox.runCode(input.code, { - language: input.language, - envs: input.envs, - timeoutMs: input.timeoutMs, - }) - return { - success: true, - stdout: truncate(result.logs.stdout.join('')), - stderr: truncate(result.logs.stderr.join('')), - text: truncate(result.text ?? ''), - error: result.error - ? truncate( - `${result.error.name}: ${result.error.value}\n${result.error.traceback}`, - ) - : '', - } - }), + async (input, workspace, info, signal) => { + const ms = deadline(input.timeoutMs) + const outcome = await workspace.runCode( + input.code, + input.language, + input.envs, + info, + { signal, timeoutMs: ms }, + ) + if ('stopped' in outcome) { + const why = + outcome.stopped === 'timed_out' + ? `exceeded ${ms} ms` + : 'was cancelled' + return failure( + `Execution ${why}. The ${input.language} kernel was restarted; its variables and imports were lost.`, + { [outcome.stopped]: true, kernel_restarted: true }, + ) + } + const { execution } = outcome + return { + success: true, + stdout: truncate(execution.logs.stdout.join('')), + stderr: truncate(execution.logs.stderr.join('')), + text: truncate(execution.text ?? ''), + error: execution.error + ? truncate( + `${execution.error.name}: ${execution.error.value}\n${execution.error.traceback}`, + ) + : '', + } + }, ), this.tool( 'run_command', - 'Run a shell command and return stdout, stderr and exit_code. Each command starts a new process; cwd and envs do not persist.', - z.object(command), - (input) => - this.manager.run(async (sandbox) => { - try { - const result = await sandbox.commands.run(input.command, { - cwd: input.cwd, - envs: input.envs, - timeoutMs: input.timeoutMs, - }) - return { - success: true, - stdout: truncate(result.stdout), - stderr: truncate(result.stderr), - exit_code: result.exitCode, - } - } catch (error) { - if (!(error instanceof CommandExitError)) throw error - return { - success: true, - stdout: truncate(error.stdout), - stderr: truncate(error.stderr), - exit_code: error.exitCode, - } - } - }), + 'Run a shell command and return stdout, stderr and exit_code. Each command starts a new process; cwd and envs do not persist, files do.', + z.object({ ...command, timeoutMs }), + async (input, workspace, info, signal) => { + const ms = deadline(input.timeoutMs) + const outcome = await workspace.runCommand( + input.command, + { cwd: input.cwd, envs: input.envs }, + info, + { signal, timeoutMs: ms }, + ) + if ('stopped' in outcome) { + const why = + outcome.stopped === 'timed_out' + ? `timed out after ${ms} ms` + : 'was cancelled' + return failure( + `Command ${why}; it and its child processes were killed.`, + { + [outcome.stopped]: true, + stdout: truncate(outcome.stdout), + stderr: truncate(outcome.stderr), + }, + ) + } + return { + success: true, + stdout: truncate(outcome.result.stdout), + stderr: truncate(outcome.result.stderr), + exit_code: outcome.result.exitCode, + } + }, ), this.tool( 'write_file', 'Write a UTF-8 text file inside the sandbox. Creates parent directories.', z.object({ path, content: z.string() }), - (input) => - this.manager.run(async (sandbox) => { - await sandbox.files.write(input.path, input.content) - return { success: true, path: input.path } - }), + (input, workspace, info, signal) => + workspace.run( + async (sandbox) => { + await sandbox.files.write(input.path, input.content) + return { success: true, path: input.path } + }, + info, + signal, + ), ), this.tool( 'read_file', 'Read a UTF-8 text file from the sandbox. Large content is truncated.', z.object({ path }), - (input) => - this.manager.run(async (sandbox) => ({ - success: true, - path: input.path, - content: truncate(await sandbox.files.read(input.path)), - })), + (input, workspace, info, signal) => + workspace.run( + async (sandbox) => ({ + success: true, + path: input.path, + content: truncate(await sandbox.files.read(input.path)), + }), + info, + signal, + ), ), this.tool( 'list_files', 'List entries in a sandbox directory.', z.object({ path: path.default('.') }), - (input) => - this.manager.run(async (sandbox) => { - const entries = await sandbox.files.list(input.path) - return { - success: true, - entries: entries - .slice(0, 100) - .map(({ name, type }) => ({ name: truncate(name), type })), - truncated: entries.length > 100, - } - }), + (input, workspace, info, signal) => + workspace.run( + async (sandbox) => { + const entries = await sandbox.files.list(input.path) + return { + success: true, + entries: entries + .slice(0, 100) + .map(({ name, type }) => ({ name: truncate(name), type })), + truncated: entries.length > 100, + } + }, + info, + signal, + ), ), this.tool( 'start_background_command', - 'Start a sandbox process and return its PID immediately. Optional preview URL is not checked for readiness and expires when the runner invocation ends.', + 'Start a long-running sandbox process and return its PID immediately. It runs until it exits, timeoutMs elapses, or the workspace ends. An optional preview URL is not checked for readiness.', z.object({ ...command, + timeoutMs: limit.describe( + `Optional process lifetime in milliseconds, 1 to ${max}. Omit to keep it running.`, + ), port: z.number().int().min(1).max(65535).optional(), }), - (input) => - this.manager.run(async (sandbox) => { - const handle = await sandbox.commands.run(input.command, { - cwd: input.cwd, - envs: input.envs, - timeoutMs: input.timeoutMs, - background: true, - }) - // Stop consuming the SDK event stream without killing the process. - await handle.disconnect() - let previewUrl: string | null = null - if (input.port !== undefined) { - try { - previewUrl = `https://${sandbox.getHost(input.port)}` - } catch { - /* Preserve the started process PID. */ + (input, workspace, info, signal) => + workspace.run( + async (sandbox) => { + const handle = await sandbox.commands.run(input.command, { + cwd: input.cwd, + envs: input.envs, + // The SDK default (60 s) would kill a disconnected process too. + timeoutMs: input.timeoutMs ?? 0, + background: true, + }) + // Stop consuming the SDK event stream without killing the process. + await handle.disconnect() + let previewUrl: string | null = null + if (input.port !== undefined) { + try { + previewUrl = `https://${sandbox.getHost(input.port)}` + } catch { + /* Preserve the started process PID. */ + } } - } - return { - success: true, - pid: handle.pid, - preview_url: previewUrl, - ...(input.port === undefined ? {} : { readiness: 'unknown' }), - } - }), + return { + success: true, + pid: handle.pid, + preview_url: previewUrl, + ...(input.port === undefined ? {} : { readiness: 'unknown' }), + } + }, + info, + signal, + ), ), ] } - private admissionError(context?: InvocationContext): string | undefined { - if (!context) return 'An ADK invocation context is required.' - if (this.conflicted) - return 'This toolset was shared by overlapping invocations. Create a separate E2BToolset for each run.' - const { session, invocationId } = context - if (this.closedInvocations.get(session)?.has(invocationId)) { - return 'The workspace for this invocation was closed. It cannot be reopened; start a new run with a separate E2BToolset.' - } - const key = JSON.stringify([ - session.appName, - session.userId, - session.id, - invocationId, - ]) - if (this.owner && this.owner !== key) { - this.conflicted = true - return 'This toolset was shared by overlapping invocations. Create a separate E2BToolset for each run.' + private workspace(session: SessionRef): Workspace { + let workspace = this.workspaces.get(key(session)) + if (!workspace) { + workspace = new Workspace(this.options, CLEANUP_HINT) + this.workspaces.set(key(session), workspace) } - this.owner = key - this.activeInvocationId = invocationId - this.activeSessions.add(session) + return workspace } private tool( name: string, description: string, parameters: T, - execute: ToolOptions['execute'], + execute: Execute, ): BaseTool { return new SafeFunctionTool({ name, description, parameters, - execute: async (input, context) => { - const admissionError = this.admissionError(context?.invocationContext) - if (admissionError) return failure(admissionError) - try { - return await execute(input, context) - } catch (error) { - return failure(operationError(error)) - } - }, + execute: (async (input: z.infer, context) => { + const invocation = context?.invocationContext + if (!invocation) + return failure('An ADK invocation context is required.') + const info: RunInfo = { created: false } + const result = await execute( + input, + this.workspace(invocation.session), + info, + invocation.abortSignal, + ).catch((error: unknown) => + failure(operationError(error), { + ...(error instanceof WorkspaceExpiredError && { + workspace_expired: true, + }), + ...(error instanceof OperationAbortedError && { aborted: true }), + }), + ) + return info.created ? { ...result, workspace: 'new' } : result + }) as ToolOptions['execute'], }) } - override async getTools(context?: ReadonlyContext): Promise { - if (context) { - const error = this.admissionError(context.invocationContext) - if (error) throw new Error(error) - } + override async getTools(): Promise { return [...this.tools] } - override async close(): Promise { - // Invalidate before awaiting cleanup: ADK close() has no invocation context. - if (this.activeInvocationId !== undefined) { - for (const session of this.activeSessions) { - const closed = this.closedInvocations.get(session) ?? new Set() - closed.add(this.activeInvocationId) - this.closedInvocations.set(session, closed) - } - } - this.activeSessions.clear() - this.owner = undefined - this.activeInvocationId = undefined - await this.manager.close() + /** + * ADK calls this after every run without saying which one ended, so it + * releases nothing. Use destroySession() or destroyAll(). + */ + override async close(): Promise {} + + /** Kill the session's sandbox. Later calls in that session start a new workspace. */ + async destroySession(session: SessionRef): Promise { + const id = key(session) + const workspace = this.workspaces.get(id) + if (!workspace) return + await workspace.destroy() + // Forget it only if no newer call has started using it meanwhile. + if (workspace.idle && this.workspaces.get(id) === workspace) + this.workspaces.delete(id) + } + + /** Destroy every workspace, continuing past failures; failed ones stay retryable. */ + async destroyAll(): Promise { + const results = await Promise.allSettled( + [...this.workspaces.values()].map((workspace) => workspace.destroy()), + ) + for (const [id, workspace] of this.workspaces) + if (workspace.idle) this.workspaces.delete(id) + const failed = results.filter((r) => r.status === 'rejected').length + if (failed) + throw new SandboxCleanupError( + `${failed} workspace(s) could not be destroyed; retry destroyAll().`, + ) } } diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..caddc4b --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,376 @@ +import { + CommandExitError, + Sandbox, + SandboxNotFoundError, + TimeoutError, + type CommandHandle, + type CommandResult, + type Context, + type Execution, +} from '@e2b/code-interpreter' +import type { E2BSandboxOptions, E2BToolsetOptions } from './options.js' +import { + OperationAbortedError, + SandboxCleanupError, + WorkspaceBlockedError, + WorkspaceDestroyedError, + WorkspaceExpiredError, +} from './results.js' + +/** Upper bound for cleanup and recovery calls, independent of operation limits. */ +const RECOVERY_WAIT_MS = 30_000 +/** Lets our own deadline fire before the SDK's, so one path handles both. */ +const SDK_GRACE_MS = 5_000 + +export type Stop = 'timed_out' | 'aborted' +export type CodeOutcome = { execution: Execution } | { stopped: Stop } +export type CommandOutcome = + { result: CommandResult } | { stopped: Stop; stdout: string; stderr: string } + +interface Current { + sandbox: Sandbox + contexts: Map> +} + +/** Set when an operation created the sandbox, so its result can say so. */ +export interface RunInfo { + created: boolean +} + +interface RunOptions { + signal?: AbortSignal + timeoutMs: number +} + +/** Validate creation options and return the per-operation execution limit. */ +export function operationLimit(options: E2BToolsetOptions): number { + const ttl = options.sandboxOptions?.timeoutMs ?? 300_000 + if (!Number.isFinite(ttl) || ttl <= 0) + throw new Error( + 'sandboxOptions.timeoutMs must be a positive finite number.', + ) + const max = options.maxOperationMs ?? 600_000 + if (!Number.isSafeInteger(max) || max <= 0) + throw new Error('maxOperationMs must be a positive integer.') + return max +} + +const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'` + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new TimeoutError('Recovery timed out')), + ms, + ) + }), + ]).finally(() => clearTimeout(timer)) +} + +/** Wait for `previous`, unless the caller cancels first. */ +function turn(previous: Promise, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new OperationAbortedError()) + const abort = () => reject(new OperationAbortedError()) + signal?.addEventListener('abort', abort, { once: true }) + void previous.then(() => { + signal?.removeEventListener('abort', abort) + resolve() + }) + }) +} + +/** Aborts on the caller's signal or at the deadline, whichever comes first. */ +function stopper(signal: AbortSignal | undefined, timeoutMs: number) { + let reason: Stop | undefined + let reject!: (error: Error) => void + const stopped = new Promise((_, fail) => (reject = fail)) + stopped.catch(() => undefined) + const stop = (why: Stop) => { + reason ??= why + reject(new OperationAbortedError()) + } + const abort = () => stop('aborted') + const timer = setTimeout(() => stop('timed_out'), timeoutMs) + if (signal?.aborted) abort() + else signal?.addEventListener('abort', abort, { once: true }) + return { + /** Resolves with `promise`, or rejects once stopped. */ + race: (promise: Promise) => Promise.race([promise, stopped]), + get reason() { + return reason + }, + dispose() { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + }, + } +} + +/** + * One lazily created, serialized sandbox for one ADK session. An expired or + * destroyed sandbox is never silently replaced inside a running operation. + */ +export class Workspace { + private current?: Current + private queue: Promise = Promise.resolve() + private epoch = 0 + private pending = 0 + private destroying?: Promise + private blocked?: Error + private readonly ttl: number + + constructor( + private readonly options: E2BSandboxOptions, + private readonly cleanupHint: string, + ) { + // The cloud default is five minutes. Do not override creation defaults. + this.ttl = options.sandboxOptions?.timeoutMs ?? 300_000 + } + + /** No sandbox, queued work or cleanup: safe to forget. */ + get idle(): boolean { + return this.pending === 0 && !this.current && !this.destroying + } + + run( + operation: (sandbox: Sandbox, current: Current) => Promise, + info: RunInfo, + signal?: AbortSignal, + ): Promise { + if (this.destroying) return Promise.reject(new WorkspaceDestroyedError()) + const epoch = this.epoch + const previous = this.queue + this.pending++ + const result = turn(previous, signal).then(() => + this.serve(operation, info, epoch, signal), + ) + // A cancelled waiter must not let the next operation overtake the active one. + this.queue = Promise.allSettled([previous, result]) + return result.finally(() => this.pending--) + } + + private async serve( + operation: (sandbox: Sandbox, current: Current) => Promise, + info: RunInfo, + epoch: number, + signal?: AbortSignal, + ): Promise { + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + if (this.blocked) throw this.blocked + if (signal?.aborted) throw new OperationAbortedError() + let current = this.current + if (current) { + try { + await current.sandbox.setTimeout(this.ttl) + } catch (error) { + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + // A paused sandbox also reports 404 here; connect() resumes it. + // Only a sandbox that cannot be resumed is gone. + if ( + error instanceof SandboxNotFoundError && + !(await this.resume(current)) + ) { + if (this.current === current) this.current = undefined + throw new WorkspaceExpiredError() + } + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + } + } else { + const sandbox = this.options.template + ? await Sandbox.create( + this.options.template, + this.options.sandboxOptions, + ) + : await Sandbox.create(this.options.sandboxOptions) + if (epoch !== this.epoch) { + // ponytail: best effort; if this kill fails, the sandbox TTL releases it. + await withTimeout(sandbox.kill(), RECOVERY_WAIT_MS).catch( + () => undefined, + ) + throw new WorkspaceDestroyedError() + } + current = this.current = { sandbox, contexts: new Map() } + info.created = true + } + const { sandbox } = current + const refresh = () => + sandbox.setTimeout(this.ttl).catch(() => { + // Best-effort maintenance must not replace an operation's result. + }) + let stopped = false + let timer: ReturnType | undefined + let pending: Promise = Promise.resolve() + const schedule = () => { + timer = setTimeout( + () => { + pending = refresh().then(() => { + if (!stopped) schedule() + }) + }, + Math.max(100, this.ttl / 2), + ) + timer.unref() + } + schedule() + try { + return await operation(sandbox, current) + } catch (error) { + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + throw error + } finally { + stopped = true + clearTimeout(timer) + await pending + if (epoch === this.epoch) await refresh() + } + } + + runCode( + code: string, + language: string, + envs: Record | undefined, + info: RunInfo, + { signal, timeoutMs }: RunOptions, + ): Promise { + return this.run( + async (sandbox, current) => { + const context = await this.context(current, language) + if (signal?.aborted) throw new OperationAbortedError() + const stop = stopper(signal, timeoutMs) + const execution = sandbox.runCode(code, { + context, + envs, + timeoutMs: timeoutMs + SDK_GRACE_MS, + }) + execution.catch(() => undefined) + try { + return { execution: await stop.race(execution) } + } catch (error) { + const reason = + stop.reason ?? + (error instanceof TimeoutError ? 'timed_out' : undefined) + if (!reason) throw error + // A timed-out kernel stays busy; only a restart stops the code. + try { + await withTimeout( + sandbox.restartCodeContext(context), + RECOVERY_WAIT_MS, + ) + } catch { + const error = new WorkspaceBlockedError( + `The ${language} kernel could not be restarted after the code was stopped. ${this.cleanupHint}`, + ) + if (this.current === current) this.blocked = error + throw error + } + // The restart closes the open stream; don't let it outlive this turn. + await withTimeout(execution, RECOVERY_WAIT_MS).catch(() => undefined) + return { stopped: reason } + } finally { + stop.dispose() + } + }, + info, + signal, + ) + } + + runCommand( + command: string, + options: { cwd?: string; envs?: Record }, + info: RunInfo, + { signal, timeoutMs }: RunOptions, + ): Promise { + return this.run( + async (sandbox) => { + // Own process group, so stopping the command also stops its children. + // No abort signal here: aborting the start request leaves the process running. + const handle = await sandbox.commands.run( + `exec setsid -w /bin/bash -c ${quote(command)}`, + { ...options, background: true, timeoutMs: 0 }, + ) + const stop = stopper(signal, timeoutMs) + try { + return { result: await stop.race(handle.wait()) } + } catch (error) { + if (error instanceof CommandExitError && !stop.reason) + return { result: error } + if (!stop.reason) throw error + await this.killGroup(sandbox, handle) + return { + stopped: stop.reason, + stdout: handle.stdout, + stderr: handle.stderr, + } + } finally { + stop.dispose() + } + }, + info, + signal, + ) + } + + private async killGroup(sandbox: Sandbox, handle: CommandHandle) { + try { + await withTimeout( + sandbox.commands.run(`kill -9 -- -${handle.pid}`), + RECOVERY_WAIT_MS, + ) + } catch { + // The group may be gone already; still make sure the leader is. + await withTimeout(handle.kill(), RECOVERY_WAIT_MS) + } + await withTimeout( + handle.wait().catch(() => undefined), + RECOVERY_WAIT_MS, + ) + } + + private async resume(current: Current): Promise { + try { + await current.sandbox.connect({ timeoutMs: this.ttl }) + return true + } catch (error) { + if (error instanceof SandboxNotFoundError) return false + throw error + } + } + + private context(current: Current, language: string): Promise { + let context = current.contexts.get(language) + if (!context) { + context = current.sandbox.createCodeContext({ language }) + current.contexts.set(language, context) + context.catch(() => current.contexts.delete(language)) + } + return context + } + + /** Kill the sandbox now; queued and later-started work never recreates it. */ + destroy(): Promise { + this.destroying ??= this.kill().finally(() => { + this.destroying = undefined + }) + return this.destroying + } + + private async kill() { + this.epoch++ + const current = this.current + if (!current) return + try { + await withTimeout(current.sandbox.kill(), RECOVERY_WAIT_MS) + } catch { + // Retain the handle so a later destroy can retry; block reuse meanwhile. + this.blocked = new SandboxCleanupError(this.cleanupHint) + throw this.blocked + } + if (this.current === current) this.current = undefined + this.blocked = undefined + } +} diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 791b9de..c28c221 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { CodeExecutionLanguage as Language } from '@google/adk' +import { TimeoutError } from '@e2b/code-interpreter' import { E2BCodeExecutor } from '../src/index.js' import { context, execution, mockSandbox } from './helpers.js' @@ -18,7 +19,11 @@ describe('code executor', () => { codeExecutionInput: { code: 'code', language, inputFiles: [] }, }), ).toEqual({ stdout: 'hello\n\n42', stderr: '', outputFiles: [] }) - expect(sandbox.runCode).toHaveBeenCalledWith('code', { language: expected }) + expect(sandbox.runCode).toHaveBeenCalledWith('code', { + context: { id: `ctx-${expected}`, language: expected, cwd: '/home/user' }, + envs: undefined, + timeoutMs: 65_000, + }) await executor.close() }) @@ -93,6 +98,33 @@ describe('code executor', () => { await executor.close() await executor.close() expect(sandbox.kill).toHaveBeenCalledTimes(1) + // close() is not terminal: a later call starts a new workspace. + await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: input, + }) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('bounds execution and restarts the kernel after a timeout', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new TimeoutError('timed out')) + const executor = new E2BCodeExecutor({ maxOperationMs: 5000 }) + const result = await executor.executeCode({ + invocationContext: context(), + codeExecutionInput: { + code: 'x', + language: Language.PYTHON, + inputFiles: [], + }, + }) + expect(result.stderr).toBe( + 'Execution exceeded 5000 ms. The kernel was restarted; its variables and imports were lost.', + ) + expect(sandbox.runCode.mock.calls[0]![1]).toMatchObject({ + timeoutMs: 10_000, + }) + expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) }) it('normalizes runtime errors and truncates all output fields', async () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 3e318f4..ba298e3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -4,16 +4,26 @@ import { InvocationContext, PluginManager, BaseLlm, + type Event, type LlmRequest, type LlmResponse, type BaseLlmConnection, } from '@google/adk' -import { Sandbox, type Execution } from '@e2b/code-interpreter' +import { + FileNotFoundError, + Sandbox, + type Execution, +} from '@e2b/code-interpreter' -export function context(id = 'session', userId = 'user') { +export function context( + id = 'session', + userId = 'user', + abortSignal?: AbortSignal, +) { return new InvocationContext({ invocationId: 'invocation', pluginManager: new PluginManager(), + abortSignal, session: { id, userId, @@ -25,8 +35,8 @@ export function context(id = 'session', userId = 'user') { }) } -export function toolContext() { - return new Context({ invocationContext: context() }) +export function toolContext(invocation = context()) { + return new Context({ invocationContext: invocation }) } export function execution(overrides: Partial = {}): Execution { @@ -38,12 +48,37 @@ export function execution(overrides: Partial = {}): Execution { } as Execution } +/** A background CommandHandle double. */ +export function commandHandle( + result = { stdout: 'ok', stderr: '', exitCode: 0 }, +) { + return { + pid: 101, + stdout: '', + stderr: '', + wait: vi.fn().mockResolvedValue(result), + kill: vi.fn().mockResolvedValue(true), + disconnect: vi.fn().mockResolvedValue(undefined), + } +} + export function fakeSandbox() { + const handle = commandHandle() return { sandboxId: 'fake-sandbox', + handle, runCode: vi.fn().mockResolvedValue(execution()), + createCodeContext: vi.fn(async ({ language }: { language: string }) => ({ + id: `ctx-${language}`, + language, + cwd: '/home/user', + })), + restartCodeContext: vi.fn().mockResolvedValue(undefined), commands: { - run: vi.fn().mockResolvedValue({ stdout: 'ok', stderr: '', exitCode: 0 }), + // Background starts return the handle; foreground calls are kill commands. + run: vi.fn(async (_command: string, opts?: { background?: boolean }) => + opts?.background ? handle : { stdout: '', stderr: '', exitCode: 0 }, + ), }, files: { write: vi.fn().mockResolvedValue({ path: '/home/user/test.txt' }), @@ -52,10 +87,15 @@ export function fakeSandbox() { }, getHost: vi.fn().mockReturnValue('3000-fake.e2b.app'), setTimeout: vi.fn().mockResolvedValue(undefined), + connect: vi.fn(async function (this: unknown) { + return this + }), kill: vi.fn().mockResolvedValue(true), } } +export type FakeSandbox = ReturnType + export function mockSandbox() { const sandbox = fakeSandbox() const create = vi @@ -68,6 +108,33 @@ export function mockSandbox() { return { sandbox, create } } +/** Every create returns a new sandbox with its own files, lost on kill. */ +export function statefulSandboxes( + create: ReturnType['create'], +) { + const boxes: Array }> = [] + create.mockImplementation(async () => { + const box = Object.assign(fakeSandbox(), { + data: new Map(), + }) + box.files.write.mockImplementation(async (path: string, value: string) => { + box.data.set(path, value) + return { path } + }) + box.files.read.mockImplementation(async (path: string) => { + if (!box.data.has(path)) throw new FileNotFoundError(`${path} missing`) + return box.data.get(path) + }) + box.kill.mockImplementation(async () => { + box.data.clear() + return true + }) + boxes.push(box) + return box as unknown as Sandbox + }) + return boxes +} + export function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void @@ -78,6 +145,8 @@ export function deferred() { return { promise, resolve, reject } } +export const tick = () => new Promise((resolve) => setImmediate(resolve)) + export class ScriptedModel extends BaseLlm { readonly requests: LlmRequest[] = [] private index = 0 @@ -98,3 +167,53 @@ export class ScriptedModel extends BaseLlm { throw new Error('Live streaming is not used') } } + +export type PlannedCall = { name: string; args: Record } + +/** + * Deterministic under concurrency: the latest user text is a JSON list of tool + * calls, issued one per model turn, then "done". + */ +export class PlanModel extends BaseLlm { + constructor() { + super({ model: 'plan' }) + } + override async *generateContentAsync(request: LlmRequest) { + const parts = request.contents.flatMap((c) => + (c.parts ?? []).map((p) => ({ role: c.role, p })), + ) + let start = parts.length - 1 + while (!(parts[start]!.role === 'user' && parts[start]!.p.text)) start-- + const plan = JSON.parse(parts[start]!.p.text!) as PlannedCall[] + const done = parts.slice(start).filter((x) => x.p.functionResponse).length + const next = plan[done] + yield next + ? { + content: { + role: 'model', + parts: [{ functionCall: { id: `call-${done}`, ...next } }], + }, + } + : { content: { role: 'model', parts: [{ text: 'done' }] } } + } + override async connect(): Promise { + throw new Error('Live streaming is not used') + } +} + +export const plan = (...calls: PlannedCall[]) => ({ + role: 'user', + parts: [{ text: JSON.stringify(calls) }], +}) + +export async function drain(stream: AsyncGenerator) { + const events: Event[] = [] + for await (const event of stream) events.push(event) + return events +} + +export const toolResponses = (events: Event[]) => + events + .flatMap((e) => e.content?.parts ?? []) + .filter((p) => p.functionResponse) + .map((p) => p.functionResponse!.response as Record) diff --git a/tests/isolation.test.ts b/tests/isolation.test.ts index 789b74a..7531aac 100644 --- a/tests/isolation.test.ts +++ b/tests/isolation.test.ts @@ -1,235 +1,325 @@ import { describe, expect, it } from 'vitest' -import type { Sandbox } from '@e2b/code-interpreter' import { - Context, + FunctionTool, InMemoryRunner, + InvocationContext, LlmAgent, - type Event, + PluginManager, type LlmResponse, } from '@google/adk' +import { z } from 'zod' import { E2BToolset } from '../src/index.js' -import { context, mockSandbox, ScriptedModel } from './helpers.js' +import { + deferred, + drain, + mockSandbox, + plan, + PlanModel, + ScriptedModel, + statefulSandboxes, + tick, + toolContext, + toolResponses, +} from './helpers.js' -const done: LlmResponse = { - content: { role: 'model', parts: [{ text: 'done' }] }, -} -const call = (name: string, args: Record): LlmResponse => ({ - content: { role: 'model', parts: [{ functionCall: { name, args } }] }, +const write = (content: string) => ({ + name: 'write_file', + args: { path: '/home/user/data.txt', content }, }) -const write = call('write_file', { path: '/secret.txt', content: 'alice-data' }) -const read = call('read_file', { path: '/secret.txt' }) -const message = { role: 'user', parts: [{ text: 'test' }] } -function runner(toolset: E2BToolset, responses: LlmResponse[]) { - return new InMemoryRunner({ +const read = { name: 'read_file', args: { path: '/home/user/data.txt' } } + +/** The adk web / api_server pattern: one module-level agent for every user. */ +function app(toolset = new E2BToolset()) { + const runner = new InMemoryRunner({ agent: new LlmAgent({ name: 'agent', - model: new ScriptedModel(responses), + model: new PlanModel(), tools: [toolset], }), }) -} -async function response(stream: AsyncGenerator) { - for (;;) { - const event = await stream.next() - if (event.done) throw new Error('Missing tool response') - const result = event.value.content?.parts?.find((p) => p.functionResponse) - ?.functionResponse?.response - if (result) return result - } -} -async function drain(stream: AsyncGenerator) { - const events: Event[] = [] - for await (const event of stream) events.push(event) - return events -} - -describe('invocation isolation', () => { - it('allows sequential invocations in the same session with fresh workspaces', async () => { - const { create, sandbox } = mockSandbox() - const app = runner(new E2BToolset(), [write, done, write, done]) - const session = await app.sessionService.createSession({ - appName: app.appName, - userId: 'alice', - }) - for (let i = 0; i < 2; i++) { + const session = (userId: string) => + runner.sessionService.createSession({ appName: runner.appName, userId }) + const ask = async ( + s: { id: string; userId: string }, + message: ReturnType, + ) => + toolResponses( await drain( - app.runAsync({ - userId: 'alice', - sessionId: session.id, + runner.runAsync({ + userId: s.userId, + sessionId: s.id, newMessage: message, }), - ) - } - expect(create).toHaveBeenCalledTimes(2) - expect(sandbox.kill).toHaveBeenCalledTimes(2) - }) + ), + ) + return { runner, toolset, session, ask } +} - it.each([false, true])( - 'fails closed for overlapping runs (same session: %s)', - async (sameSession) => { - const { sandbox, create } = mockSandbox() - const toolset = new E2BToolset() - const app = runner(toolset, [write, read, done]) - const session = await app.sessionService.createSession({ - appName: app.appName, - userId: 'alice', - }) - const alice = app.runAsync({ - userId: 'alice', - sessionId: session.id, - newMessage: message, - }) - expect((await response(alice)).success).toBe(true) - const bob = sameSession - ? app.runAsync({ - userId: 'alice', - sessionId: session.id, - newMessage: message, - }) - : app.runEphemeral({ userId: 'bob', newMessage: message }) - // Discovery rejects the second invocation even if it never calls a tool. - await expect(drain(bob)).rejects.toThrow('overlapping invocations') - expect(sandbox.files.read).not.toHaveBeenCalled() - expect(sandbox.kill).toHaveBeenCalledTimes(1) - await expect(drain(alice)).rejects.toThrow('overlapping invocations') - expect(create).toHaveBeenCalledTimes(1) - await toolset.close() - await expect( - drain(app.runEphemeral({ userId: 'later', newMessage: message })), - ).rejects.toThrow('overlapping invocations') - expect(create).toHaveBeenCalledTimes(1) - }, +function invocation(sessionId: string, invocationId: string) { + return toolContext( + new InvocationContext({ + invocationId, + pluginManager: new PluginManager(), + session: { + id: sessionId, + userId: 'user', + appName: 'test', + state: {}, + events: [], + lastUpdateTime: 0, + }, + }), ) +} - it('does not expose a previous user workspace after Runner swallows a failed kill', async () => { - const { sandbox, create } = mockSandbox() - sandbox.kill.mockRejectedValueOnce(new Error('secret SDK request')) - const toolset = new E2BToolset() - const app = runner(toolset, [write, done, read, done, write, done]) - await drain(app.runEphemeral({ userId: 'alice', newMessage: message })) - const bob = app.runEphemeral({ userId: 'bob', newMessage: message }) - expect(await response(bob)).toMatchObject({ +describe('session-scoped workspaces', () => { + it('gives concurrent sessions of a module-level agent separate sandboxes, and later sessions still work', async () => { + const { create } = mockSandbox() + const boxes = statefulSandboxes(create) + const { session, ask } = app() + const [alice, bob] = await Promise.all([session('alice'), session('bob')]) + const [a, b] = await Promise.all([ + ask(alice, plan(write('alice-data'), read)), + ask(bob, plan(write('bob-data'), read)), + ]) + expect(a[1]).toMatchObject({ success: true, content: 'alice-data' }) + expect(b[1]).toMatchObject({ success: true, content: 'bob-data' }) + expect(create).toHaveBeenCalledTimes(2) + const carol = await session('carol') + expect((await ask(carol, plan(read)))[0]).toEqual({ success: false, - error: expect.stringContaining('Retry close()'), + error: 'Sandbox operation failed (FileNotFoundError).', + workspace: 'new', }) - expect(sandbox.files.read).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(3) + // ADK closed the toolset after each run; nothing was killed. + expect(boxes.map((box) => box.kill.mock.calls.length)).toEqual([0, 0, 0]) + }) + + it('keeps files and kernels across messages of one session', async () => { + const { create, sandbox } = mockSandbox() + const { session, ask } = app() + const s = await session('alice') + const first = await ask( + s, + plan(write('x'), { name: 'run_code', args: { code: 'n = 1' } }), + ) + expect(first[0]).toMatchObject({ workspace: 'new' }) + const second = await ask( + s, + plan(read, { name: 'run_code', args: { code: 'n' } }), + ) + expect(second.every((r) => r.success && !('workspace' in r))).toBe(true) expect(create).toHaveBeenCalledTimes(1) - await drain(bob) // Cleanup retries the retained handle successfully. - expect(sandbox.kill).toHaveBeenCalledTimes(2) - await drain(app.runEphemeral({ userId: 'carol', newMessage: message })) - expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) + expect(sandbox.kill).not.toHaveBeenCalled() }) - it('does not reopen after an unrelated runner closes the toolset without discovering tools', async () => { - const { sandbox, create } = mockSandbox() - const toolset = new E2BToolset() - const aliceRunner = runner(toolset, [write, read, done]) - const alice = aliceRunner.runEphemeral({ - userId: 'alice', - newMessage: message, + it('keeps the workspace through a tool-confirmation pause', async () => { + const { create } = mockSandbox() + statefulSandboxes(create) + const deploy = new FunctionTool({ + name: 'deploy', + description: 'deploy', + parameters: z.object({}), + requireConfirmation: true, + execute: async () => ({ ok: true }), }) - await response(alice) - const bobRunner = new InMemoryRunner({ + const call = ( + name: string, + args: Record, + id: string, + ): LlmResponse => ({ + content: { role: 'model', parts: [{ functionCall: { name, args, id } }] }, + }) + const done: LlmResponse = { + content: { role: 'model', parts: [{ text: 'done' }] }, + } + const runner = new InMemoryRunner({ agent: new LlmAgent({ - name: 'short_circuit', - model: new ScriptedModel([]), - tools: [toolset], - beforeAgentCallback: () => done.content, + name: 'agent', + model: new ScriptedModel([ + call('write_file', write('artifact').args, 'w1'), + call('deploy', {}, 'd1'), + call('read_file', read.args, 'r1'), + done, + ]), + tools: [new E2BToolset(), deploy], + }), + }) + const s = await runner.sessionService.createSession({ + appName: runner.appName, + userId: 'u', + }) + const first = await drain( + runner.runAsync({ userId: 'u', sessionId: s.id, newMessage: plan() }), + ) + const confirm = first + .flatMap((e) => e.content?.parts ?? []) + .find( + (p) => p.functionCall?.name === 'adk_request_confirmation', + )?.functionCall + expect(confirm).toBeDefined() + const second = await drain( + runner.runAsync({ + userId: 'u', + sessionId: s.id, + newMessage: { + role: 'user', + parts: [ + { + functionResponse: { + id: confirm!.id, + name: confirm!.name, + response: { confirmed: true }, + }, + }, + ], + }, }), + ) + expect(toolResponses(second)).toContainEqual({ + success: true, + path: '/home/user/data.txt', + content: 'artifact', + }) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('serializes overlapping invocations of one session in one shared sandbox', async () => { + const { create, sandbox } = mockSandbox() + let active = 0 + let overlap = false + sandbox.files.write.mockImplementation(async () => { + overlap ||= ++active > 1 + await tick() + active-- + return { path: 'x' } }) - await drain(bobRunner.runEphemeral({ userId: 'bob', newMessage: message })) - expect(sandbox.kill).toHaveBeenCalledTimes(1) - await expect(drain(alice)).rejects.toThrow('was closed') - expect(sandbox.files.read).not.toHaveBeenCalled() + const toolset = new E2BToolset() + const tool = (await toolset.getTools())[2]! + const results = await Promise.all( + ['inv-1', 'inv-2', 'inv-1', 'inv-2'].map((id, i) => + tool.runAsync({ + args: { path: `/f${i}`, content: 'x' }, + toolContext: invocation('shared', id), + }), + ), + ) + expect(results.every((r) => (r as { success: boolean }).success)).toBe(true) + expect(overlap).toBe(false) expect(create).toHaveBeenCalledTimes(1) }) - it('rejects late calls and cloned contexts after close without reopening or disturbing a newer run', async () => { - const { sandbox, create } = mockSandbox() + it('a late ADK close() from another run does not disturb active work', async () => { + const { sandbox } = mockSandbox() + const slow = deferred() + sandbox.files.read.mockReturnValue(slow.promise) const toolset = new E2BToolset() - const tools = await toolset.getTools() - const readTool = tools.find((t) => t.name === 'read_file')! - const alice = context('alice') - const invoke = (ctx: typeof alice) => - readTool.runAsync({ - args: { path: '/secret.txt' }, - toolContext: new Context({ invocationContext: ctx }), - }) - await invoke(alice) - await toolset.close() - await invoke(context('bob')) + const tool = (await toolset.getTools())[3]! + const active = tool.runAsync({ + args: { path: '/a' }, + toolContext: invocation('s', 'new'), + }) + await tick() await toolset.close() - expect(await invoke(alice.clone())).toMatchObject({ + slow.resolve('still here') + expect(await active).toMatchObject({ success: true, content: 'still here' }) + expect(sandbox.kill).not.toHaveBeenCalled() + }) + + it('destroySession releases only that session; later messages start a new workspace', async () => { + const { create } = mockSandbox() + const boxes = statefulSandboxes(create) + const { session, ask, toolset } = app() + const [alice, bob] = await Promise.all([session('alice'), session('bob')]) + await ask(alice, plan(write('a'))) + await ask(bob, plan(write('b'))) + await toolset.destroySession(alice) + expect(boxes[0]!.kill).toHaveBeenCalledTimes(1) + expect(boxes[1]!.kill).not.toHaveBeenCalled() + await toolset.destroySession(alice) + expect(boxes[0]!.kill).toHaveBeenCalledTimes(1) + expect((await ask(alice, plan(read)))[0]).toMatchObject({ success: false, - error: expect.stringContaining('was closed'), + workspace: 'new', }) - expect(create).toHaveBeenCalledTimes(2) - expect(sandbox.files.read).toHaveBeenCalledTimes(2) + expect((await ask(bob, plan(read)))[0]).toMatchObject({ content: 'b' }) }) - it('guards individual tools even when getTools is called without a context', async () => { + it('destroySession fails queued work without recreating, and blocks until a failed kill is retried', async () => { const { create, sandbox } = mockSandbox() const toolset = new E2BToolset() - const readTool = (await toolset.getTools()).find( - (t) => t.name === 'read_file', - )! - const invoke = (user: string) => - readTool.runAsync({ - args: { path: '/secret.txt' }, - toolContext: new Context({ invocationContext: context(user, user) }), - }) - expect(await invoke('alice')).toMatchObject({ success: true }) - expect(await invoke('bob')).toMatchObject({ + const [, , writeTool, readTool] = await toolset.getTools() + const ref = { appName: 'test', userId: 'user', id: 's' } + await writeTool!.runAsync({ + args: { path: '/a', content: 'x' }, + toolContext: invocation('s', 'i'), + }) + const slow = deferred() + sandbox.files.read.mockReturnValueOnce(slow.promise) + const running = readTool!.runAsync({ + args: { path: '/a' }, + toolContext: invocation('s', 'i'), + }) + const queued = readTool!.runAsync({ + args: { path: '/a' }, + toolContext: invocation('s', 'j'), + }) + await tick() + sandbox.kill.mockRejectedValueOnce(new Error('down')) + await expect(toolset.destroySession(ref)).rejects.toThrow( + 'retry destroySession()', + ) + slow.reject(new Error('sandbox killed')) + expect(await running).toMatchObject({ success: false, - error: expect.stringContaining('overlapping invocations'), + error: expect.stringContaining('released'), + }) + expect(await queued).toMatchObject({ + success: false, + error: expect.stringContaining('released'), + }) + expect( + await readTool!.runAsync({ + args: { path: '/a' }, + toolContext: invocation('s', 'k'), + }), + ).toMatchObject({ + success: false, + error: expect.stringContaining('cleanup failed'), }) - expect(await invoke('alice')).toMatchObject({ success: false }) - expect(sandbox.files.read).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1) - await toolset.close() + await toolset.destroySession(ref) + expect( + await readTool!.runAsync({ + args: { path: '/a' }, + toolContext: invocation('s', 'k'), + }), + ).toMatchObject({ success: true, workspace: 'new' }) }) - it('keeps separate concurrent instances independent', async () => { - const { sandbox, create } = mockSandbox() - // Separate in-memory SDK workspaces make data isolation observable. - create.mockImplementation(async () => { - const files = new Map() - let killed = false - return { - ...sandbox, - files: { - ...sandbox.files, - write: async (path: string, value: string) => { - expect(killed).toBe(false) - files.set(path, value) - }, - read: async (path: string) => { - expect(killed).toBe(false) - if (!files.has(path)) throw new Error('Not found') - return files.get(path)! - }, - }, - kill: async () => { - killed = true - return true - }, - } as unknown as Sandbox - }) - const aliceRunner = runner(new E2BToolset(), [write, read, done]) - const bobRunner = runner(new E2BToolset(), [read, done]) - const alice = aliceRunner.runEphemeral({ - userId: 'alice', - newMessage: message, - }) - await response(alice) - const bob = bobRunner.runEphemeral({ userId: 'bob', newMessage: message }) - expect((await response(bob)).success).toBe(false) - await drain(bob) - expect(await response(alice)).toMatchObject({ - success: true, - content: 'alice-data', - }) - await drain(alice) - expect(create).toHaveBeenCalledTimes(2) + it('destroyAll handles concurrent calls and partial failures', async () => { + const { create } = mockSandbox() + const boxes = statefulSandboxes(create) + const toolset = new E2BToolset() + const writeTool = (await toolset.getTools())[2]! + for (const id of ['a', 'b', 'c']) + await writeTool.runAsync({ + args: { path: '/x', content: id }, + toolContext: invocation(id, 'i'), + }) + boxes[1]!.kill.mockRejectedValueOnce(new Error('down')) + const [first, second] = await Promise.allSettled([ + toolset.destroyAll(), + toolset.destroyAll(), + ]) + expect(first.status).toBe('rejected') + expect(second.status).toBe('rejected') + expect(boxes.map((box) => box.kill.mock.calls.length)).toEqual([1, 1, 1]) + await toolset.destroyAll() + expect(boxes.map((box) => box.kill.mock.calls.length)).toEqual([1, 2, 1]) + await toolset.destroyAll() + expect(boxes.map((box) => box.kill.mock.calls.length)).toEqual([1, 2, 1]) }) }) diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts deleted file mode 100644 index 2e9b8be..0000000 --- a/tests/lifecycle.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Sandbox } from '@e2b/code-interpreter' -import { SandboxManager } from '../src/sandbox-manager.js' -import { deferred, mockSandbox } from './helpers.js' - -afterEach(() => vi.useRealTimers()) - -describe('workspace lifecycle', () => { - it('is lazy and forwards only supplied SDK options', async () => { - const { create, sandbox } = mockSandbox() - const manager = new SandboxManager({ - template: 'custom', - sandboxOptions: { - timeoutMs: 1200, - allowInternetAccess: false, - envs: { TEST: 'value' }, - }, - }) - expect(create).not.toHaveBeenCalled() - await manager.run(async () => 1) - expect(create).toHaveBeenCalledWith('custom', { - timeoutMs: 1200, - allowInternetAccess: false, - envs: { TEST: 'value' }, - }) - expect(sandbox.setTimeout).toHaveBeenCalledWith(1200) - await manager.close() - }) - - it('serializes concurrent calls and closes after an in-flight create', async () => { - const { create, sandbox } = mockSandbox() - const pending = deferred() - create.mockReturnValue(pending.promise) - const manager = new SandboxManager() - const order: string[] = [] - const a = manager.run(async () => { - order.push('a') - }) - const b = manager.run(async () => { - order.push('b') - }) - const close = manager.close() - await Promise.resolve() - expect(create).toHaveBeenCalledTimes(1) - expect(sandbox.kill).not.toHaveBeenCalled() - pending.resolve(sandbox as unknown as Sandbox) - await Promise.all([a, b, close]) - expect(order).toEqual(['a', 'b']) - expect(sandbox.kill).toHaveBeenCalledTimes(1) - await manager.close() - expect(sandbox.kill).toHaveBeenCalledTimes(1) - await manager.run(async () => undefined) - expect(create).toHaveBeenCalledTimes(2) - await manager.close() - }) - - it('waits for active work before killing and rejects no queued cleanup', async () => { - const { sandbox } = mockSandbox() - const started = deferred() - const finish = deferred() - const manager = new SandboxManager() - const operation = manager.run(async () => { - started.resolve() - await finish.promise - }) - await started.promise - const close = manager.close() - expect(sandbox.kill).not.toHaveBeenCalled() - finish.resolve() - await Promise.all([operation, close]) - expect(sandbox.kill).toHaveBeenCalledTimes(1) - }) - - it('does not silently recreate an expired sandbox or lose a failed kill handle', async () => { - const { create, sandbox } = mockSandbox() - const manager = new SandboxManager() - await manager.run(async () => undefined) - sandbox.runCode.mockRejectedValue(new Error('Expired')) - await expect(manager.run((box) => box.runCode('1'))).rejects.toThrow( - 'Expired', - ) - await expect(manager.run((box) => box.runCode('2'))).rejects.toThrow( - 'Expired', - ) - expect(create).toHaveBeenCalledTimes(1) - sandbox.kill.mockRejectedValueOnce(new Error('Unavailable')) - await expect(manager.close()).rejects.toThrow('Retry close()') - const workAfterFailure = vi.fn() - const renewals = sandbox.setTimeout.mock.calls.length - await expect(manager.run(workAfterFailure)).rejects.toThrow('Retry close()') - expect(workAfterFailure).not.toHaveBeenCalled() - expect(sandbox.setTimeout).toHaveBeenCalledTimes(renewals) - expect(create).toHaveBeenCalledTimes(1) - await manager.close() - expect(sandbox.kill).toHaveBeenCalledTimes(2) - await manager.run(async () => undefined) - expect(create).toHaveBeenCalledTimes(2) - await manager.close() - }) - - it('can retry a failed creation', async () => { - const { create } = mockSandbox() - create.mockRejectedValueOnce(new Error('Unavailable')) - const manager = new SandboxManager() - await expect(manager.run(async () => undefined)).rejects.toThrow( - 'Unavailable', - ) - await manager.run(async () => undefined) - expect(create).toHaveBeenCalledTimes(2) - await manager.close() - }) - - it('refreshes throughout operations exceeding TTL and cancels the timer on errors', async () => { - vi.useFakeTimers() - const { sandbox } = mockSandbox() - const manager = new SandboxManager({ sandboxOptions: { timeoutMs: 1000 } }) - const started = deferred() - const finish = deferred() - const work = manager.run(async () => { - started.resolve() - return finish.promise - }) - await started.promise - expect(sandbox.setTimeout).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(2500) - expect(sandbox.setTimeout).toHaveBeenCalledTimes(6) - const rejected = expect(work).rejects.toThrow('Execution failed') - finish.reject(new Error('Execution failed')) - await rejected - expect(vi.getTimerCount()).toBe(0) - expect(sandbox.setTimeout).toHaveBeenCalledTimes(7) - await manager.close() - }) - - it('maintenance failures do not change a successful operation', async () => { - const { sandbox } = mockSandbox() - sandbox.setTimeout.mockRejectedValue(new Error('Transient')) - const manager = new SandboxManager() - expect(await manager.run(async () => 42)).toBe(42) - await manager.close() - }) -}) diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 339358f..711e5d6 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -33,7 +33,7 @@ async function collect(stream: AsyncGenerator) { } describe('real ADK runner integration', () => { - it('routes tool calls and closes the workspace after each invocation', async () => { + it('routes tool calls; ADK close() after each run keeps the session workspace', async () => { const { sandbox, create } = mockSandbox() const toolset = new E2BToolset() const model = new ScriptedModel([toolCall, done, toolCall, done]) @@ -44,28 +44,31 @@ describe('real ADK runner integration', () => { runner.runEphemeral({ userId: 'user', newMessage: message }), ) expect(sandbox.runCode).toHaveBeenCalledWith('6 * 7', { - language: 'python', + context: { id: 'ctx-python', language: 'python', cwd: '/home/user' }, envs: undefined, - timeoutMs: undefined, + timeoutMs: 65_000, }) expect( events .flatMap((e) => e.content?.parts ?? []) .some((p) => p.functionResponse?.name === 'run_code'), ).toBe(true) - expect(sandbox.kill).toHaveBeenCalledTimes(1) + // runEphemeral uses a new session each time, so each gets a workspace. await collect(runner.runEphemeral({ userId: 'user', newMessage: message })) expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.kill).not.toHaveBeenCalled() + await toolset.destroyAll() expect(sandbox.kill).toHaveBeenCalledTimes(2) }) - it('closes after a model error following tool execution', async () => { + it('a model error after tool execution leaves the workspace for the application to release', async () => { const { sandbox } = mockSandbox() + const toolset = new E2BToolset() const runner = new InMemoryRunner({ agent: new LlmAgent({ name: 'agent', model: new ScriptedModel([toolCall, new Error('Model failed')]), - tools: [new E2BToolset()], + tools: [toolset], }), }) const events = await collect( @@ -74,52 +77,59 @@ describe('real ADK runner integration', () => { expect(events.some((event) => event.errorMessage === 'Model failed')).toBe( true, ) + expect(sandbox.kill).not.toHaveBeenCalled() + await toolset.destroyAll() expect(sandbox.kill).toHaveBeenCalledTimes(1) }) - it('closes when the event consumer stops early', async () => { + it('forwards runner cancellation to a running command and kills its process group', async () => { const { sandbox } = mockSandbox() - const runner = new InMemoryRunner({ - agent: new LlmAgent({ - name: 'agent', - model: new ScriptedModel([toolCall, done]), - tools: [new E2BToolset()], - }), + let exit!: (error: Error) => void + sandbox.handle.wait.mockReturnValueOnce( + new Promise((_, reject) => (exit = reject)), + ) + sandbox.commands.run.mockImplementation(async (command, opts) => { + if (opts?.background) return sandbox.handle + if (command === 'kill -9 -- -101') exit(new Error('killed')) + return { stdout: '', stderr: '', exitCode: 0 } }) - for await (const event of runner.runEphemeral({ - userId: 'user', - newMessage: message, - })) { - if (event.content?.parts?.some((p) => p.functionResponse)) break - } - expect(sandbox.runCode).toHaveBeenCalledTimes(1) - expect(sandbox.kill).toHaveBeenCalledTimes(1) - }) - - it('closes on cancellation after tool execution', async () => { - const { sandbox } = mockSandbox() - const model = new ScriptedModel([toolCall, done]) + const model = new ScriptedModel([ + { + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'c', + name: 'run_command', + args: { command: 'sleep 600' }, + }, + }, + ], + }, + }, + done, + ]) const runner = new InMemoryRunner({ - agent: new LlmAgent({ - name: 'agent', - model, - tools: [new E2BToolset()], - }), + agent: new LlmAgent({ name: 'agent', model, tools: [new E2BToolset()] }), }) const session = await runner.sessionService.createSession({ appName: runner.appName, userId: 'user', }) const abort = new AbortController() - for await (const event of runner.runAsync({ - userId: 'user', - sessionId: session.id, - newMessage: message, - abortSignal: abort.signal, - })) { - if (event.content?.parts?.some((p) => p.functionResponse)) abort.abort() - } - expect(sandbox.kill).toHaveBeenCalledTimes(1) + const run = collect( + runner.runAsync({ + userId: 'user', + sessionId: session.id, + newMessage: message, + abortSignal: abort.signal, + }), + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + abort.abort() + await run + expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') expect(model.requests).toHaveLength(1) }) diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 5854a22..52beb97 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1,21 +1,27 @@ import { describe, expect, it, vi } from 'vitest' -import { CommandExitError } from '@e2b/code-interpreter' +import { + CommandExitError, + FileNotFoundError, + SandboxNotFoundError, +} from '@e2b/code-interpreter' +import { Context } from '@google/adk' import { E2BToolset } from '../src/index.js' -import { execution, mockSandbox, toolContext } from './helpers.js' +import { context, execution, mockSandbox, toolContext } from './helpers.js' async function invoke( toolset: E2BToolset, name: string, args: Record, + ctx = toolContext(), ) { const tool = (await toolset.getTools()).find((tool) => tool.name === name)! - return tool.runAsync({ args, toolContext: toolContext() }) + return tool.runAsync({ args, toolContext: ctx }) } describe('six tools', () => { it('exports declarations without creating a sandbox', async () => { const { create } = mockSandbox() - const tools = await new E2BToolset().getTools() + const tools = await new E2BToolset({ maxOperationMs: 90_000 }).getTools() expect(tools.map((t) => t.name)).toEqual([ 'run_code', 'run_command', @@ -25,11 +31,14 @@ describe('six tools', () => { 'start_background_command', ]) for (const t of tools) expect(t._getDeclaration()?.parameters).toBeDefined() + expect(JSON.stringify(tools[0]!._getDeclaration())).toContain( + '1 to 90000. Defaults to 60000', + ) expect(create).not.toHaveBeenCalled() }) it.each(['python', 'javascript', 'typescript', 'r', 'java', 'bash'])( - 'forwards %s and preserves runtime error semantics', + 'forwards %s through its code context and preserves runtime error semantics', async (language) => { const { sandbox } = mockSandbox() sandbox.runCode.mockResolvedValue( @@ -38,26 +47,26 @@ describe('six tools', () => { }), ) const toolset = new E2BToolset() - expect( - await invoke(toolset, 'run_code', { - code: 'code', - language, - timeoutMs: 1200, - envs: { X: '1' }, - }), - ).toEqual({ + const args = { code: 'code', language, timeoutMs: 1200, envs: { X: '1' } } + const expected = { success: true, stdout: 'hello\n', stderr: '', text: '42', error: 'ValueError: bad\ntrace', + } + expect(await invoke(toolset, 'run_code', args)).toEqual({ + ...expected, + workspace: 'new', }) + expect(await invoke(toolset, 'run_code', args)).toEqual(expected) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) + expect(sandbox.createCodeContext).toHaveBeenCalledWith({ language }) expect(sandbox.runCode).toHaveBeenCalledWith('code', { - language, - timeoutMs: 1200, + context: { id: `ctx-${language}`, language, cwd: '/home/user' }, + timeoutMs: 6200, envs: { X: '1' }, }) - await toolset.close() }, ) @@ -65,14 +74,20 @@ describe('six tools', () => { ['run_code', { code: 1 }], ['run_code', { code: 'x', language: 'cmd' }], ['run_code', { code: 'x', timeoutMs: -1 }], + ['run_code', { code: 'x', timeoutMs: 0 }], + ['run_code', { code: 'x', timeoutMs: 600_001 }], + ['run_code', { code: 'x', timeoutMs: 1.5 }], ['run_code', { code: 'x', envs: { A: 2 } }], ['run_command', { command: '' }], + ['run_command', { command: 'x', timeoutMs: 0 }], + ['run_command', { command: 'x', timeoutMs: 600_001 }], ['write_file', { path: 'x' }], ['read_file', { path: '' }], ['list_files', { path: 7 }], ['start_background_command', { command: 'x', port: 0 }], ['start_background_command', { command: 'x', port: 65536 }], ['start_background_command', { command: 'x', port: 1.5 }], + ['start_background_command', { command: 'x', timeoutMs: 0 }], ])('rejects invalid %s input before SDK effects', async (name, args) => { const { create } = mockSandbox() expect( @@ -85,12 +100,29 @@ describe('six tools', () => { expect(create).not.toHaveBeenCalled() }) + it('applies a custom maxOperationMs to the schema and the default limit', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset({ maxOperationMs: 1000 }) + expect( + await invoke(toolset, 'run_command', { command: 'x', timeoutMs: 1001 }), + ).toMatchObject({ success: false }) + expect(create).not.toHaveBeenCalled() + await invoke(toolset, 'run_code', { code: 'x' }) + expect(sandbox.runCode.mock.calls[0]![1]).toMatchObject({ timeoutMs: 6000 }) + expect(() => new E2BToolset({ maxOperationMs: 0 })).toThrow( + 'maxOperationMs', + ) + expect(() => new E2BToolset({ sandboxOptions: { timeoutMs: 0 } })).toThrow( + 'sandboxOptions.timeoutMs', + ) + }) + it('uses one workspace for file operations, including empty files', async () => { const { create, sandbox } = mockSandbox() const toolset = new E2BToolset() expect( await invoke(toolset, 'write_file', { path: 'a.txt', content: '' }), - ).toEqual({ success: true, path: 'a.txt' }) + ).toEqual({ success: true, path: 'a.txt', workspace: 'new' }) expect(sandbox.files.write).toHaveBeenCalledWith('a.txt', '') expect(await invoke(toolset, 'read_file', { path: 'a.txt' })).toEqual({ success: true, @@ -103,37 +135,96 @@ describe('six tools', () => { truncated: false, }) expect(create).toHaveBeenCalledTimes(1) - await toolset.close() }) - it('normalizes nonzero command exits as completed commands', async () => { + it('runs commands in a process group and normalizes nonzero exits', async () => { const { sandbox } = mockSandbox() - sandbox.commands.run.mockRejectedValue( - new CommandExitError({ - stdout: 'out', - stderr: 'err', - exitCode: 7, - error: 'failed', - }), - ) const toolset = new E2BToolset() expect( await invoke(toolset, 'run_command', { - command: 'exit 7', + command: 'echo ok', cwd: '/tmp', envs: { X: 'y' }, - timeoutMs: 0, + timeoutMs: 5000, }), - ).toEqual({ success: true, stdout: 'out', stderr: 'err', exit_code: 7 }) - expect(sandbox.commands.run).toHaveBeenCalledWith('exit 7', { - cwd: '/tmp', - envs: { X: 'y' }, - timeoutMs: 0, + ).toEqual({ + success: true, + stdout: 'ok', + stderr: '', + exit_code: 0, + workspace: 'new', }) - await toolset.close() + expect(sandbox.commands.run).toHaveBeenCalledWith( + "exec setsid -w /bin/bash -c 'echo ok'", + { cwd: '/tmp', envs: { X: 'y' }, background: true, timeoutMs: 0 }, + ) + sandbox.handle.wait.mockRejectedValueOnce( + new CommandExitError({ + stdout: 'out', + stderr: 'err', + exitCode: 7, + error: 'failed', + }), + ) + expect(await invoke(toolset, 'run_command', { command: 'exit 7' })).toEqual( + { success: true, stdout: 'out', stderr: 'err', exit_code: 7 }, + ) + }) + + it('reports stopped commands and code explicitly', async () => { + vi.useFakeTimers() + try { + const { sandbox } = mockSandbox() + const exited = deferred() + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + sandbox.commands.run.mockImplementation(async (command, opts) => { + if (opts?.background) return sandbox.handle + if (command.startsWith('kill')) exited.reject(new Error('killed')) + return { stdout: '', stderr: '', exitCode: 0 } + }) + const toolset = new E2BToolset() + const command = invoke(toolset, 'run_command', { + command: 'sleep 600', + timeoutMs: 1000, + }) + await vi.advanceTimersByTimeAsync(1000) + expect(await command).toEqual({ + success: false, + error: + 'Command timed out after 1000 ms; it and its child processes were killed.', + timed_out: true, + stdout: '', + stderr: '', + workspace: 'new', + }) + const running = deferred() + sandbox.runCode.mockReturnValueOnce(running.promise) + sandbox.restartCodeContext.mockImplementation(async () => + running.reject(new Error('closed')), + ) + const abort = new AbortController() + const code = invoke( + toolset, + 'run_code', + { code: 'while True: pass', language: 'r' }, + toolContext(context('session', 'user', abort.signal)), + ) + await vi.advanceTimersByTimeAsync(10) + abort.abort() + await vi.advanceTimersByTimeAsync(10) + expect(await code).toEqual({ + success: false, + error: + 'Execution was cancelled. The r kernel was restarted; its variables and imports were lost.', + aborted: true, + kernel_restarted: true, + }) + } finally { + vi.useRealTimers() + } }) - it('returns safe failures without leaking SDK messages or logging arguments', async () => { + it('returns safe, actionable failures without leaking SDK messages or logging arguments', async () => { const { sandbox } = mockSandbox() const log = vi.spyOn(console, 'log') const debug = vi.spyOn(console, 'debug') @@ -146,14 +237,67 @@ describe('six tools', () => { code: 'raw-code', envs: { KEY: 'secret-key' }, }) - expect(result).toMatchObject({ success: false }) + expect(result).toMatchObject({ + success: false, + error: 'Sandbox operation failed.', + }) expect(JSON.stringify(result)).not.toMatch( /secret-key|raw-code|private-file/, ) + sandbox.files.read.mockRejectedValue( + new FileNotFoundError('/secret/path token=abc'), + ) + const missing = await invoke(toolset, 'read_file', { path: '/x' }) + expect(missing).toEqual({ + success: false, + error: 'Sandbox operation failed (FileNotFoundError).', + }) expect(log).not.toHaveBeenCalled() expect(debug).not.toHaveBeenCalled() expect(warn).not.toHaveBeenCalled() - await toolset.close() + }) + + it('surfaces sandbox expiry once, then a new workspace', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + await invoke(toolset, 'write_file', { path: 'a', content: 'x' }) + sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + sandbox.connect.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + expect(await invoke(toolset, 'read_file', { path: 'a' })).toEqual({ + success: false, + error: expect.stringContaining('expired or was deleted'), + workspace_expired: true, + }) + expect(await invoke(toolset, 'read_file', { path: 'a' })).toMatchObject({ + success: true, + workspace: 'new', + }) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('keys workspaces by app, user and session, never by one of them alone', async () => { + const { create } = mockSandbox() + const toolset = new E2BToolset() + for (const [id, user] of [ + ['s', 'alice'], + ['s', 'bob'], + ['t', 'alice'], + ['s', 'alice'], + ] as const) + await invoke(toolset, 'list_files', {}, toolContext(context(id, user))) + expect(create).toHaveBeenCalledTimes(3) + }) + + it('requires an ADK invocation context', async () => { + const { create } = mockSandbox() + const tool = (await new E2BToolset().getTools())[2]! + expect( + await tool.runAsync({ + args: { path: 'a', content: 'x' }, + toolContext: {} as Context, + }), + ).toMatchObject({ success: false }) + expect(create).not.toHaveBeenCalled() }) it('bounds code, file and directory outputs', async () => { @@ -174,33 +318,38 @@ describe('six tools', () => { } expect(list.entries).toHaveLength(100) expect(list.truncated).toBe(true) - await toolset.close() }) - it('returns the background PID and an unverified URL, disconnecting the stream', async () => { + it('keeps background processes alive by default, returns the PID and an unverified URL', async () => { const { sandbox } = mockSandbox() - const disconnect = vi.fn().mockResolvedValue(undefined) - sandbox.commands.run.mockResolvedValue({ pid: 123, disconnect }) const toolset = new E2BToolset() expect( await invoke(toolset, 'start_background_command', { command: 'server', port: 3000, - timeoutMs: 0, }), ).toEqual({ success: true, - pid: 123, + pid: 101, preview_url: 'https://3000-fake.e2b.app', readiness: 'unknown', + workspace: 'new', }) - expect(disconnect).toHaveBeenCalledTimes(1) + expect(sandbox.handle.disconnect).toHaveBeenCalledTimes(1) expect(sandbox.commands.run).toHaveBeenCalledWith('server', { background: true, timeoutMs: 0, cwd: undefined, envs: undefined, }) + await invoke(toolset, 'start_background_command', { + command: 'server', + timeoutMs: 5000, + }) + expect(sandbox.commands.run).toHaveBeenLastCalledWith( + 'server', + expect.objectContaining({ timeoutMs: 5000 }), + ) sandbox.getHost.mockImplementation(() => { throw new Error('host') }) @@ -209,10 +358,16 @@ describe('six tools', () => { command: 'server', port: 3000, }), - ).toMatchObject({ success: true, pid: 123, preview_url: null }) + ).toMatchObject({ success: true, pid: 101, preview_url: null }) expect( await invoke(toolset, 'start_background_command', { command: 'server' }), - ).toEqual({ success: true, pid: 123, preview_url: null }) - await toolset.close() + ).toEqual({ success: true, pid: 101, preview_url: null }) }) }) + +function deferred() { + let reject!: (reason: unknown) => void + const promise = new Promise((_, rej) => (reject = rej)) + promise.catch(() => undefined) + return { promise, reject } +} diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..976047c --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,387 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CommandExitError, + FileNotFoundError, + SandboxNotFoundError, + TimeoutError, + type Sandbox, +} from '@e2b/code-interpreter' +import { + OperationAbortedError, + WorkspaceBlockedError, + WorkspaceDestroyedError, + WorkspaceExpiredError, +} from '../src/results.js' +import { Workspace } from '../src/workspace.js' +import { deferred, execution, mockSandbox, tick } from './helpers.js' + +afterEach(() => vi.useRealTimers()) + +const info = () => ({ created: false }) +const workspace = (options = {}) => new Workspace(options, 'Retry destroy.') + +describe('workspace lifecycle', () => { + it('is lazy, forwards only supplied SDK options and reports creation', async () => { + const { create, sandbox } = mockSandbox() + const options = { + timeoutMs: 1200, + allowInternetAccess: false, + lifecycle: { onTimeout: 'pause' as const, autoResume: true }, + } + const ws = workspace({ template: 'custom', sandboxOptions: options }) + expect(create).not.toHaveBeenCalled() + const first = info() + await ws.run(async () => 1, first) + expect(first.created).toBe(true) + expect(create).toHaveBeenCalledWith('custom', options) + const second = info() + await ws.run(async () => 1, second) + expect(second.created).toBe(false) + expect(create).toHaveBeenCalledTimes(1) + expect(sandbox.setTimeout).toHaveBeenCalledWith(1200) + expect(sandbox.kill).not.toHaveBeenCalled() + }) + + it('serializes work; a cancelled waiter has no SDK effect and does not let the next one overtake', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + const gate = deferred() + const order: string[] = [] + const a = ws.run(async () => { + order.push('a') + await gate.promise + order.push('a done') + }, info()) + const cancel = new AbortController() + const skipped = vi.fn() + const b = ws.run(skipped, info(), cancel.signal) + const c = ws.run(async () => order.push('c'), info()) + await tick() + cancel.abort() + await expect(b).rejects.toBeInstanceOf(OperationAbortedError) + await tick() + expect(order).toEqual(['a']) + gate.resolve() + await Promise.all([a, c]) + expect(order).toEqual(['a', 'a done', 'c']) + expect(skipped).not.toHaveBeenCalled() + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + expect(sandbox.commands.run).not.toHaveBeenCalled() + }) + + it('starts an operation deadline when it is served, not when it is queued', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + sandbox.runCode.mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve(execution()), 2000)), + ) + const ws = workspace() + const first = ws.run( + () => new Promise((resolve) => setTimeout(resolve, 5000)), + info(), + ) + const queued = ws.runCode('x', 'python', undefined, info(), { + timeoutMs: 3000, + }) + await vi.advanceTimersByTimeAsync(8000) + await first + expect(await queued).toHaveProperty('execution') + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + }) + + it('uses one lazy code context per language and never passes language with it', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + await ws.runCode('a', 'python', { X: '1' }, info(), { timeoutMs: 1000 }) + await ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }) + await ws.runCode('c', 'r', undefined, info(), { timeoutMs: 1000 }) + expect(sandbox.createCodeContext.mock.calls).toEqual([ + [{ language: 'python' }], + [{ language: 'r' }], + ]) + expect(sandbox.runCode).toHaveBeenNthCalledWith(1, 'a', { + context: { id: 'ctx-python', language: 'python', cwd: '/home/user' }, + envs: { X: '1' }, + timeoutMs: 6000, + }) + }) + + it.each([ + ['deadline', 'timed_out'], + ['abort', 'aborted'], + ['SDK timeout', 'timed_out'], + ])( + '%s during code restarts the kernel before the next call', + async (how, stopped) => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const running = deferred() + running.promise.catch(() => undefined) + sandbox.runCode.mockReturnValueOnce(running.promise) + // Live-verified: a restart ends the open execution stream. + sandbox.restartCodeContext.mockImplementation(async () => + running.reject(new Error('UnexpectedEndOfExecution')), + ) + const ws = workspace() + const abort = new AbortController() + const result = ws.runCode('loop', 'python', undefined, info(), { + timeoutMs: 1000, + signal: abort.signal, + }) + await vi.advanceTimersByTimeAsync(10) + if (how === 'abort') abort.abort() + if (how === 'SDK timeout') running.reject(new TimeoutError('timed out')) + await vi.advanceTimersByTimeAsync(1000) + expect(await result).toEqual({ stopped }) + expect(sandbox.restartCodeContext).toHaveBeenCalledWith({ + id: 'ctx-python', + language: 'python', + cwd: '/home/user', + }) + const next = ws.runCode('1', 'python', undefined, info(), { + timeoutMs: 1000, + }) + await vi.advanceTimersByTimeAsync(0) + expect(await next).toHaveProperty('execution') + expect(vi.getTimerCount()).toBe(0) + }, + ) + + it('blocks the workspace when a kernel restart fails, until destroy recovers it', async () => { + const { sandbox, create } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new TimeoutError('timed out')) + sandbox.restartCodeContext.mockRejectedValueOnce(new Error('down')) + const ws = workspace() + await expect( + ws.runCode('loop', 'java', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toThrow('The java kernel could not be restarted') + const skipped = vi.fn() + await expect(ws.run(skipped, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + expect(skipped).not.toHaveBeenCalled() + await ws.destroy() + const later = info() + await ws.run(async () => 1, later) + expect(later.created).toBe(true) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('runs commands in their own process group without passing the abort signal to the SDK', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + const abort = new AbortController() + const outcome = await ws.runCommand( + "echo 'hi'", + { cwd: '/tmp', envs: { X: 'y' } }, + info(), + { timeoutMs: 1000, signal: abort.signal }, + ) + expect(outcome).toEqual({ + result: { stdout: 'ok', stderr: '', exitCode: 0 }, + }) + expect(sandbox.commands.run).toHaveBeenCalledWith( + `exec setsid -w /bin/bash -c 'echo '\\''hi'\\'''`, + { cwd: '/tmp', envs: { X: 'y' }, background: true, timeoutMs: 0 }, + ) + const exit = new CommandExitError({ + stdout: 'out', + stderr: 'err', + exitCode: 7, + error: 'failed', + }) + sandbox.handle.wait.mockRejectedValueOnce(exit) + expect( + await ws.runCommand('exit 7', {}, info(), { timeoutMs: 1000 }), + ).toEqual({ result: exit }) + }) + + it.each(['deadline', 'abort', 'abort before the handle exists'])( + '%s kills the command process group', + async (how) => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const exited = deferred() + exited.promise.catch(() => undefined) + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + Object.assign(sandbox.handle, { stdout: 'partial', stderr: '' }) + const start = deferred() + sandbox.commands.run.mockImplementation(async (command, opts) => { + if (opts?.background) return start.promise + if (command === 'kill -9 -- -101') exited.reject(new Error('killed')) + return { stdout: '', stderr: '', exitCode: 0 } + }) + const abort = new AbortController() + const ws = workspace() + const result = ws.runCommand('sleep 600', {}, info(), { + timeoutMs: 1000, + signal: abort.signal, + }) + await vi.advanceTimersByTimeAsync(10) + if (how === 'abort before the handle exists') abort.abort() + start.resolve(sandbox.handle) + await vi.advanceTimersByTimeAsync(10) + if (how === 'abort') abort.abort() + await vi.advanceTimersByTimeAsync(1000) + const stopped = how === 'deadline' ? 'timed_out' : 'aborted' + expect(await result).toEqual({ stopped, stdout: 'partial', stderr: '' }) + expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') + expect(vi.getTimerCount()).toBe(0) + }, + ) + + it('falls back to killing the command itself when the group kill fails', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const exited = deferred() + exited.promise.catch(() => undefined) + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + sandbox.handle.kill.mockImplementation(async () => { + exited.reject(new Error('killed')) + return true + }) + sandbox.commands.run.mockImplementation(async (_command, opts) => { + if (opts?.background) return sandbox.handle + throw new Error('no such group') + }) + const result = workspace().runCommand('x', {}, info(), { timeoutMs: 1000 }) + await vi.advanceTimersByTimeAsync(1000) + expect(await result).toMatchObject({ stopped: 'timed_out' }) + expect(sandbox.handle.kill).toHaveBeenCalledTimes(1) + }) + + it('destroy fails queued work without recreating, rejects new work meanwhile, then allows a new workspace', async () => { + const { sandbox, create } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + const gate = deferred() + const running = ws.run(() => gate.promise, info()) + const queuedWork = vi.fn() + const queued = ws.run(queuedWork, info()) + const kill = deferred() + sandbox.kill.mockReturnValueOnce(kill.promise) + const destroy = ws.destroy() + expect(ws.destroy()).toBe(destroy) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceDestroyedError, + ) + kill.resolve(true) + await destroy + gate.resolve() + await expect(running).rejects.toBeInstanceOf(WorkspaceDestroyedError) + await expect(queued).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(queuedWork).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + expect(ws.idle).toBe(true) + const later = info() + await ws.run(async () => 1, later) + expect(later.created).toBe(true) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('kills a sandbox whose creation finished after destroy', async () => { + const { sandbox, create } = mockSandbox() + const pending = deferred() + create.mockReturnValueOnce(pending.promise) + const ws = workspace() + const work = vi.fn() + const first = ws.run(work, info()) + await tick() + await ws.destroy() + pending.resolve(sandbox as unknown as Sandbox) + await expect(first).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(work).not.toHaveBeenCalled() + expect(sandbox.kill).toHaveBeenCalledTimes(1) + expect(ws.idle).toBe(true) + }) + + it('keeps a failed kill handle blocked and retryable', async () => { + const { sandbox, create } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + sandbox.kill.mockRejectedValueOnce(new Error('Unavailable')) + await expect(ws.destroy()).rejects.toThrow( + 'Sandbox cleanup failed. Retry destroy.', + ) + const work = vi.fn() + const renewals = sandbox.setTimeout.mock.calls.length + await expect(ws.run(work, info())).rejects.toThrow('Retry destroy.') + expect(work).not.toHaveBeenCalled() + expect(sandbox.setTimeout).toHaveBeenCalledTimes(renewals) + expect(ws.idle).toBe(false) + await ws.destroy() + expect(sandbox.kill).toHaveBeenCalledTimes(2) + await ws.run(async () => 1, info()) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('reports an expired sandbox once, resets code contexts, then starts a new workspace', async () => { + const { sandbox, create } = mockSandbox() + const ws = workspace() + await ws.runCode('x = 1', 'python', undefined, info(), { timeoutMs: 1000 }) + sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + sandbox.connect.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + const work = vi.fn() + await expect(ws.run(work, info())).rejects.toBeInstanceOf( + WorkspaceExpiredError, + ) + expect(work).not.toHaveBeenCalled() + const next = info() + await ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }) + await ws.run(async () => 1, next) + expect(create).toHaveBeenCalledTimes(2) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(2) + }) + + it('resumes a paused sandbox, which the control plane also reports as missing', async () => { + const { sandbox, create } = mockSandbox() + const ws = workspace({ sandboxOptions: { timeoutMs: 1200 } }) + await ws.runCode('x = 1', 'python', undefined, info(), { timeoutMs: 1000 }) + sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('paused')) + const resumed = info() + expect(await ws.run(async () => 'kept', resumed)).toBe('kept') + expect(sandbox.connect).toHaveBeenCalledWith({ timeoutMs: 1200 }) + expect(resumed.created).toBe(false) + await ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }) + expect(create).toHaveBeenCalledTimes(1) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) + sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('paused')) + sandbox.connect.mockRejectedValueOnce(new Error('Transient')) + await expect(ws.run(async () => 1, info())).rejects.toThrow('Transient') + expect(await ws.run(async () => 2, info())).toBe(2) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('does not treat a missing file or other refresh failure as expiry', async () => { + const { sandbox, create } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + await expect( + ws.run(async () => { + throw new FileNotFoundError('missing') + }, info()), + ).rejects.toBeInstanceOf(FileNotFoundError) + sandbox.setTimeout.mockRejectedValue(new Error('Transient')) + expect(await ws.run(async () => 42, info())).toBe(42) + expect(create).toHaveBeenCalledTimes(1) + }) + + it('refreshes throughout operations exceeding TTL and stops when they end', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const ws = workspace({ sandboxOptions: { timeoutMs: 1000 } }) + await ws.run(async () => 1, info()) + const calls = sandbox.setTimeout.mock.calls.length + const finish = deferred() + const work = ws.run(() => finish.promise, info()) + await vi.advanceTimersByTimeAsync(2500) + expect(sandbox.setTimeout.mock.calls.length - calls).toBe(6) + const rejected = expect(work).rejects.toThrow('Execution failed') + finish.reject(new Error('Execution failed')) + await rejected + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(10_000) + expect(sandbox.setTimeout.mock.calls.length - calls).toBe(7) + }) +}) From 6782b4d777ca866b7d35b5e3cea3fe9966bb2c6a Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Wed, 23 Sep 2026 14:28:00 +0200 Subject: [PATCH 08/23] docs: document session workspaces, cleanup and operation limits Rewrite the README and data-analysis example for a module-level agent with explicit destroySession() cleanup. Extend the live harness to cover stop mechanisms, pause/resume, expiry, background lifetime and a multi-user conversation, with an optional test-only OpenAI model. --- .env.example | 1 + README.md | 96 +++--- examples/data-analysis.ts | 94 +++--- scripts/live.ts | 600 +++++++++++++++++++++++++++++--------- scripts/openai-model.ts | 148 ++++++++++ 5 files changed, 723 insertions(+), 216 deletions(-) create mode 100644 scripts/openai-model.ts diff --git a/.env.example b/.env.example index 5cd0a29..28ef179 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ E2B_API_KEY= GOOGLE_API_KEY= +OPENAI_API_KEY= diff --git a/README.md b/README.md index 5fa1dfd..72dabcf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # E2B for Google ADK -Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` creates a shared workspace lazily and releases it when the ADK runner invocation finishes. +Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` gives each ADK session its own lazily created workspace, which persists across the messages of that conversation until your application releases it or the sandbox times out. ## Install @@ -26,41 +26,37 @@ The integration is model-independent. The examples use Gemini 2.5 Flash through import { E2BToolset } from '@e2b/adk' import { InMemoryRunner, LlmAgent } from '@google/adk' -async function calculate(userId: string, question: string): Promise { - const toolset = new E2BToolset() - const runner = new InMemoryRunner({ - agent: new LlmAgent({ - name: 'calculator', - model: 'gemini-2.5-flash', - instruction: - 'Use run_code to verify calculations. Check its error field.', - tools: [toolset], - }), - }) - - let answer = '' - try { - for await (const event of runner.runEphemeral({ - userId, - newMessage: { role: 'user', parts: [{ text: question }] }, - runConfig: { maxLlmCalls: 6 }, - })) { - if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) - for (const part of event.content?.parts ?? []) { - if (part.text && !part.thought) answer += part.text - } - } - } finally { - await toolset.close() +// Create once per application, like any ADK root agent. +const toolset = new E2BToolset() +const runner = new InMemoryRunner({ + agent: new LlmAgent({ + name: 'calculator', + model: 'gemini-2.5-flash', + instruction: 'Use run_code to verify calculations. Check its error field.', + tools: [toolset], + }), +}) + +const session = await runner.sessionService.createSession({ + appName: runner.appName, + userId: 'user', +}) +try { + for await (const event of runner.runAsync({ + userId: session.userId, + sessionId: session.id, + newMessage: { role: 'user', parts: [{ text: 'Compute 1234567 * 89.' }] }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) + if (part.text && !part.thought) process.stdout.write(part.text) } - return answer +} finally { + // Release the sandbox when the conversation is over. + await toolset.destroySession(session) } - -console.log(await calculate('user', 'Compute 1234567 * 89.')) ``` -Create the toolset, agent and runner inside the request function, as above. Concurrent calls to `calculate()` then own separate sandboxes. Pass the toolset itself in `tools: [toolset]` so ADK's `runAsync()` / `runEphemeral()` can close it. If you register individual tools from `getTools()`, your application must call `close()` explicitly. - | Tool | Arguments | Result fields besides `success` | | -------------------------- | ------------------------------------------------------ | ------------------------------------------ | | `run_code` | `code`, optional `language`, `envs`, `timeoutMs` | `stdout`, `stderr`, `text`, `error` | @@ -70,44 +66,54 @@ Create the toolset, agent and runner inside the request function, as above. Conc | `list_files` | optional `path` (default `.`) | `entries`, `truncated` | | `start_background_command` | `command`, optional `cwd`, `envs`, `timeoutMs`, `port` | `pid`, `preview_url`, optional `readiness` | -`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`. Kernel state persists between calls in the same sandbox. Commands start independent processes; their working directory and environment changes do not carry into the next command. +`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`, with one stateful kernel per language in each workspace. Commands start independent processes; their working directory and environment changes do not carry into the next command, but files do. + +`success` means the operation ran. Code that raises still returns `success: true` with an `error`; a command exiting nonzero returns `success: true` with its `exit_code`. Invalid arguments and SDK/transport failures return `success: false` with an `error` rather than aborting the agent loop. SDK error messages are sanitized to their error class, such as `(FileNotFoundError)`. Each text output is limited to 10,000 characters; directory listings contain at most 100 entries and indicate truncation. -`success` means the operation ran. Code that raises still returns `success: true` with an `error`; a command exiting nonzero returns `success: true` with its `exit_code`. Invalid arguments and SDK/transport failures return `success: false` with an `error` rather than aborting the agent loop. SDK error messages are sanitized. Each text output is limited to 10,000 characters; directory listings contain at most 100 entries and indicate truncation. +The call that creates a workspace also returns `workspace: "new"`. If the sandbox has expired or was deleted, the next call returns `success: false` with `workspace_expired: true`; the call after that starts a new, empty workspace. Agent instructions should tell the model to check these fields. -## Configuration and lifecycle +## Workspaces and cleanup + +One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. + +ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox, fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. + +`runEphemeral()` creates a session whose id your code never sees, so each call leaves a workspace that only `destroyAll()` or the sandbox timeout releases. Prefer `runAsync()` with sessions you create. Workspaces live in the toolset's memory: a deployment with several server processes gives each process its own workspace for the same session. + +## Configuration and limits ```typescript const toolset = new E2BToolset({ template: 'your-code-interpreter-template', + maxOperationMs: 600_000, sandboxOptions: { timeoutMs: 300_000, + lifecycle: { onTimeout: 'pause', autoResume: true }, metadata: { application: 'analyst' }, allowInternetAccess: false, }, }) ``` -`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use. `sandboxOptions` accepts the E2B SDK's creation options except `template`. Unspecified network, lifecycle, environment and connection options retain the SDK defaults. Sandbox and tool timeout values use milliseconds. An operation's `timeoutMs: 0` disables its SDK execution deadline; the sandbox TTL still applies. - -Creation, operations and teardown are serialized per instance. Active SDK operations refresh the sandbox TTL, including a heartbeat for operations longer than the TTL. The adapter does not silently replace an expired sandbox with an empty one. After successful `close()`, a new invocation can create a new workspace; late calls from the closed invocation are rejected. If cleanup fails, the handle is retained for retry and sandbox operations are blocked until `close()` succeeds. Keep the explicit `finally` cleanup: ADK 2.1.0 suppresses automatic toolset cleanup errors, whereas an explicit `close()` reports a safe error to your application. +`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are kept until destroyed, so release finished sessions. -ADK 2.1.0 closes registered toolsets at the end of `runAsync()` / `runEphemeral()`, including errors and cancellation. `runLive()` does not automatically close toolsets; always call `close()` in `finally` for that mode. Files, kernel state and background processes are shared within a single invocation. A preview URL is syntactic, has `readiness: "unknown"`, and stops working when the sandbox closes. Finish consuming or explicitly close a runner's async iterator so its `finally` cleanup runs. +`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. If a kernel cannot be restarted, the workspace is blocked until `destroySession()`. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. -Overlapping invocations must use separate toolsets, even within the same user session. Detecting an overlap permanently invalidates the shared instance; subsequent discovery or tool calls fail, and `close()` still releases its sandbox. Because ADK calls `close()` without identifying the invocation, a competing runner's cleanup can interrupt the original run. This guard prevents cross-invocation access and silent workspace recreation; it does not make shared concurrent use supported. Create a new toolset after such a conflict. +`start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. -The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. +The E2B SDK buffers command and code output in memory before it is truncated. The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. ## Direct code executor -`E2BCodeExecutor` implements ADK's `BaseCodeExecutor`. Call `executeCode({ invocationContext, codeExecutionInput })` and close it explicitly in `finally`. See the complete [direct executor example](examples/code-executor.ts). +`E2BCodeExecutor` implements ADK's `BaseCodeExecutor`. Call `executeCode({ invocationContext, codeExecutionInput })` and call `close()` when you are done. See the complete [direct executor example](examples/code-executor.ts). **ADK 2.1.0 limitation:** setting `LlmAgent({ codeExecutor })` alone does not execute model responses: ADK does not register its code execution response processor. Use `E2BToolset` for an autonomous agent. This package does not install a custom processor or import ADK internals. -Direct calls accept ADK's `CodeExecutionLanguage.PYTHON`, `JAVASCRIPT`, `TYPESCRIPT` and `SHELL` (mapped to bash). Unsupported languages, nonempty `inputFiles` and nonempty `args` return a descriptive `stderr` without creating a sandbox. Output contains bounded `stdout`/`stderr` and `outputFiles: []`; automatic file staging and artifact collection are not supported. The executor retains kernel state until `close()` and remains bound to its first ADK session to prevent accidental cross-session sharing. +Direct calls accept ADK's `CodeExecutionLanguage.PYTHON`, `JAVASCRIPT`, `TYPESCRIPT` and `SHELL` (mapped to bash). Unsupported languages, nonempty `inputFiles` and nonempty `args` return a descriptive `stderr` without creating a sandbox. Output contains bounded `stdout`/`stderr` and `outputFiles: []`; automatic file staging and artifact collection are not supported. Each call is limited to 60 seconds (or `maxOperationMs`, if lower); a timed-out call restarts the kernel. The executor keeps kernel state until `close()`, which releases the sandbox; a later call starts a new one. It stays bound to its first ADK session to prevent accidental cross-session sharing. ## Examples and development -- [Data analysis agent](examples/data-analysis.ts): save a CSV, calculate results with Python, and report the answer. +- [Data analysis agent](examples/data-analysis.ts): a two-message conversation that saves a CSV once and reuses it. - [Direct code executor](examples/code-executor.ts): reuse a stateful Python kernel through the ADK executor contract. ```bash @@ -125,4 +131,4 @@ pnpm example:executor pnpm test:live ``` -Live checks create real E2B sandboxes and make Gemini requests. They verify tool execution, supported languages and teardown, and clean up only workspaces tagged with their unique test-run ID. They are never part of `pnpm check` or CI. +Live checks create real E2B sandboxes and make Gemini requests (`LIVE_MODEL=openai pnpm test:live agent` uses a small OpenAI model through a test-only adapter instead). They verify the tools in every language, stopping commands and code, pause and resume, expiry, and a multi-user conversation with confirmation and cancellation. They clean up only workspaces tagged with their unique test-run ID and are never part of `pnpm check` or CI. diff --git a/examples/data-analysis.ts b/examples/data-analysis.ts index 070e882..46d1017 100644 --- a/examples/data-analysis.ts +++ b/examples/data-analysis.ts @@ -1,5 +1,5 @@ import { E2BToolset } from '@e2b/adk' -import { InMemoryRunner, LlmAgent } from '@google/adk' +import { InMemoryRunner, LlmAgent, type Session } from '@google/adk' const dataset = `month,region,marketing_spend,revenue 2024-01,North,12000,48000 @@ -7,50 +7,62 @@ const dataset = `month,region,marketing_spend,revenue 2024-01,South,8000,24000 2024-02,South,11000,33000` -async function analyze(userId: string, csv: string): Promise { - const toolset = new E2BToolset({ - sandboxOptions: { metadata: { example: 'adk-typescript-data-analysis' } }, - }) - const agent = new LlmAgent({ - name: 'data_analyst', - model: 'gemini-2.5-flash', - instruction: - 'Use write_file to save the supplied CSV to /home/user/sales.csv. ' + - 'Use run_code with Python to read that file and calculate the answers. ' + - 'Check error and exit_code fields, fix failures and rerun. ' + - 'Never report a number you have not verified in the sandbox.', - tools: [toolset], - generateContentConfig: { - temperature: 0, - thinkingConfig: { thinkingBudget: 0 }, - }, - }) - const runner = new InMemoryRunner({ agent }) +// One toolset and agent for the whole application; each session gets its own sandbox. +const toolset = new E2BToolset({ + sandboxOptions: { metadata: { example: 'adk-typescript-data-analysis' } }, +}) +const rootAgent = new LlmAgent({ + name: 'data_analyst', + model: 'gemini-2.5-flash', + instruction: + 'Files and Python variables persist for the whole conversation. ' + + 'Use write_file to save supplied data and run_code with Python to calculate answers. ' + + 'Check error and exit_code fields, fix failures and rerun. ' + + 'If a result has workspace_expired, earlier files are gone: ask for the data again. ' + + 'Never report a number you have not verified in the sandbox.', + tools: [toolset], + generateContentConfig: { + temperature: 0, + thinkingConfig: { thinkingBudget: 0 }, + }, +}) +const runner = new InMemoryRunner({ agent: rootAgent }) +async function ask(session: Session, text: string): Promise { let answer = '' - try { - for await (const event of runner.runEphemeral({ - userId, - newMessage: { - role: 'user', - parts: [ - { - text: `${csv}\nCalculate total revenue divided by total marketing spend for each region. Which region performs better?`, - }, - ], - }, - runConfig: { maxLlmCalls: 8 }, - })) { - if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) - for (const part of event.content?.parts ?? []) { - if (part.text && !part.thought) answer += part.text - } + for await (const event of runner.runAsync({ + userId: session.userId, + sessionId: session.id, + newMessage: { role: 'user', parts: [{ text }] }, + runConfig: { maxLlmCalls: 8 }, + })) { + if (event.errorCode) throw new Error(`Agent failed: ${event.errorCode}`) + for (const part of event.content?.parts ?? []) { + if (part.text && !part.thought) answer += part.text } - } finally { - // Runner closes registered toolsets; this also covers an interrupted caller. - await toolset.close() } return answer } -console.log(await analyze('analyst', dataset)) +const session = await runner.sessionService.createSession({ + appName: runner.appName, + userId: 'analyst', +}) +try { + console.log( + await ask( + session, + `${dataset}\nSave this CSV to /home/user/sales.csv. Calculate total revenue divided by total marketing spend for each region. Which region performs better?`, + ), + ) + // The follow-up reuses the saved file in the same sandbox. + console.log( + await ask( + session, + 'Using the same file, which month had the highest total revenue?', + ), + ) +} finally { + // ADK does not release session workspaces; the application does. + await toolset.destroySession(session) +} diff --git a/scripts/live.ts b/scripts/live.ts index 2311549..5e303a9 100644 --- a/scripts/live.ts +++ b/scripts/live.ts @@ -1,119 +1,332 @@ import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' +import { setTimeout as sleep } from 'node:timers/promises' import { CodeExecutionLanguage, Context, + FunctionTool, InMemoryRunner, InvocationContext, LlmAgent, PluginManager, + type Event, } from '@google/adk' import { Sandbox } from '@e2b/code-interpreter' +import { z } from 'zod' import { E2BCodeExecutor, E2BToolset } from '../src/index.js' +import { OpenAIModel } from './openai-model.js' assert(process.env.E2B_API_KEY, 'E2B_API_KEY is required') const runId = randomUUID() -const options = { - sandboxOptions: { timeoutMs: 60_000, metadata: { adkLiveRun: runId } }, -} -const ctx = new InvocationContext({ - invocationId: 'live', - pluginManager: new PluginManager(), - session: { - appName: 'live', - userId: 'live', - id: runId, - state: {}, - events: [], - lastUpdateTime: 0, - }, +const metadata = { adkLiveRun: runId } +const options = (extra: Record = {}) => ({ + sandboxOptions: { timeoutMs: 120_000, metadata, ...extra }, }) +type Result = Record + +function invocation(id: string, userId = 'live', abortSignal?: AbortSignal) { + return new InvocationContext({ + invocationId: randomUUID(), + pluginManager: new PluginManager(), + abortSignal, + session: { + appName: 'live', + userId, + id, + state: {}, + events: [], + lastUpdateTime: 0, + }, + }) +} async function remaining() { - const paginator = Sandbox.list({ query: { metadata: { adkLiveRun: runId } } }) + const paginator = Sandbox.list({ query: { metadata } }) const ids: string[] = [] while (paginator.hasNext) ids.push(...(await paginator.nextItems()).map((box) => box.sandboxId)) return ids } -async function directTools() { - const toolset = new E2BToolset(options) - const tools = await toolset.getTools() - async function call(name: string, args: Record) { - const result = (await tools +function client(toolset: E2BToolset) { + const tools = toolset.getTools() + const raw = async ( + name: string, + args: Result, + ctx: InvocationContext, + ): Promise => + (await ( + await tools + ) .find((tool) => tool.name === name)! .runAsync({ args, toolContext: new Context({ invocationContext: ctx }), - })) as Record + })) as Result + const call = async (name: string, args: Result, ctx: InvocationContext) => { + const result = await raw(name, args, ctx) + assert.equal(result.success, true, `${name}: ${JSON.stringify(result)}`) + return result + } + /** Marker-file line count and matching processes, from inside the sandbox. */ + const probe = async (ctx: InvocationContext, file: string, pattern: string) => + String( + ( + await call( + 'run_command', + { + command: `wc -l < ${file} 2>/dev/null || echo 0; pgrep -fc '${pattern}' || true`, + }, + ctx, + ) + ).stdout, + ) + .trim() + .split('\n') + .map(Number) + /** Assert that writes to `file` stopped and no process matches `pattern`. */ + const stopped = async ( + ctx: InvocationContext, + file: string, + pattern: string, + ) => { + const [before] = await probe(ctx, file, pattern) + await sleep(2000) + const [after, processes] = await probe(ctx, file, pattern) + assert.equal(after, before, `${file} still growing`) + // pgrep -f also matches the probing shell itself. + assert(processes! <= 1, `${processes} processes match ${pattern}`) + } + return { raw, call, stopped } +} + +async function directTools() { + const toolset = new E2BToolset(options()) + const { call } = client(toolset) + const ctx = invocation(runId) + const first = await call( + 'write_file', + { path: '/home/user/adk-smoke.txt', content: 'smoke-content' }, + ctx, + ) + assert.equal(first.workspace, 'new') + assert.equal( + (await call('read_file', { path: '/home/user/adk-smoke.txt' }, ctx)) + .content, + 'smoke-content', + ) + assert.match( + JSON.stringify(await call('list_files', { path: '/home/user' }, ctx)), + /adk-smoke.txt/, + ) + const exit = await call( + 'run_command', + { command: "printf 'shell ok'; echo err >&2; exit 7", cwd: '/tmp' }, + ctx, + ) + assert.deepEqual( + [exit.stdout, exit.stderr, exit.exit_code], + ['shell ok', 'err\n', 7], + ) + const samples: Record = { + python: ['v = 6 * 7', 'print(v)'], + javascript: ['var v = 6 * 7', 'console.log(v)'], + typescript: ['var v: number = 6 * 7', 'console.log(v)'], + r: ['v <- 6 * 7', 'cat(v)'], + java: ['int v = 6 * 7;', 'System.out.println(v);'], + bash: ['v=$((6 * 7))', 'echo $v'], + } + for (const [language, [set, get]] of Object.entries(samples)) { + await call('run_code', { code: set, language, timeoutMs: 30_000 }, ctx) + const result = await call('run_code', { code: get, language }, ctx) + assert.equal(result.error, '', `${language} execution error`) + assert.match(`${result.stdout}${result.text}`, /42/) + console.log(`PASS run_code ${language} keeps kernel state`) + } + const runtimeError = await call( + 'run_code', + { code: 'raise ValueError("expected")' }, + ctx, + ) + assert.match(String(runtimeError.error), /ValueError/) + const bg = await call( + 'start_background_command', + { command: 'python3 -m http.server 3000', port: 3000 }, + ctx, + ) + assert.equal(typeof bg.pid, 'number') + assert.match(String(bg.preview_url), /^https:\/\//) + await sleep(2000) + const served = await fetch(String(bg.preview_url)) + assert.equal(served.status, 200, 'preview URL serves the background process') + console.log('PASS preview URL reaches the background server') + assert.equal((await remaining()).length, 1) + await toolset.destroySession(ctx.session) + assert.deepEqual(await remaining(), []) + console.log('PASS six tools, state, errors, background and destroySession') +} + +async function stopMechanisms() { + const toolset = new E2BToolset(options()) + const { raw, call, stopped } = client(toolset) + const ctx = invocation(runId) + const loop = (file: string) => + `(while :; do echo x >> ${file}; sleep 0.2; done) & sleep 600` + + let result = await raw( + 'run_command', + { command: loop('/tmp/deadline'), timeoutMs: 3000 }, + ctx, + ) + assert.equal(result.timed_out, true, JSON.stringify(result)) + await stopped(ctx, '/tmp/deadline', 'sleep 600') + console.log('PASS command deadline kills the command and its children') + + for (const delay of [20, 1500]) { + const abort = new AbortController() + setTimeout(() => abort.abort(), delay) + const started = Date.now() + result = await raw( + 'run_command', + { command: loop(`/tmp/abort${delay}`) }, + invocation(runId, 'live', abort.signal), + ) + assert.equal(result.aborted, true, JSON.stringify(result)) + assert(Date.now() - started < 10_000, 'abort returns promptly') + await stopped(ctx, `/tmp/abort${delay}`, 'sleep 600') + console.log(`PASS command abort after ${delay} ms leaves no process`) + } + + const loops: Record = { + python: ['v = 1', 'while True:\n pass', 'print(v)'], + javascript: ['var v = 1', 'while (true) {}', 'console.log(v)'], + typescript: ['var v: number = 1', 'while (true) {}', 'console.log(v)'], + r: ['v <- 1', 'repeat {}', 'print(v)'], + java: ['int v = 1;', 'while (true) {}', 'System.out.println(v);'], + bash: ['v=1', 'while :; do :; done', 'echo "v=[$v]"'], + } + for (const [language, [set, spin, get]] of Object.entries(loops)) { + await call('run_code', { code: set, language, timeoutMs: 30_000 }, ctx) + result = await raw( + 'run_code', + { code: spin, language, timeoutMs: 3000 }, + ctx, + ) assert.equal( - result.success, + result.timed_out, true, - `Tool ${name} failed: ${JSON.stringify(result)}`, + `${language}: ${JSON.stringify(result)}`, + ) + assert.equal(result.kernel_restarted, true) + const started = Date.now() + const after = await call('run_code', { code: get, language }, ctx) + assert( + Date.now() - started < 15_000, + `${language} kernel responds promptly`, + ) + assert.doesNotMatch( + `${after.stdout}${after.text}`, + /\b1\b/, + `${language} state was reset`, + ) + console.log( + `PASS ${language} deadline restarts the kernel (${Date.now() - started} ms)`, ) - return result } - try { - await call('write_file', { - path: '/home/user/adk-smoke.txt', - content: 'smoke-content', - }) - assert.equal( - (await call('read_file', { path: '/home/user/adk-smoke.txt' })).content, - 'smoke-content', + + const abort = new AbortController() + setTimeout(() => abort.abort(), 1500) + result = await raw( + 'run_code', + { + code: "import subprocess\nsubprocess.Popen(['bash', '-c', 'while :; do echo x >> /tmp/child; sleep 0.2; done'])\nwhile True:\n pass", + }, + invocation(runId, 'live', abort.signal), + ) + assert.equal(result.aborted, true, JSON.stringify(result)) + await stopped(ctx, '/tmp/child', 'tmp/child') + console.log( + 'PASS code abort restarts the kernel and stops its child processes', + ) + + await toolset.destroySession(ctx.session) + assert.deepEqual(await remaining(), []) +} + +async function lifecycle() { + // L3: a disconnected background process outlives the SDK's 60 s default. + { + const toolset = new E2BToolset(options()) + const { call } = client(toolset) + const ctx = invocation(randomUUID()) + await call( + 'start_background_command', + { command: 'while :; do echo x >> /tmp/bg; sleep 1; done' }, + ctx, + ) + await sleep(70_000) + const count = Number( + (await call('run_command', { command: 'wc -l < /tmp/bg' }, ctx)).stdout, + ) + assert(count >= 65, `background process stopped after ${count} s`) + console.log(`PASS background process alive after 70 s (${count} lines)`) + await toolset.destroyAll() + } + + // L1/L2: a paused autoResume sandbox keeps files and kernel state. + { + const toolset = new E2BToolset( + options({ lifecycle: { onTimeout: 'pause', autoResume: true } }), ) - assert.match( - JSON.stringify(await call('list_files', { path: '/home/user' })), - /adk-smoke.txt/, + const { raw, call } = client(toolset) + const ctx = invocation(randomUUID()) + await call( + 'write_file', + { path: '/home/user/keep.txt', content: 'kept' }, + ctx, ) + await call('run_code', { code: 'kept = 41' }, ctx) + const [id] = await remaining() + assert(await Sandbox.pause(id!), 'pause') + console.log(`INFO paused ${id}: ${(await Sandbox.getInfo(id!)).state}`) + const read = await raw('read_file', { path: '/home/user/keep.txt' }, ctx) + console.log( + `INFO first call on the paused sandbox: ${JSON.stringify(read)}`, + ) + assert.equal(read.content, 'kept', 'files survive pause/resume') assert.equal( - (await call('run_command', { command: 'printf shell-ok; exit 7' })) - .exit_code, - 7, - ) - const samples: Record = { - python: 'print(6 * 7)', - javascript: 'console.log(6 * 7)', - typescript: 'const n: number = 6 * 7; console.log(n)', - r: 'cat(6 * 7)', - java: 'System.out.println(6 * 7);', - bash: 'echo $((6 * 7))', - } - for (const [language, code] of Object.entries(samples)) { - const result = await call('run_code', { - code, - language, - timeoutMs: 20_000, - }) - assert.equal(result.error, '', `${language} execution error`) - assert.match(`${result.stdout}${result.text}`, /42/) - console.log(`PASS run_code ${language}`) - } - await call('run_code', { code: 'adk_value = 17' }) - assert.equal((await call('run_code', { code: 'adk_value + 1' })).text, '18') - const runtimeError = await call('run_code', { - code: 'raise ValueError("expected")', - }) - assert.match(String(runtimeError.error), /ValueError/) - const bg = await call('start_background_command', { - command: 'sleep 30', - port: 3000, - timeoutMs: 0, - }) - assert.equal(typeof bg.pid, 'number') - assert.match(String(bg.preview_url), /^https:\/\//) - assert.equal(bg.readiness, 'unknown') - assert.equal((await remaining()).length, 1) - } finally { - await toolset.close() + (await call('run_code', { code: 'kept + 1' }, ctx)).text, + '42', + 'kernel survives pause/resume', + ) + console.log('PASS pause/autoResume keeps files and kernel state') + await Sandbox.pause(id!) + await toolset.destroyAll() + assert.deepEqual(await remaining(), [], 'destroyAll kills a paused sandbox') + console.log('PASS destroyAll removes a paused sandbox') + } + + // L5: TTL expiry is explicit, then a new workspace. + { + const toolset = new E2BToolset(options({ timeoutMs: 10_000 })) + const { raw, call } = client(toolset) + const ctx = invocation(randomUUID()) + await call('write_file', { path: '/home/user/x.txt', content: 'x' }, ctx) + await sleep(25_000) + const expired = await raw('read_file', { path: '/home/user/x.txt' }, ctx) + assert.equal(expired.workspace_expired, true, JSON.stringify(expired)) + const fresh = await raw('read_file', { path: '/home/user/x.txt' }, ctx) + assert.equal(fresh.workspace, 'new', JSON.stringify(fresh)) + assert.equal(fresh.success, false, 'the new workspace is empty') + console.log('PASS TTL expiry reported once, then a new empty workspace') + await toolset.destroyAll() } assert.deepEqual(await remaining(), []) - console.log('PASS six tools, state, errors, background and cleanup') } async function directExecutor() { - const executor = new E2BCodeExecutor(options) + const executor = new E2BCodeExecutor(options()) + const ctx = invocation(runId) try { for (const [language, code] of [ [CodeExecutionLanguage.PYTHON, 'print(21 * 2)'], @@ -138,69 +351,196 @@ async function directExecutor() { assert.deepEqual(await remaining(), []) } -async function agentTools() { - const toolset = new E2BToolset(options) - const runner = new InMemoryRunner({ - agent: new LlmAgent({ - name: 'sandbox_smoke', - model: 'gemini-2.5-flash', - instruction: - 'Use write_file to save the requested file, then run_code to read it and compute. Always actually call tools. Return the computed answer.', - tools: [toolset], - generateContentConfig: { - temperature: 0, - thinkingConfig: { thinkingBudget: 0 }, - }, - }), +/** L6: the adk web / api_server shape, driven by Gemini as real users would. */ +async function agent() { + const toolset = new E2BToolset(options()) + let lastRequest = 0 + const deployed: string[] = [] + const deploy = new FunctionTool({ + name: 'deploy_report', + description: 'Publish a report file. Requires human approval.', + parameters: z.object({ path: z.string() }), + requireConfirmation: true, + execute: async ({ path }) => { + deployed.push(path) + return { published: path } + }, }) - const completed: string[] = [] - let output = '' - try { - for await (const event of runner.runEphemeral({ - userId: 'live', - newMessage: { - role: 'user', - parts: [ - { - text: 'Save the text 6,7 into /home/user/numbers.txt. Read it using Python, multiply the two numbers and report the result.', - }, - ], - }, - runConfig: { maxLlmCalls: 6 }, + const rootAgent = new LlmAgent({ + name: 'analyst', + // LIVE_MODEL=openai uses a small OpenAI model when Gemini is rate limited. + model: + process.env.LIVE_MODEL === 'openai' + ? new OpenAIModel() + : 'gemini-2.5-flash', + instruction: + 'You work in a sandbox that persists for the whole conversation. Always use the tools; never guess file contents. Report tool results exactly.', + tools: [toolset, deploy], + generateContentConfig: { + temperature: 0, + thinkingConfig: { thinkingBudget: 0 }, + }, + // Pace requests below free-tier rate limits; this is not a retry. + beforeModelCallback: async () => { + if (process.env.LIVE_MODEL === 'openai') return undefined + const wait = lastRequest + 13_000 - Date.now() + lastRequest = Math.max(Date.now(), lastRequest + 13_000) + if (wait > 0) await sleep(wait) + return undefined + }, + }) + const runner = new InMemoryRunner({ agent: rootAgent, appName: 'live' }) + const say = async ( + userId: string, + sessionId: string, + text: string | Event['content'], + abortSignal?: AbortSignal, + ) => { + const events: Event[] = [] + for await (const event of runner.runAsync({ + userId, + sessionId, + newMessage: + typeof text === 'string' ? { role: 'user', parts: [{ text }] } : text!, + runConfig: { maxLlmCalls: 10 }, + abortSignal, })) { - assert(!event.errorCode, `Model error: ${event.errorCode}`) - for (const part of event.content?.parts ?? []) { - if (part.functionResponse) { - completed.push(part.functionResponse.name ?? '') - assert.equal(part.functionResponse.response?.success, true) - } - if (part.text) output += part.text - } + if (event.errorCode) + throw new Error(`Model error ${event.errorCode}: ${event.errorMessage}`) + events.push(event) + } + const parts = events.flatMap((e) => e.content?.parts ?? []) + return { + parts, + text: parts + .filter((p) => p.text && !p.thought) + .map((p) => p.text) + .join(''), + tools: parts + .filter((p) => p.functionResponse) + .map((p) => p.functionResponse!), } - assert(completed.includes('write_file')) - assert(completed.includes('run_code')) - assert.match(output, /42/) - assert.deepEqual(await remaining(), [], 'Runner must close the sandbox') - } finally { - await toolset.close() } - console.log('PASS Gemini tool routing, file analysis and runner cleanup') + const users = await Promise.all( + ['alice', 'bob'].map((userId) => + runner.sessionService.createSession({ appName: 'live', userId }), + ), + ) + + const firstTurn = await Promise.all( + users.map((s) => + say( + s.userId, + s.id, + `Write the text "${s.userId}-secret-${runId.slice(0, 6)}" to /home/user/note.txt, and store the number ${s.userId.length * 7} in a Python variable named n.`, + ), + ), + ) + for (const turn of firstTurn) + assert( + turn.tools.some((t) => t.name === 'write_file' && t.response?.success), + ) + assert.equal((await remaining()).length, 2, 'one sandbox per session') + console.log('PASS two concurrent users got separate workspaces') + + for (const s of users) { + const turn = await say( + s.userId, + s.id, + 'Read /home/user/note.txt and print the Python variable n. Reply with both values.', + ) + const other = s.userId === 'alice' ? 'bob' : 'alice' + assert.match(turn.text, new RegExp(`${s.userId}-secret`)) + assert.doesNotMatch(turn.text, new RegExp(`${other}-secret`)) + assert.match(turn.text, new RegExp(String(s.userId.length * 7))) + console.log( + `PASS ${s.userId}: files and kernel persisted into the next message`, + ) + } + + const alice = users[0]! + const paused = await say( + alice.userId, + alice.id, + 'Write "quarterly: 42" to /home/user/report.txt, then publish it with deploy_report, then read it back and tell me its content.', + ) + const request = paused.parts.find( + (p) => p.functionCall?.name === 'adk_request_confirmation', + )?.functionCall + assert(request, 'deploy_report asked for confirmation') + const resumed = await say(alice.userId, alice.id, { + role: 'user', + parts: [ + { + functionResponse: { + id: request.id, + name: request.name, + response: { confirmed: true }, + }, + }, + ], + }) + assert.deepEqual(deployed, ['/home/user/report.txt']) + assert.match(resumed.text + JSON.stringify(resumed.tools), /quarterly: 42/) + console.log('PASS workspace survived the confirmation pause') + + const bob = users[1]! + const abort = new AbortController() + const started = Date.now() + const cancelled = say( + bob.userId, + bob.id, + 'Run exactly this shell command with run_command and timeoutMs 300000: touch /tmp/started && sleep 240', + abort.signal, + ) + setTimeout(() => abort.abort(), 8000) + await cancelled + assert(Date.now() - started < 30_000, 'cancelled run returned promptly') + const { call } = client(toolset) + const left = await call( + 'run_command', + { + command: 'test -f /tmp/started && echo started; pgrep -xc sleep || true', + }, + invocation(bob.id, bob.userId), + ) + assert.equal( + String(left.stdout).trim(), + 'started\n0', + 'the command ran and no sleep process is left', + ) + console.log('PASS runner abort stopped the command in the sandbox') + + await toolset.destroyAll() + assert.deepEqual(await remaining(), []) + console.log('PASS destroyAll released every session workspace') } +const modes: Record Promise> = { + direct: directTools, + stop: stopMechanisms, + executor: directExecutor, + lifecycle, + agent, +} +let failed = false try { - const mode = process.argv[2] ?? 'all' - if (mode === 'all' || mode === 'direct') { - await directTools() - await directExecutor() - } - if (mode === 'all' || mode === 'agent') await agentTools() + const selected = process.argv[2] ?? 'all' + for (const [name, run] of Object.entries(modes)) + if (selected === 'all' || selected.split(',').includes(name)) await run() +} catch (error) { + failed = true + if (/429|RESOURCE_EXHAUSTED/.test(String(error))) + console.log('INCONCLUSIVE: model rate limit (HTTP 429)') + throw error } finally { // Only clean up this run's unique metadata-tagged workspaces. const leaked = await remaining() for (const id of leaked) await Sandbox.kill(id) - assert.equal( - leaked.length, - 0, - 'Fallback cleanup was needed: investigate lifecycle', - ) + if (!failed) + assert.equal( + leaked.length, + 0, + 'Fallback cleanup was needed: investigate lifecycle', + ) } diff --git a/scripts/openai-model.ts b/scripts/openai-model.ts new file mode 100644 index 0000000..84d47b1 --- /dev/null +++ b/scripts/openai-model.ts @@ -0,0 +1,148 @@ +// Test-only: ADK 2.1.0 has no OpenAI provider. Used by the live harness when +// Gemini is rate limited. Not part of the published package. +import { + BaseLlm, + type BaseLlmConnection, + type LlmRequest, + type LlmResponse, +} from '@google/adk' + +type Json = Record + +/** Gemini Schema (uppercase types, string lengths) to JSON Schema. */ +function jsonSchema(schema: unknown): unknown { + if (Array.isArray(schema)) return schema.map(jsonSchema) + if (!schema || typeof schema !== 'object') return schema + const out: Json = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'type' && typeof value === 'string') + out[key] = value.toLowerCase() + else if (/^(min|max)(Length|Items)$/.test(key)) out[key] = Number(value) + else out[key] = jsonSchema(value) + } + return out +} + +export class OpenAIModel extends BaseLlm { + constructor(model = 'gpt-4.1-mini') { + super({ model }) + } + + override async *generateContentAsync( + request: LlmRequest, + ): AsyncGenerator { + const messages: Json[] = [] + const system = request.config?.systemInstruction as + string | { parts?: { text?: string }[] } | undefined + const instruction = + typeof system === 'string' + ? system + : system?.parts?.map((p) => p.text ?? '').join('') + if (instruction) messages.push({ role: 'system', content: instruction }) + const pending = new Set() + for (const content of request.contents) { + const parts = content.parts ?? [] + const text = parts + .filter((p) => p.text && !p.thought) + .map((p) => p.text) + .join('') + if (content.role === 'model') { + const calls = parts.filter((p) => p.functionCall) + for (const p of calls) pending.add(p.functionCall!.id!) + if (text || calls.length) + messages.push({ + role: 'assistant', + content: text || null, + ...(calls.length && { + tool_calls: calls.map((p) => ({ + id: p.functionCall!.id, + type: 'function', + function: { + name: p.functionCall!.name, + arguments: JSON.stringify(p.functionCall!.args ?? {}), + }, + })), + }), + }) + } else { + for (const p of parts.filter((p) => p.functionResponse)) { + const id = p.functionResponse!.id! + // Framework-internal responses (e.g. confirmations) have no model call. + if (!pending.delete(id)) continue + messages.push({ + role: 'tool', + tool_call_id: id, + content: JSON.stringify(p.functionResponse!.response), + }) + } + if (text) messages.push({ role: 'user', content: text }) + } + } + const tools = (request.config?.tools ?? []) + .flatMap((t) => + 'functionDeclarations' in t ? (t.functionDeclarations ?? []) : [], + ) + .map((d) => ({ + type: 'function', + function: { + name: d.name, + description: d.description, + parameters: jsonSchema( + d.parameters ?? + d.parametersJsonSchema ?? { type: 'object', properties: {} }, + ), + }, + })) + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: this.model, + messages, + temperature: 0, + ...(tools.length && { tools, parallel_tool_calls: false }), + }), + }) + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: { message?: string } + } + throw new Error(`OpenAI ${response.status}: ${body.error?.message ?? ''}`) + } + const message = ( + (await response.json()) as { + choices: { + message: { + content: string | null + tool_calls?: { + id: string + function: { name: string; arguments: string } + }[] + } + }[] + } + ).choices[0]!.message + yield { + content: { + role: 'model', + parts: [ + ...(message.content ? [{ text: message.content }] : []), + ...(message.tool_calls ?? []).map((call) => ({ + functionCall: { + id: call.id, + name: call.function.name, + args: JSON.parse(call.function.arguments) as Json, + }, + })), + ], + }, + } + } + + override async connect(): Promise { + throw new Error('Live streaming is not supported by this test adapter') + } +} From 746f178c14c94ec3e07ae74749217836194cfa46 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Wed, 23 Sep 2026 16:02:00 +0200 Subject: [PATCH 09/23] fix: fail closed when cleanup or a stop cannot be confirmed - destroy() waits for a sandbox that is still being created and kills it; if that kill fails the handle is kept, reuse is blocked and a retry works. - A stopped command counts as gone only when the process-group kill succeeds or reports that no process is left, and the command exits. Otherwise the workspace is blocked until destroySession(); there is no leader-only fallback. - Every async setup step rechecks destroy, block and cancellation before side effects, and a result is not reported as a success after a destroy. - The deadline covers command start and kernel start-up. A handle that arrives after the deadline is killed; code that never started does not claim a kernel restart. --- README.md | 4 +- scripts/live.ts | 23 ++++ src/code-executor.ts | 2 +- src/toolset.ts | 13 +- src/workspace.ts | 234 ++++++++++++++++++++++----------- tests/workspace.test.ts | 279 ++++++++++++++++++++++++++++++++++------ 6 files changed, 439 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 72dabcf..a245d35 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The call that creates a workspace also returns `workspace: "new"`. If the sandbo One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. -ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox, fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. +ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. `runEphemeral()` creates a session whose id your code never sees, so each call leaves a workspace that only `destroyAll()` or the sandbox timeout releases. Prefer `runAsync()` with sessions you create. Workspaces live in the toolset's memory: a deployment with several server processes gives each process its own workspace for the same session. @@ -97,7 +97,7 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are kept until destroyed, so release finished sessions. -`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. If a kernel cannot be restarted, the workspace is blocked until `destroySession()`. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. +`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. diff --git a/scripts/live.ts b/scripts/live.ts index 5e303a9..cdf9e9f 100644 --- a/scripts/live.ts +++ b/scripts/live.ts @@ -197,6 +197,29 @@ async function stopMechanisms() { console.log(`PASS command abort after ${delay} ms leaves no process`) } + // The deadline covers the start request: a 1 ms limit kills the late handle. + result = await raw( + 'run_command', + { command: loop('/tmp/start'), timeoutMs: 1 }, + ctx, + ) + assert.equal(result.timed_out, true, JSON.stringify(result)) + await stopped(ctx, '/tmp/start', 'sleep 600') + console.log('PASS a deadline during command start kills the late process') + + // ...and kernel start-up: code that never started does not claim a restart. + result = await raw( + 'run_code', + { code: 'System.out.println(1);', language: 'java', timeoutMs: 1 }, + ctx, + ) + assert.equal(result.timed_out, true, JSON.stringify(result)) + assert.equal(result.kernel_restarted, undefined) + assert.match(String(result.error), /did not run/) + console.log( + 'PASS a deadline during kernel start-up reports that code did not run', + ) + const loops: Record = { python: ['v = 1', 'while True:\n pass', 'print(v)'], javascript: ['var v = 1', 'while (true) {}', 'console.log(v)'], diff --git a/src/code-executor.ts b/src/code-executor.ts index 0f36515..238f531 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -81,7 +81,7 @@ export class E2BCodeExecutor extends BaseCodeExecutor { ) if ('stopped' in outcome) return fail( - `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. The kernel was restarted; its variables and imports were lost.`, + `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. ${outcome.kernelRestarted ? 'The kernel was restarted; its variables and imports were lost.' : 'The code did not run.'}`, ) const { execution } = outcome const text = execution.text ? `\n${execution.text}` : '' diff --git a/src/toolset.ts b/src/toolset.ts index 76ef997..5486e87 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -108,10 +108,15 @@ export class E2BToolset extends BaseToolset { outcome.stopped === 'timed_out' ? `exceeded ${ms} ms` : 'was cancelled' - return failure( - `Execution ${why}. The ${input.language} kernel was restarted; its variables and imports were lost.`, - { [outcome.stopped]: true, kernel_restarted: true }, - ) + return outcome.kernelRestarted + ? failure( + `Execution ${why}. The ${input.language} kernel was restarted; its variables and imports were lost.`, + { [outcome.stopped]: true, kernel_restarted: true }, + ) + : failure( + `Execution ${why} while the ${input.language} kernel was starting; the code did not run.`, + { [outcome.stopped]: true }, + ) } const { execution } = outcome return { diff --git a/src/workspace.ts b/src/workspace.ts index caddc4b..17e5cee 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -23,7 +23,8 @@ const RECOVERY_WAIT_MS = 30_000 const SDK_GRACE_MS = 5_000 export type Stop = 'timed_out' | 'aborted' -export type CodeOutcome = { execution: Execution } | { stopped: Stop } +export type CodeOutcome = + { execution: Execution } | { stopped: Stop; kernelRestarted: boolean } export type CommandOutcome = { result: CommandResult } | { stopped: Stop; stdout: string; stderr: string } @@ -83,6 +84,19 @@ function turn(previous: Promise, signal?: AbortSignal) { }) } +type Settled = { value: T } | { error: unknown } | undefined + +/** How `promise` settled within `ms`; undefined if it did not. */ +function settleWithin(promise: Promise, ms: number): Promise> { + return withTimeout( + promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ), + ms, + ).catch(() => undefined) +} + /** Aborts on the caller's signal or at the deadline, whichever comes first. */ function stopper(signal: AbortSignal | undefined, timeoutMs: number) { let reason: Stop | undefined @@ -110,12 +124,21 @@ function stopper(signal: AbortSignal | undefined, timeoutMs: number) { } } +type Operation = ( + sandbox: Sandbox, + current: Current, + /** Throws if the workspace was destroyed or blocked since the operation started. */ + check: () => void, +) => Promise + /** * One lazily created, serialized sandbox for one ADK session. An expired or * destroyed sandbox is never silently replaced inside a running operation. */ export class Workspace { private current?: Current + /** An in-flight create; destroy() owns its result. */ + private creating?: Promise private queue: Promise = Promise.resolve() private epoch = 0 private pending = 0 @@ -137,7 +160,7 @@ export class Workspace { } run( - operation: (sandbox: Sandbox, current: Current) => Promise, + operation: Operation, info: RunInfo, signal?: AbortSignal, ): Promise { @@ -154,48 +177,41 @@ export class Workspace { } private async serve( - operation: (sandbox: Sandbox, current: Current) => Promise, + operation: Operation, info: RunInfo, epoch: number, signal?: AbortSignal, ): Promise { - if (epoch !== this.epoch) throw new WorkspaceDestroyedError() - if (this.blocked) throw this.blocked + const check = () => { + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + if (this.blocked) throw this.blocked + } + check() if (signal?.aborted) throw new OperationAbortedError() let current = this.current if (current) { try { await current.sandbox.setTimeout(this.ttl) } catch (error) { - if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + check() // A paused sandbox also reports 404 here; connect() resumes it. // Only a sandbox that cannot be resumed is gone. if ( error instanceof SandboxNotFoundError && !(await this.resume(current)) ) { + check() if (this.current === current) this.current = undefined throw new WorkspaceExpiredError() } - if (epoch !== this.epoch) throw new WorkspaceDestroyedError() } } else { - const sandbox = this.options.template - ? await Sandbox.create( - this.options.template, - this.options.sandboxOptions, - ) - : await Sandbox.create(this.options.sandboxOptions) - if (epoch !== this.epoch) { - // ponytail: best effort; if this kill fails, the sandbox TTL releases it. - await withTimeout(sandbox.kill(), RECOVERY_WAIT_MS).catch( - () => undefined, - ) - throw new WorkspaceDestroyedError() - } - current = this.current = { sandbox, contexts: new Map() } + current = await this.create(epoch) info.created = true } + // Setup awaited the SDK: recheck before any side effect. + check() + if (signal?.aborted) throw new OperationAbortedError() const { sandbox } = current const refresh = () => sandbox.setTimeout(this.ttl).catch(() => { @@ -217,7 +233,9 @@ export class Workspace { } schedule() try { - return await operation(sandbox, current) + const value = await operation(sandbox, current, check) + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + return value } catch (error) { if (epoch !== this.epoch) throw new WorkspaceDestroyedError() throw error @@ -229,6 +247,29 @@ export class Workspace { } } + private async create(epoch: number): Promise { + const creating = this.options.template + ? Sandbox.create(this.options.template, this.options.sandboxOptions) + : Sandbox.create(this.options.sandboxOptions) + this.creating = creating.catch(() => undefined) + let sandbox: Sandbox + try { + sandbox = await creating + } finally { + this.creating = undefined + } + // Destroyed meanwhile: destroy() kills this sandbox and keeps it if that fails. + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + return (this.current = { sandbox, contexts: new Map() }) + } + + /** Block reuse until destroy() succeeds, unless this sandbox is already gone. */ + private block(current: Current, message: string): Error { + const error = new WorkspaceBlockedError(`${message} ${this.cleanupHint}`) + if (this.current === current) this.blocked = error + return error + } + runCode( code: string, language: string, @@ -237,39 +278,51 @@ export class Workspace { { signal, timeoutMs }: RunOptions, ): Promise { return this.run( - async (sandbox, current) => { - const context = await this.context(current, language) - if (signal?.aborted) throw new OperationAbortedError() + async (sandbox, current, check) => { + // The deadline covers kernel start-up too. const stop = stopper(signal, timeoutMs) - const execution = sandbox.runCode(code, { - context, - envs, - timeoutMs: timeoutMs + SDK_GRACE_MS, - }) - execution.catch(() => undefined) try { - return { execution: await stop.race(execution) } - } catch (error) { - const reason = - stop.reason ?? - (error instanceof TimeoutError ? 'timed_out' : undefined) - if (!reason) throw error - // A timed-out kernel stays busy; only a restart stops the code. + let context: Context try { - await withTimeout( - sandbox.restartCodeContext(context), - RECOVERY_WAIT_MS, - ) - } catch { - const error = new WorkspaceBlockedError( - `The ${language} kernel could not be restarted after the code was stopped. ${this.cleanupHint}`, - ) - if (this.current === current) this.blocked = error + context = await stop.race(this.context(current, language)) + } catch (error) { + // Nothing ran yet; the context stays cached for the next call. + if (stop.reason) + return { stopped: stop.reason, kernelRestarted: false } throw error } - // The restart closes the open stream; don't let it outlive this turn. - await withTimeout(execution, RECOVERY_WAIT_MS).catch(() => undefined) - return { stopped: reason } + check() + const execution = sandbox.runCode(code, { + context, + envs, + timeoutMs: timeoutMs + SDK_GRACE_MS, + }) + execution.catch(() => undefined) + try { + return { execution: await stop.race(execution) } + } catch (error) { + const reason = + stop.reason ?? + (error instanceof TimeoutError ? 'timed_out' : undefined) + if (!reason) throw error + // A timed-out kernel stays busy; only a restart stops the code. + try { + await withTimeout( + sandbox.restartCodeContext(context), + RECOVERY_WAIT_MS, + ) + } catch { + throw this.block( + current, + `The ${language} kernel could not be restarted after the code was stopped.`, + ) + } + // The restart closes the open stream; don't let it outlive this turn. + await withTimeout(execution, RECOVERY_WAIT_MS).catch( + () => undefined, + ) + return { stopped: reason, kernelRestarted: true } + } } finally { stop.dispose() } @@ -286,23 +339,53 @@ export class Workspace { { signal, timeoutMs }: RunOptions, ): Promise { return this.run( - async (sandbox) => { - // Own process group, so stopping the command also stops its children. - // No abort signal here: aborting the start request leaves the process running. - const handle = await sandbox.commands.run( - `exec setsid -w /bin/bash -c ${quote(command)}`, - { ...options, background: true, timeoutMs: 0 }, - ) + async (sandbox, current) => { + // The deadline covers the start request too. const stop = stopper(signal, timeoutMs) try { - return { result: await stop.race(handle.wait()) } - } catch (error) { - if (error instanceof CommandExitError && !stop.reason) - return { result: error } - if (!stop.reason) throw error - await this.killGroup(sandbox, handle) + // Own process group, so stopping the command also stops its children. + // No abort signal here: aborting the start request leaves the process running. + const starting = sandbox.commands.run( + `exec setsid -w /bin/bash -c ${quote(command)}`, + { ...options, background: true, timeoutMs: 0 }, + ) + let handle: CommandHandle + try { + handle = await stop.race(starting) + } catch (error) { + if (!stop.reason) throw error + // Stopped while starting: the process may still start, so own it. + const late = await settleWithin(starting, RECOVERY_WAIT_MS) + if (!late) { + void starting.then( + (handle) => this.stopGroup(sandbox, handle), + () => undefined, + ) + throw this.block( + current, + 'The command was stopped while starting and could not be confirmed stopped.', + ) + } + if ('error' in late) throw late.error + handle = late.value + } + if (!stop.reason) { + try { + return { result: await stop.race(handle.wait()) } + } catch (error) { + if (!stop.reason) { + if (error instanceof CommandExitError) return { result: error } + throw error + } + } + } + if (!(await this.stopGroup(sandbox, handle))) + throw this.block( + current, + 'The command could not be confirmed stopped.', + ) return { - stopped: stop.reason, + stopped: stop.reason!, stdout: handle.stdout, stderr: handle.stderr, } @@ -315,20 +398,24 @@ export class Workspace { ) } - private async killGroup(sandbox: Sandbox, handle: CommandHandle) { + /** SIGKILL the command's process group; true only if it is confirmed gone. */ + private async stopGroup(sandbox: Sandbox, handle: CommandHandle) { try { await withTimeout( sandbox.commands.run(`kill -9 -- -${handle.pid}`), RECOVERY_WAIT_MS, ) - } catch { - // The group may be gone already; still make sure the leader is. - await withTimeout(handle.kill(), RECOVERY_WAIT_MS) + } catch (error) { + // kill reports "No such process" once the whole group has exited. + // Any other failure leaves the group's state unknown. + if (!( + error instanceof CommandExitError && + /No such process/.test(error.stderr) + )) + return false } - await withTimeout( - handle.wait().catch(() => undefined), - RECOVERY_WAIT_MS, - ) + const exited = await settleWithin(handle.wait(), RECOVERY_WAIT_MS) + return exited !== undefined } private async resume(current: Current): Promise { @@ -361,6 +448,9 @@ export class Workspace { private async kill() { this.epoch++ + // A create still in flight belongs to this destroy, even if it lands later. + const late = await this.creating + if (late) this.current = { sandbox: late, contexts: new Map() } const current = this.current if (!current) return try { diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 976047c..295b3a4 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -133,7 +133,7 @@ describe('workspace lifecycle', () => { if (how === 'abort') abort.abort() if (how === 'SDK timeout') running.reject(new TimeoutError('timed out')) await vi.advanceTimersByTimeAsync(1000) - expect(await result).toEqual({ stopped }) + expect(await result).toEqual({ stopped, kernelRestarted: true }) expect(sandbox.restartCodeContext).toHaveBeenCalledWith({ id: 'ctx-python', language: 'python', @@ -231,26 +231,6 @@ describe('workspace lifecycle', () => { }, ) - it('falls back to killing the command itself when the group kill fails', async () => { - vi.useFakeTimers() - const { sandbox } = mockSandbox() - const exited = deferred() - exited.promise.catch(() => undefined) - sandbox.handle.wait.mockReturnValueOnce(exited.promise) - sandbox.handle.kill.mockImplementation(async () => { - exited.reject(new Error('killed')) - return true - }) - sandbox.commands.run.mockImplementation(async (_command, opts) => { - if (opts?.background) return sandbox.handle - throw new Error('no such group') - }) - const result = workspace().runCommand('x', {}, info(), { timeoutMs: 1000 }) - await vi.advanceTimersByTimeAsync(1000) - expect(await result).toMatchObject({ stopped: 'timed_out' }) - expect(sandbox.handle.kill).toHaveBeenCalledTimes(1) - }) - it('destroy fails queued work without recreating, rejects new work meanwhile, then allows a new workspace', async () => { const { sandbox, create } = mockSandbox() const ws = workspace() @@ -280,22 +260,6 @@ describe('workspace lifecycle', () => { expect(create).toHaveBeenCalledTimes(2) }) - it('kills a sandbox whose creation finished after destroy', async () => { - const { sandbox, create } = mockSandbox() - const pending = deferred() - create.mockReturnValueOnce(pending.promise) - const ws = workspace() - const work = vi.fn() - const first = ws.run(work, info()) - await tick() - await ws.destroy() - pending.resolve(sandbox as unknown as Sandbox) - await expect(first).rejects.toBeInstanceOf(WorkspaceDestroyedError) - expect(work).not.toHaveBeenCalled() - expect(sandbox.kill).toHaveBeenCalledTimes(1) - expect(ws.idle).toBe(true) - }) - it('keeps a failed kill handle blocked and retryable', async () => { const { sandbox, create } = mockSandbox() const ws = workspace() @@ -385,3 +349,244 @@ describe('workspace lifecycle', () => { expect(sandbox.setTimeout.mock.calls.length - calls).toBe(7) }) }) + +describe('cleanup and deadline boundaries', () => { + const noSuchGroup = () => + new CommandExitError({ + stdout: '', + stderr: 'bash: kill: (-101) - No such process\n', + exitCode: 1, + error: 'exit status 1', + }) + + it('destroy waits for an in-flight create and kills the late sandbox', async () => { + const { sandbox, create } = mockSandbox() + const pending = deferred() + create.mockReturnValueOnce(pending.promise) + const ws = workspace() + const work = vi.fn() + const first = ws.run(work, info()) + await tick() + let destroyed = false + const destroy = ws.destroy().then(() => (destroyed = true)) + await tick() + expect(destroyed).toBe(false) + pending.resolve(sandbox as unknown as Sandbox) + await destroy + expect(sandbox.kill).toHaveBeenCalledTimes(1) + await expect(first).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(work).not.toHaveBeenCalled() + }) + + it('keeps a late sandbox whose kill failed blocked and retryable', async () => { + const { sandbox, create } = mockSandbox() + const pending = deferred() + create.mockReturnValueOnce(pending.promise) + sandbox.kill.mockRejectedValueOnce(new Error('network down')) + const ws = workspace() + const first = ws.run(async () => 1, info()).catch((error) => error) + await tick() + const destroy = ws.destroy() + pending.resolve(sandbox as unknown as Sandbox) + await expect(destroy).rejects.toThrow('Sandbox cleanup failed') + expect(await first).toBeInstanceOf(WorkspaceDestroyedError) + expect(ws.idle).toBe(false) + const work = vi.fn() + await expect(ws.run(work, info())).rejects.toThrow('Retry destroy.') + expect(work).not.toHaveBeenCalled() + await ws.destroy() + expect(sandbox.kill).toHaveBeenCalledTimes(2) + expect(ws.idle).toBe(true) + await ws.run(async () => 2, info()) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('does not start an operation whose workspace was destroyed during the TTL refresh', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + const refresh = deferred() + sandbox.setTimeout.mockReturnValueOnce(refresh.promise) + const effect = vi.fn(async () => 42) + const run = ws.run(effect, info()) + await tick() + await ws.destroy() + refresh.resolve() + await expect(run).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(effect).not.toHaveBeenCalled() + }) + + it('does not start an operation after a failed destroy blocked the live sandbox', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + const refresh = deferred() + sandbox.setTimeout.mockReturnValueOnce(refresh.promise) + const effect = vi.fn(async () => 42) + const run = ws.run(effect, info()) + await tick() + sandbox.kill.mockRejectedValueOnce(new Error('down')) + await expect(ws.destroy()).rejects.toThrow('Sandbox cleanup failed') + refresh.resolve() + await expect(run).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(effect).not.toHaveBeenCalled() + }) + + it('does not run code whose workspace was destroyed while its context was created', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + await ws.run(async () => 1, info()) + const context = deferred<{ id: string; language: string; cwd: string }>() + sandbox.createCodeContext.mockReturnValueOnce(context.promise) + const run = ws.runCode('x', 'python', undefined, info(), { + timeoutMs: 10_000, + }) + await tick() + await ws.destroy() + context.resolve({ id: 'late', language: 'python', cwd: '/home/user' }) + await expect(run).rejects.toBeInstanceOf(WorkspaceDestroyedError) + expect(sandbox.runCode).not.toHaveBeenCalled() + }) + + it('does not report success for an operation that finished after destroy', async () => { + mockSandbox() + const ws = workspace() + const gate = deferred() + const run = ws.run(() => gate.promise, info()) + await tick() + await tick() + await ws.destroy() + gate.resolve(42) + await expect(run).rejects.toBeInstanceOf(WorkspaceDestroyedError) + }) + + it('blocks the workspace instead of trusting a leader-only kill when the group kill fails', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const exited = deferred() + exited.promise.catch(() => undefined) + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + sandbox.handle.kill.mockImplementation(async () => { + exited.reject(new Error('killed')) + return true + }) + sandbox.commands.run.mockImplementation(async (_command, opts) => { + if (opts?.background) return sandbox.handle + throw new Error('network failure') + }) + const ws = workspace() + const run = ws + .runCommand('loop', {}, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(100) + const error = await run + expect(error).toBeInstanceOf(WorkspaceBlockedError) + expect(error.message).toContain('could not be confirmed') + expect(sandbox.handle.kill).not.toHaveBeenCalled() + const effect = vi.fn(async () => 42) + await expect(ws.run(effect, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + expect(effect).not.toHaveBeenCalled() + await ws.destroy() + expect(await ws.run(effect, info())).toBe(42) + }) + + it('blocks the workspace when the command does not exit after its group was killed', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + sandbox.handle.wait.mockReturnValue(new Promise(() => {})) + const ws = workspace() + const run = ws + .runCommand('loop', {}, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(31_000) + expect(await run).toBeInstanceOf(WorkspaceBlockedError) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + }) + + it('treats "No such process" from the group kill as confirmed absence', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const exited = deferred() + exited.promise.catch(() => undefined) + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + sandbox.commands.run.mockImplementation(async (_command, opts) => { + if (opts?.background) return sandbox.handle + exited.reject(new Error('exited')) + throw noSuchGroup() + }) + const ws = workspace() + const run = ws.runCommand('x', {}, info(), { timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await run).toMatchObject({ stopped: 'timed_out' }) + expect(await ws.run(async () => 1, info())).toBe(1) + }) + + it('counts a slow command start against the deadline and kills the late handle', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const exited = deferred() + exited.promise.catch(() => undefined) + sandbox.handle.wait.mockReturnValueOnce(exited.promise) + const start = deferred() + sandbox.commands.run.mockImplementation(async (command, opts) => { + if (opts?.background) return start.promise + if (command === 'kill -9 -- -101') exited.reject(new Error('killed')) + return { stdout: '', stderr: '', exitCode: 0 } + }) + const ws = workspace() + let settled = false + const run = ws + .runCommand('echo x', {}, info(), { timeoutMs: 100 }) + .finally(() => (settled = true)) + await vi.advanceTimersByTimeAsync(10_000) + expect(settled).toBe(false) + start.resolve(sandbox.handle) + await vi.advanceTimersByTimeAsync(0) + expect(await run).toMatchObject({ stopped: 'timed_out' }) + expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') + }) + + it('blocks the workspace when a stopped command never delivers its handle, and kills it if it arrives', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const start = deferred() + sandbox.commands.run.mockImplementation(async (_command, opts) => + opts?.background + ? start.promise + : { stdout: '', stderr: '', exitCode: 0 }, + ) + const ws = workspace() + const run = ws + .runCommand('echo x', {}, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(31_000) + expect(await run).toBeInstanceOf(WorkspaceBlockedError) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + start.resolve(sandbox.handle) + await vi.advanceTimersByTimeAsync(0) + expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') + }) + + it('counts context creation against the deadline and does not claim a kernel restart', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const context = deferred<{ id: string; language: string; cwd: string }>() + sandbox.createCodeContext.mockReturnValueOnce(context.promise) + const ws = workspace() + const run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: false }) + expect(sandbox.runCode).not.toHaveBeenCalled() + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + context.resolve({ id: 'ctx-java', language: 'java', cwd: '/home/user' }) + await vi.advanceTimersByTimeAsync(0) + await ws.runCode('y', 'java', undefined, info(), { timeoutMs: 100 }) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) + }) +}) From 20eca6e42e9135afa8fa11847e8a9c7365f8b09b Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 24 Sep 2026 15:11:00 +0200 Subject: [PATCH 10/23] fix: treat uncertain SDK outcomes as possibly still running A rejected SDK call does not prove that nothing ran. A command start fails as an ordinary error only when the sandbox refuses it before creating a process (InvalidArgumentError, e.g. a missing cwd); any other start failure blocks the workspace until destroySession(). A lost command stream is followed by a process-group kill and a lost code stream by a kernel restart before the error is returned; if either cannot be confirmed, the workspace is blocked. Background starts follow the same rule. --- README.md | 2 +- scripts/live.ts | 18 +++++ src/toolset.ts | 49 ++++++-------- src/workspace.ts | 109 +++++++++++++++++++++++++---- tests/tools.test.ts | 30 ++++++++ tests/workspace.test.ts | 147 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index a245d35..ba612aa 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are kept until destroyed, so release finished sessions. -`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. +`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart, and a start with an unknown outcome blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. diff --git a/scripts/live.ts b/scripts/live.ts index cdf9e9f..84f78e7 100644 --- a/scripts/live.ts +++ b/scripts/live.ts @@ -128,6 +128,24 @@ async function directTools() { [exit.stdout, exit.stderr, exit.exit_code], ['shell ok', 'err\n', 7], ) + // A start the sandbox refuses is an ordinary, correctable error. + for (const name of ['run_command', 'start_background_command']) { + const refused = await client(toolset).raw( + name, + { command: 'pwd', cwd: '/does/not/exist' }, + ctx, + ) + assert.equal( + refused.error, + 'Sandbox operation failed (InvalidArgumentError).', + JSON.stringify(refused), + ) + } + assert.equal( + (await call('run_command', { command: 'pwd', cwd: '/tmp' }, ctx)).stdout, + '/tmp\n', + ) + console.log('PASS a refused start (missing cwd) does not block the workspace') const samples: Record = { python: ['v = 6 * 7', 'print(v)'], javascript: ['var v = 6 * 7', 'console.log(v)'], diff --git a/src/toolset.ts b/src/toolset.ts index 5486e87..4920f3d 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -225,36 +225,29 @@ export class E2BToolset extends BaseToolset { ), port: z.number().int().min(1).max(65535).optional(), }), - (input, workspace, info, signal) => - workspace.run( - async (sandbox) => { - const handle = await sandbox.commands.run(input.command, { - cwd: input.cwd, - envs: input.envs, - // The SDK default (60 s) would kill a disconnected process too. - timeoutMs: input.timeoutMs ?? 0, - background: true, - }) - // Stop consuming the SDK event stream without killing the process. - await handle.disconnect() - let previewUrl: string | null = null - if (input.port !== undefined) { - try { - previewUrl = `https://${sandbox.getHost(input.port)}` - } catch { - /* Preserve the started process PID. */ - } - } - return { - success: true, - pid: handle.pid, - preview_url: previewUrl, - ...(input.port === undefined ? {} : { readiness: 'unknown' }), - } - }, + async (input, workspace, info, signal) => { + const started = await workspace.startBackground( + input.command, + { cwd: input.cwd, envs: input.envs }, + input.timeoutMs, info, signal, - ), + ) + let previewUrl: string | null = null + if (input.port !== undefined) { + try { + previewUrl = `https://${started.host(input.port)}` + } catch { + /* Preserve the started process PID. */ + } + } + return { + success: true, + pid: started.pid, + preview_url: previewUrl, + ...(input.port === undefined ? {} : { readiness: 'unknown' }), + } + }, ), ] } diff --git a/src/workspace.ts b/src/workspace.ts index 17e5cee..04d7e8f 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,5 +1,7 @@ import { CommandExitError, + InvalidArgumentError, + NotFoundError, Sandbox, SandboxNotFoundError, TimeoutError, @@ -56,6 +58,9 @@ export function operationLimit(options: E2BToolsetOptions): number { return max } +/** The server refused before creating a process (verified live for a bad cwd). */ +const refusedToStart = (error: unknown) => error instanceof InvalidArgumentError + const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'` function withTimeout(promise: Promise, ms: number): Promise { @@ -304,19 +309,23 @@ export class Workspace { const reason = stop.reason ?? (error instanceof TimeoutError ? 'timed_out' : undefined) - if (!reason) throw error - // A timed-out kernel stays busy; only a restart stops the code. - try { - await withTimeout( - sandbox.restartCodeContext(context), - RECOVERY_WAIT_MS, - ) - } catch { - throw this.block( - current, - `The ${language} kernel could not be restarted after the code was stopped.`, - ) + if (!reason) { + // Refused before the code ran: nothing to stop. + if (error instanceof InvalidArgumentError) throw error + if ( + error instanceof NotFoundError && + !(error instanceof SandboxNotFoundError) + ) { + // The kernel server no longer knows this context. + current.contexts.delete(language) + throw error + } + // The stream broke, not necessarily the code: stop it first. + await this.restart(sandbox, current, context, language) + throw error } + // A timed-out kernel stays busy; only a restart stops the code. + await this.restart(sandbox, current, context, language) // The restart closes the open stream; don't let it outlive this turn. await withTimeout(execution, RECOVERY_WAIT_MS).catch( () => undefined, @@ -345,9 +354,11 @@ export class Workspace { try { // Own process group, so stopping the command also stops its children. // No abort signal here: aborting the start request leaves the process running. - const starting = sandbox.commands.run( + const starting = this.start( + sandbox, + current, `exec setsid -w /bin/bash -c ${quote(command)}`, - { ...options, background: true, timeoutMs: 0 }, + { ...options, timeoutMs: 0 }, ) let handle: CommandHandle try { @@ -375,6 +386,12 @@ export class Workspace { } catch (error) { if (!stop.reason) { if (error instanceof CommandExitError) return { result: error } + // The stream broke, not necessarily the command: stop it first. + if (!(await this.stopGroup(sandbox, handle))) + throw this.block( + current, + 'The connection to the command was lost and it could not be confirmed stopped.', + ) throw error } } @@ -398,6 +415,70 @@ export class Workspace { ) } + startBackground( + command: string, + options: { cwd?: string; envs?: Record }, + timeoutMs: number | undefined, + info: RunInfo, + signal?: AbortSignal, + ) { + return this.run( + async (sandbox, current) => { + // The SDK default (60 s) would kill a disconnected process too. + const handle = await this.start(sandbox, current, command, { + ...options, + timeoutMs: timeoutMs ?? 0, + }) + // Stop consuming the SDK event stream without killing the process. + await handle.disconnect() + return { + pid: handle.pid, + host: (port: number) => sandbox.getHost(port), + } + }, + info, + signal, + ) + } + + /** Start in the background; an ambiguous failure may leave it running. */ + private async start( + sandbox: Sandbox, + current: Current, + command: string, + options: { cwd?: string; envs?: Record; timeoutMs: number }, + ): Promise { + try { + return await sandbox.commands.run(command, { + ...options, + background: true, + }) + } catch (error) { + if (refusedToStart(error)) throw error + const name = error instanceof Error ? error.name : 'unknown error' + throw this.block( + current, + `The command start failed (${name}); it may still be running.`, + ) + } + } + + private async restart( + sandbox: Sandbox, + current: Current, + context: Context, + language: string, + ) { + try { + await withTimeout(sandbox.restartCodeContext(context), RECOVERY_WAIT_MS) + } catch { + throw this.block( + current, + `The ${language} kernel could not be restarted, so the code may still be running.`, + ) + } + } + /** SIGKILL the command's process group; true only if it is confirmed gone. */ private async stopGroup(sandbox: Sandbox, handle: CommandHandle) { try { diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 52beb97..4adc056 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { CommandExitError, FileNotFoundError, + InvalidArgumentError, SandboxNotFoundError, } from '@e2b/code-interpreter' import { Context } from '@google/adk' @@ -257,6 +258,35 @@ describe('six tools', () => { expect(warn).not.toHaveBeenCalled() }) + it('lets the model correct a refused command, but blocks after an ambiguous start', async () => { + const { sandbox } = mockSandbox() + const toolset = new E2BToolset() + sandbox.commands.run.mockRejectedValueOnce( + new InvalidArgumentError("[invalid_argument] cwd '/nope' does not exist"), + ) + expect( + await invoke(toolset, 'run_command', { command: 'ls', cwd: '/nope' }), + ).toEqual({ + success: false, + error: 'Sandbox operation failed (InvalidArgumentError).', + workspace: 'new', + }) + expect( + await invoke(toolset, 'run_command', { command: 'ls', cwd: '/tmp' }), + ).toMatchObject({ success: true }) + sandbox.commands.run.mockRejectedValueOnce(new TypeError('fetch failed')) + expect( + await invoke(toolset, 'run_command', { command: 'ls' }), + ).toMatchObject({ + success: false, + error: expect.stringContaining('may still be running'), + }) + expect(await invoke(toolset, 'read_file', { path: '/a' })).toMatchObject({ + success: false, + error: expect.stringContaining('destroySession()'), + }) + }) + it('surfaces sandbox expiry once, then a new workspace', async () => { const { sandbox, create } = mockSandbox() const toolset = new E2BToolset() diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 295b3a4..d781654 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -2,6 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CommandExitError, FileNotFoundError, + InvalidArgumentError, + NotFoundError, + SandboxError, SandboxNotFoundError, TimeoutError, type Sandbox, @@ -590,3 +593,147 @@ describe('cleanup and deadline boundaries', () => { expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) }) }) + +describe('uncertain SDK outcomes', () => { + const invalidCwd = () => + new InvalidArgumentError("[invalid_argument] cwd '/nope' does not exist") + + it('blocks the workspace when a command start fails ambiguously', async () => { + const { sandbox } = mockSandbox() + sandbox.commands.run.mockRejectedValueOnce(new TypeError('fetch failed')) + const ws = workspace() + await expect( + ws.runCommand('x', {}, info(), { timeoutMs: 1000 }), + ).rejects.toThrow('may still be running') + const effect = vi.fn(async () => 1) + await expect(ws.run(effect, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + expect(effect).not.toHaveBeenCalled() + await ws.destroy() + expect(await ws.run(effect, info())).toBe(1) + }) + + it('does not block after the server refuses to start a command', async () => { + const { sandbox } = mockSandbox() + sandbox.commands.run.mockRejectedValueOnce(invalidCwd()) + const ws = workspace() + await expect( + ws.runCommand('x', { cwd: '/nope' }, info(), { timeoutMs: 1000 }), + ).rejects.toBeInstanceOf(InvalidArgumentError) + expect( + await ws.runCommand('x', { cwd: '/tmp' }, info(), { timeoutMs: 1000 }), + ).toHaveProperty('result') + }) + + it('stops the group when an established command stream is lost, then reports the failure', async () => { + const { sandbox } = mockSandbox() + sandbox.handle.wait.mockRejectedValue(new SandboxError('stream lost')) + const ws = workspace() + await expect( + ws.runCommand('x', {}, info(), { timeoutMs: 1000 }), + ).rejects.toThrow('stream lost') + expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') + sandbox.handle.wait.mockResolvedValue({ + stdout: 'ok', + stderr: '', + exitCode: 0, + }) + expect( + await ws.runCommand('y', {}, info(), { timeoutMs: 1000 }), + ).toHaveProperty('result') + }) + + it('blocks when a lost command stream cannot be followed by a confirmed stop', async () => { + const { sandbox } = mockSandbox() + sandbox.handle.wait.mockRejectedValue(new SandboxError('stream lost')) + sandbox.commands.run.mockImplementation(async (_command, opts) => { + if (opts?.background) return sandbox.handle + throw new TypeError('fetch failed') + }) + const ws = workspace() + await expect( + ws.runCommand('x', {}, info(), { timeoutMs: 1000 }), + ).rejects.toBeInstanceOf(WorkspaceBlockedError) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + }) + + it('restarts the kernel when a code stream is lost, then reports the failure', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new Error('connection reset')) + const ws = workspace() + await expect( + ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toThrow('connection reset') + expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) + expect( + await ws.runCode('y', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toHaveProperty('execution') + }) + + it('blocks when a lost code stream cannot be followed by a kernel restart', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new Error('connection reset')) + sandbox.restartCodeContext.mockRejectedValueOnce(new Error('down')) + const ws = workspace() + await expect( + ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toBeInstanceOf(WorkspaceBlockedError) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + }) + + it.each([ + ['a local argument error', () => new InvalidArgumentError('bad')], + ['a missing code context', () => new NotFoundError('context missing')], + ])( + 'does not restart or block after %s before the code ran', + async (_, error) => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(error()) + const ws = workspace() + await expect( + ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toThrow() + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + expect( + await ws.runCode('y', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toHaveProperty('execution') + }, + ) + + it('forgets a code context the kernel server no longer knows', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + await ws.runCode('a', 'python', undefined, info(), { timeoutMs: 1000 }) + sandbox.runCode.mockRejectedValueOnce(new NotFoundError('context missing')) + await expect( + ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toBeInstanceOf(NotFoundError) + await ws.runCode('c', 'python', undefined, info(), { timeoutMs: 1000 }) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(2) + }) + + it('applies the same start rule to background commands', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + sandbox.commands.run.mockRejectedValueOnce(invalidCwd()) + await expect( + ws.startBackground('server', { cwd: '/nope' }, undefined, info()), + ).rejects.toBeInstanceOf(InvalidArgumentError) + expect( + await ws.startBackground('server', {}, undefined, info()), + ).toMatchObject({ pid: 101 }) + expect(sandbox.handle.disconnect).toHaveBeenCalledTimes(1) + sandbox.commands.run.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect( + ws.startBackground('server', {}, undefined, info()), + ).rejects.toBeInstanceOf(WorkspaceBlockedError) + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + }) +}) From c5cd6bb0e3d4bec6f8317501a99bf899c3530f0f Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 24 Sep 2026 17:34:00 +0200 Subject: [PATCH 11/23] fix: tell the caller when recovery resets a kernel After a lost code stream the kernel is restarted, so its variables are gone; the result now says so with kernel_restarted: true instead of a generic failure. A context the kernel server no longer knows returns kernel_reset: true and says the next call starts an empty kernel. Both state that sandbox files are kept. The direct executor reports the same text in stderr. --- README.md | 2 +- src/code-executor.ts | 3 ++- src/results.ts | 25 +++++++++++++++++----- src/toolset.ts | 8 +++++++ src/workspace.ts | 9 +++++--- tests/executor.test.ts | 17 +++++++++++++++ tests/tools.test.ts | 40 +++++++++++++++++++++++++++++++++-- tests/workspace.test.ts | 46 +++++++++++++++++++---------------------- 8 files changed, 113 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ba612aa..735ee59 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are kept until destroyed, so release finished sessions. -`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart, and a start with an unknown outcome blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. +`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. diff --git a/src/code-executor.ts b/src/code-executor.ts index 238f531..04762a5 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -5,7 +5,7 @@ import { type InvocationContext, } from '@google/adk' import type { E2BCodeExecutorOptions } from './options.js' -import { operationError, truncate } from './results.js' +import { kernelLost, operationError, truncate } from './results.js' import { operationLimit, Workspace } from './workspace.js' type ExecuteParams = Parameters[0] @@ -83,6 +83,7 @@ export class E2BCodeExecutor extends BaseCodeExecutor { return fail( `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. ${outcome.kernelRestarted ? 'The kernel was restarted; its variables and imports were lost.' : 'The code did not run.'}`, ) + if ('kernelLost' in outcome) return fail(kernelLost(language, outcome)) const { execution } = outcome const text = execution.text ? `\n${execution.text}` : '' const error = execution.error diff --git a/src/results.ts b/src/results.ts index 803b2da..180820b 100644 --- a/src/results.ts +++ b/src/results.ts @@ -42,6 +42,12 @@ export function failure(error: string, fields: Record = {}) { return { success: false as const, error, ...fields } } +/** The error's class name if it looks like one, never its message. */ +export function errorName(error: unknown): string { + const name = error instanceof Error ? error.name : '' + return /^[A-Za-z]{1,60}(Error|Exception)$/.test(name) ? name : '' +} + /** Adapter errors carry safe text; SDK messages may contain bodies or credentials. */ export function operationError(error: unknown): string { if ( @@ -52,9 +58,18 @@ export function operationError(error: unknown): string { error instanceof OperationAbortedError ) return error.message - const name = error instanceof Error ? error.name : '' - const category = /^[A-Za-z]{1,60}(Error|Exception)$/.test(name) - ? ` (${name})` - : '' - return `Sandbox operation failed${category}.` + const name = errorName(error) + return `Sandbox operation failed${name ? ` (${name})` : ''}.` +} + +/** Tell the caller its kernel state is gone; the sandbox's files are not. */ +export function kernelLost( + language: string, + outcome: { kernelLost: 'restarted' | 'missing'; cause: unknown }, +): string { + const name = errorName(outcome.cause) + const cause = name ? ` (${name})` : '' + return outcome.kernelLost === 'restarted' + ? `The connection to the ${language} kernel was lost${cause}, so it was restarted: its variables and imports were lost. Files in the sandbox are kept.` + : `The ${language} kernel state is no longer available${cause}; the next call starts an empty kernel. Files in the sandbox are kept.` } diff --git a/src/toolset.ts b/src/toolset.ts index 4920f3d..043f38b 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -9,6 +9,7 @@ import { z } from 'zod' import type { E2BToolsetOptions } from './options.js' import { failure, + kernelLost, OperationAbortedError, operationError, SandboxCleanupError, @@ -118,6 +119,13 @@ export class E2BToolset extends BaseToolset { { [outcome.stopped]: true }, ) } + if ('kernelLost' in outcome) + return failure( + kernelLost(input.language, outcome), + outcome.kernelLost === 'restarted' + ? { kernel_restarted: true } + : { kernel_reset: true }, + ) const { execution } = outcome return { success: true, diff --git a/src/workspace.ts b/src/workspace.ts index 04d7e8f..afbcb5f 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -26,7 +26,10 @@ const SDK_GRACE_MS = 5_000 export type Stop = 'timed_out' | 'aborted' export type CodeOutcome = - { execution: Execution } | { stopped: Stop; kernelRestarted: boolean } + | { execution: Execution } + | { stopped: Stop; kernelRestarted: boolean } + /** The kernel's state is gone: restarted after a lost stream, or unknown to the server. */ + | { kernelLost: 'restarted' | 'missing'; cause: unknown } export type CommandOutcome = { result: CommandResult } | { stopped: Stop; stdout: string; stderr: string } @@ -318,11 +321,11 @@ export class Workspace { ) { // The kernel server no longer knows this context. current.contexts.delete(language) - throw error + return { kernelLost: 'missing', cause: error } } // The stream broke, not necessarily the code: stop it first. await this.restart(sandbox, current, context, language) - throw error + return { kernelLost: 'restarted', cause: error } } // A timed-out kernel stays busy; only a restart stops the code. await this.restart(sandbox, current, context, language) diff --git a/tests/executor.test.ts b/tests/executor.test.ts index c28c221..6a776cd 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -106,6 +106,23 @@ describe('code executor', () => { expect(create).toHaveBeenCalledTimes(2) }) + it('reports kernel state lost by stream recovery in stderr', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new TypeError('terminated')) + const result = await new E2BCodeExecutor().executeCode({ + invocationContext: context(), + codeExecutionInput: { + code: 'x', + language: Language.PYTHON, + inputFiles: [], + }, + }) + expect(result.stderr).toBe( + 'The connection to the python kernel was lost (TypeError), so it was restarted: its variables and imports were lost. Files in the sandbox are kept.', + ) + expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) + }) + it('bounds execution and restarts the kernel after a timeout', async () => { const { sandbox } = mockSandbox() sandbox.runCode.mockRejectedValueOnce(new TimeoutError('timed out')) diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 4adc056..fe199c8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -3,6 +3,7 @@ import { CommandExitError, FileNotFoundError, InvalidArgumentError, + NotFoundError, SandboxNotFoundError, } from '@e2b/code-interpreter' import { Context } from '@google/adk' @@ -238,9 +239,12 @@ describe('six tools', () => { code: 'raw-code', envs: { KEY: 'secret-key' }, }) - expect(result).toMatchObject({ + expect(result).toEqual({ success: false, - error: 'Sandbox operation failed.', + error: + 'The connection to the python kernel was lost, so it was restarted: its variables and imports were lost. Files in the sandbox are kept.', + kernel_restarted: true, + workspace: 'new', }) expect(JSON.stringify(result)).not.toMatch( /secret-key|raw-code|private-file/, @@ -287,6 +291,38 @@ describe('six tools', () => { }) }) + it('tells the model when recovery or the server lost its kernel state, without claiming files are gone', async () => { + const { sandbox, create } = mockSandbox() + const toolset = new E2BToolset() + await invoke(toolset, 'run_code', { code: 'x = 1' }) + sandbox.runCode.mockRejectedValueOnce(new TypeError('terminated')) + expect(await invoke(toolset, 'run_code', { code: 'x' })).toEqual({ + success: false, + error: + 'The connection to the python kernel was lost (TypeError), so it was restarted: its variables and imports were lost. Files in the sandbox are kept.', + kernel_restarted: true, + }) + sandbox.runCode.mockRejectedValueOnce( + new NotFoundError('context 1234 secret-path'), + ) + const missing = await invoke(toolset, 'run_code', { + code: 'x', + language: 'r', + }) + expect(missing).toEqual({ + success: false, + error: + 'The r kernel state is no longer available (NotFoundError); the next call starts an empty kernel. Files in the sandbox are kept.', + kernel_reset: true, + }) + expect(JSON.stringify(missing)).not.toContain('secret-path') + expect( + await invoke(toolset, 'run_code', { code: 'x', language: 'r' }), + ).toMatchObject({ success: true }) + expect(sandbox.createCodeContext).toHaveBeenCalledTimes(3) + expect(create).toHaveBeenCalledTimes(1) + }) + it('surfaces sandbox expiry once, then a new workspace', async () => { const { sandbox, create } = mockSandbox() const toolset = new E2BToolset() diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index d781654..17e7e21 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -664,9 +664,9 @@ describe('uncertain SDK outcomes', () => { const { sandbox } = mockSandbox() sandbox.runCode.mockRejectedValueOnce(new Error('connection reset')) const ws = workspace() - await expect( - ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), - ).rejects.toThrow('connection reset') + expect( + await ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toEqual({ kernelLost: 'restarted', cause: new Error('connection reset') }) expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) expect( await ws.runCode('y', 'python', undefined, info(), { timeoutMs: 1000 }), @@ -686,33 +686,29 @@ describe('uncertain SDK outcomes', () => { ) }) - it.each([ - ['a local argument error', () => new InvalidArgumentError('bad')], - ['a missing code context', () => new NotFoundError('context missing')], - ])( - 'does not restart or block after %s before the code ran', - async (_, error) => { - const { sandbox } = mockSandbox() - sandbox.runCode.mockRejectedValueOnce(error()) - const ws = workspace() - await expect( - ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), - ).rejects.toThrow() - expect(sandbox.restartCodeContext).not.toHaveBeenCalled() - expect( - await ws.runCode('y', 'python', undefined, info(), { timeoutMs: 1000 }), - ).toHaveProperty('execution') - }, - ) + it('does not restart or block after a local argument error', async () => { + const { sandbox } = mockSandbox() + sandbox.runCode.mockRejectedValueOnce(new InvalidArgumentError('bad')) + const ws = workspace() + await expect( + ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }), + ).rejects.toBeInstanceOf(InvalidArgumentError) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + expect( + await ws.runCode('y', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toHaveProperty('execution') + }) it('forgets a code context the kernel server no longer knows', async () => { const { sandbox } = mockSandbox() const ws = workspace() await ws.runCode('a', 'python', undefined, info(), { timeoutMs: 1000 }) - sandbox.runCode.mockRejectedValueOnce(new NotFoundError('context missing')) - await expect( - ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }), - ).rejects.toBeInstanceOf(NotFoundError) + const missing = new NotFoundError('context missing') + sandbox.runCode.mockRejectedValueOnce(missing) + expect( + await ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toEqual({ kernelLost: 'missing', cause: missing }) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() await ws.runCode('c', 'python', undefined, info(), { timeoutMs: 1000 }) expect(sandbox.createCodeContext).toHaveBeenCalledTimes(2) }) From 86b02e2393db058b8d079edf317a6b8452d51fdc Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 24 Sep 2026 16:39:21 +0200 Subject: [PATCH 12/23] docs: state the single-process limit and idle-timeout cost up front --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 735ee59..4ad5855 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` gives each ADK session its own lazily created workspace, which persists across the messages of that conversation until your application releases it or the sandbox times out. +> **Single process only.** Workspaces are tracked in the memory of one `E2BToolset` instance. If several server instances handle one conversation (for example Cloud Run with more than one instance, where session affinity is only best effort), a later message can reach an instance that does not know the session's sandbox; it then starts an empty one, and the earlier files and variables are no longer available to the agent. + ## Install Requires Node.js 22 or later and an ESM project (`"type": "module"` in `package.json`). Tested with Google ADK 2.1.0. @@ -78,7 +80,9 @@ One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. -`runEphemeral()` creates a session whose id your code never sees, so each call leaves a workspace that only `destroyAll()` or the sandbox timeout releases. Prefer `runAsync()` with sessions you create. Workspaces live in the toolset's memory: a deployment with several server processes gives each process its own workspace for the same session. +A running sandbox is billed per second from the session's first tool call until it is destroyed or times out. Call `destroySession()` when a conversation ends; otherwise the idle timeout (five minutes by default) after the last message is billed as well. + +`runEphemeral()` creates a session whose id your code never sees, so each call leaves a workspace that only `destroyAll()` or the sandbox timeout releases. Prefer `runAsync()` with sessions you create. ## Configuration and limits @@ -95,7 +99,7 @@ const toolset = new E2BToolset({ }) ``` -`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are kept until destroyed, so release finished sessions. +`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are not billed and do not count toward the concurrency limit, but E2B keeps them until they are killed. Choose pause only if your application destroys finished sessions. `maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. From cd1fd3306a796bfef3ec21a0e1612d0f2fe43dcd Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 25 Sep 2026 11:02:47 +0200 Subject: [PATCH 13/23] docs: contrast session workspaces with the Python plugin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ad5855..84550df 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The call that creates a workspace also returns `workspace: "new"`. If the sandbo ## Workspaces and cleanup -One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. +One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. Unlike the Python `e2b-adk` plugin, which shares one sandbox across all users of a runner, sessions never share a sandbox. ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. From cee00126fc4505e799de7a70d3a4210dabd464c1 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 25 Sep 2026 11:40:26 +0200 Subject: [PATCH 14/23] feat: find a session's sandbox from any process by its metadata Sandboxes are created with adkAppName, adkUserId and adkSessionId in their metadata. A process that does not hold a session's sandbox lists by that metadata and connects to the oldest match, resuming a paused one; only when none exists does it create, and if another instance created one at the same moment the older sandbox wins and the newer is killed. This is the model of E2B's Vercel AI SDK and Mastra integrations. - Lifetime is extended with connect(), which never shortens what another instance granted; a sandbox-level 404 is reported as expiry. - run_code uses the sandbox's default kernel per language, shared by every instance; restarts look the kernel up with listCodeContexts(). - A sandbox destroyed from another process is reported as expired, not as a blocked workspace. - destroySession() also kills session sandboxes this process never held, including after a restart. - Tests run two workspaces or toolsets over a registry double of the control plane; scripts/live-processes.ts does it with three real processes. --- README.md | 10 +- scripts/live-processes.ts | 142 ++++++++++++++++++ src/code-executor.ts | 16 ++- src/toolset.ts | 24 +++- src/workspace.ts | 247 +++++++++++++++++++------------- tests/executor.test.ts | 33 ++++- tests/helpers.ts | 150 +++++++++++++++++-- tests/isolation.test.ts | 46 +++++- tests/runner.test.ts | 2 +- tests/tools.test.ts | 11 +- tests/workspace.test.ts | 294 ++++++++++++++++++++++++-------------- 11 files changed, 717 insertions(+), 258 deletions(-) create mode 100644 scripts/live-processes.ts diff --git a/README.md b/README.md index 84550df..7200aac 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` gives each ADK session its own lazily created workspace, which persists across the messages of that conversation until your application releases it or the sandbox times out. -> **Single process only.** Workspaces are tracked in the memory of one `E2BToolset` instance. If several server instances handle one conversation (for example Cloud Run with more than one instance, where session affinity is only best effort), a later message can reach an instance that does not know the session's sandbox; it then starts an empty one, and the earlier files and variables are no longer available to the agent. +> Sandboxes are tagged with their ADK session, so any server instance, and a restarted one, finds a conversation's sandbox again. Several instances behind a load balancer, such as Cloud Run, can serve one conversation. ## Install @@ -68,7 +68,7 @@ try { | `list_files` | optional `path` (default `.`) | `entries`, `truncated` | | `start_background_command` | `command`, optional `cwd`, `envs`, `timeoutMs`, `port` | `pid`, `preview_url`, optional `readiness` | -`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`, with one stateful kernel per language in each workspace. Commands start independent processes; their working directory and environment changes do not carry into the next command, but files do. +`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`, with one stateful kernel per language in each sandbox, shared by every instance serving the session. Commands start independent processes; their working directory and environment changes do not carry into the next command, but files do. `success` means the operation ran. Code that raises still returns `success: true` with an `error`; a command exiting nonzero returns `success: true` with its `exit_code`. Invalid arguments and SDK/transport failures return `success: false` with an `error` rather than aborting the agent loop. SDK error messages are sanitized to their error class, such as `(FileNotFoundError)`. Each text output is limited to 10,000 characters; directory listings contain at most 100 entries and indicate truncation. @@ -76,9 +76,9 @@ The call that creates a workspace also returns `workspace: "new"`. If the sandbo ## Workspaces and cleanup -One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. Unlike the Python `e2b-adk` plugin, which shares one sandbox across all users of a runner, sessions never share a sandbox. +One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, the older one wins and the other is killed. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. -ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroyAll()` attempts every workspace before reporting failures; use it on shutdown. +ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroySession()` also kills sandboxes of that session created by other instances or before a restart. `destroyAll()` attempts every workspace this process knows before reporting failures; use it on shutdown. To sweep sandboxes of every session, for example after a crash, list them by the `adkAppName` metadata with the E2B SDK and kill them. A running sandbox is billed per second from the session's first tool call until it is destroyed or times out. Call `destroySession()` when a conversation ends; otherwise the idle timeout (five minutes by default) after the last message is billed as well. @@ -105,7 +105,7 @@ const toolset = new E2BToolset({ `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. -The E2B SDK buffers command and code output in memory before it is truncated. The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. +Two instances can run code in the same kernel at once; the kernel executes serially, and a timeout in one instance restarts the kernel for both. The E2B SDK buffers command and code output in memory before it is truncated. The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. ## Direct code executor diff --git a/scripts/live-processes.ts b/scripts/live-processes.ts new file mode 100644 index 0000000..b05a467 --- /dev/null +++ b/scripts/live-processes.ts @@ -0,0 +1,142 @@ +// Two separate Node processes serve one ADK session: the second must find the +// first one's sandbox by metadata, see its files and kernel state, and a destroy +// from either side must be visible to the other. +// +// node --env-file=.env --import tsx scripts/live-processes.ts +// +// The parent spawns itself twice as "processes" A and B, passing the session. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { Context, InvocationContext, PluginManager } from '@google/adk' +import { Sandbox } from '@e2b/code-interpreter' +import { E2BToolset } from '../src/index.js' + +const [role, sessionId, runId] = process.argv.slice(2) + +async function tool( + toolset: E2BToolset, + name: string, + args: Record, +) { + const tools = await toolset.getTools() + const ctx = new InvocationContext({ + invocationId: randomUUID(), + pluginManager: new PluginManager(), + session: { + appName: 'live-processes', + userId: 'alice', + id: sessionId!, + state: {}, + events: [], + lastUpdateTime: 0, + }, + }) + return (await tools + .find((t) => t.name === name)! + .runAsync({ + args, + toolContext: new Context({ invocationContext: ctx }), + })) as Record +} + +const metadata = { adkLiveRun: runId! } +async function remaining() { + const paginator = Sandbox.list({ query: { metadata } }) + const ids: string[] = [] + while (paginator.hasNext) + ids.push(...(await paginator.nextItems()).map((b) => b.sandboxId)) + return ids +} + +if (role === 'A') { + // Process A: first message of the conversation. + const toolset = new E2BToolset({ + sandboxOptions: { timeoutMs: 180_000, metadata }, + }) + const w = await tool(toolset, 'write_file', { + path: '/home/user/from-a.txt', + content: 'written by A', + }) + assert.equal(w.workspace, 'new', JSON.stringify(w)) + const c = await tool(toolset, 'run_code', { code: 'from_a = 41' }) + assert.equal(c.success, true, JSON.stringify(c)) + const bg = await tool(toolset, 'start_background_command', { + command: 'sleep 600', + }) + assert.equal(bg.success, true) + console.log(`A done: ${(await remaining()).join(',')}`) +} else if (role === 'B') { + // Process B: the next message lands on another instance. + const toolset = new E2BToolset({ + sandboxOptions: { timeoutMs: 180_000, metadata }, + }) + const r = await tool(toolset, 'read_file', { path: '/home/user/from-a.txt' }) + assert.equal(r.content, 'written by A', JSON.stringify(r)) + assert.equal(r.workspace, undefined, 'B must reuse, not create') + const c = await tool(toolset, 'run_code', { code: 'print(from_a + 1)' }) + assert.equal(String(c.stdout).trim(), '42', JSON.stringify(c)) + const p = await tool(toolset, 'run_command', { + command: 'pgrep -xc sleep || true', + }) + assert.equal( + String(p.stdout).trim(), + '1', + "A's background process is visible", + ) + assert.equal((await remaining()).length, 1, 'still one sandbox') + await toolset.destroySession({ + appName: 'live-processes', + userId: 'alice', + id: sessionId!, + }) + assert.deepEqual(await remaining(), [], 'B destroyed the shared sandbox') + console.log('B done') +} else if (role === 'A2') { + // Process A again, after B destroyed the sandbox: its cached handle is stale. + const toolset = new E2BToolset({ + sandboxOptions: { timeoutMs: 180_000, metadata }, + }) + // No cache in a fresh process: the next message simply starts a new workspace. + const r = await tool(toolset, 'read_file', { path: '/home/user/from-a.txt' }) + assert.equal(r.workspace, 'new', JSON.stringify(r)) + assert.equal(r.success, false) + await toolset.destroySession({ + appName: 'live-processes', + userId: 'alice', + id: sessionId!, + }) + assert.deepEqual(await remaining(), []) + console.log('A2 done') +} else { + const session = randomUUID() + const run = randomUUID() + const step = (r: string) => { + const out = spawnSync( + process.execPath, + [ + '--env-file=.env', + '--import', + 'tsx', + 'scripts/live-processes.ts', + r, + session, + run, + ], + { encoding: 'utf8' }, + ) + process.stdout.write(out.stdout) + if (out.status !== 0) { + process.stderr.write(out.stderr) + throw new Error(`process ${r} failed`) + } + } + try { + step('A') + step('B') + step('A2') + console.log('PASS two processes shared one session sandbox and cleaned up') + } finally { + for (const id of await remaining()) await Sandbox.kill(id) + } +} diff --git a/src/code-executor.ts b/src/code-executor.ts index 04762a5..cf8d59b 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -6,24 +6,21 @@ import { } from '@google/adk' import type { E2BCodeExecutorOptions } from './options.js' import { kernelLost, operationError, truncate } from './results.js' +import { sessionTag } from './toolset.js' import { operationLimit, Workspace } from './workspace.js' type ExecuteParams = Parameters[0] /** Stateful code execution in one E2B workspace, owned by one ADK session. */ export class E2BCodeExecutor extends BaseCodeExecutor { - private readonly workspace: Workspace + private workspace?: Workspace private readonly timeoutMs: number private sessionKey?: string - constructor(options: E2BCodeExecutorOptions = {}) { + constructor(private readonly options: E2BCodeExecutorOptions = {}) { super() // Executor calls carry no limit: keep the SDK's 60 s default, capped by maxOperationMs. this.timeoutMs = Math.min(60_000, operationLimit(options)) - this.workspace = new Workspace( - options, - 'Retry close() before using this executor.', - ) this.stateful = true // ADK's response processor currently labels extracted blocks as Python. this.codeBlockDelimiters = [ @@ -71,6 +68,11 @@ export class E2BCodeExecutor extends BaseCodeExecutor { ) } this.sessionKey = key + this.workspace ??= new Workspace( + this.options, + 'Retry close() before using this executor.', + sessionTag(invocationContext.session), + ) try { const outcome = await this.workspace.runCode( input.code, @@ -106,6 +108,6 @@ export class E2BCodeExecutor extends BaseCodeExecutor { /** Releases the workspace; a later call starts a new one. The instance stays bound to its original session. */ async close(): Promise { - await this.workspace.destroy() + await this.workspace?.destroy() } } diff --git a/src/toolset.ts b/src/toolset.ts index 043f38b..3e94f4f 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -65,10 +65,18 @@ class SafeFunctionTool extends FunctionTool { const key = ({ appName, userId, id }: SessionRef) => JSON.stringify([appName, userId, id]) +/** Sandbox metadata that lets any process find a session's sandbox again. */ +export const sessionTag = ({ appName, userId, id }: SessionRef) => ({ + adkAppName: appName, + adkUserId: userId, + adkSessionId: id, +}) + /** * Six agent-callable tools. Each ADK session gets its own lazily created - * sandbox, kept until the application calls destroySession()/destroyAll() or - * the sandbox timeout releases it. ADK's automatic close() leaves it running. + * sandbox, tagged with the session so any process finds it again, and kept + * until the application calls destroySession()/destroyAll() or the sandbox + * timeout releases it. ADK's automatic close() leaves it running. */ export class E2BToolset extends BaseToolset { private readonly workspaces = new Map() @@ -263,7 +271,7 @@ export class E2BToolset extends BaseToolset { private workspace(session: SessionRef): Workspace { let workspace = this.workspaces.get(key(session)) if (!workspace) { - workspace = new Workspace(this.options, CLEANUP_HINT) + workspace = new Workspace(this.options, CLEANUP_HINT, sessionTag(session)) this.workspaces.set(key(session), workspace) } return workspace @@ -312,18 +320,20 @@ export class E2BToolset extends BaseToolset { */ override async close(): Promise {} - /** Kill the session's sandbox. Later calls in that session start a new workspace. */ + /** + * Kill the session's sandbox, wherever it was created. Later calls in that + * session start a new workspace. + */ async destroySession(session: SessionRef): Promise { const id = key(session) - const workspace = this.workspaces.get(id) - if (!workspace) return + const workspace = this.workspace(session) await workspace.destroy() // Forget it only if no newer call has started using it meanwhile. if (workspace.idle && this.workspaces.get(id) === workspace) this.workspaces.delete(id) } - /** Destroy every workspace, continuing past failures; failed ones stay retryable. */ + /** Destroy every workspace this process knows, continuing past failures; failed ones stay retryable. */ async destroyAll(): Promise { const results = await Promise.allSettled( [...this.workspaces.values()].map((workspace) => workspace.destroy()), diff --git a/src/workspace.ts b/src/workspace.ts index afbcb5f..1a6aab5 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -7,8 +7,8 @@ import { TimeoutError, type CommandHandle, type CommandResult, - type Context, type Execution, + type SandboxInfo, } from '@e2b/code-interpreter' import type { E2BSandboxOptions, E2BToolsetOptions } from './options.js' import { @@ -35,7 +35,6 @@ export type CommandOutcome = interface Current { sandbox: Sandbox - contexts: Map> } /** Set when an operation created the sandbox, so its result can say so. */ @@ -140,8 +139,9 @@ type Operation = ( ) => Promise /** - * One lazily created, serialized sandbox for one ADK session. An expired or - * destroyed sandbox is never silently replaced inside a running operation. + * One serialized sandbox for one ADK session, found again from any process + * by its metadata tag. An expired or destroyed sandbox is never silently + * replaced inside a running operation. */ export class Workspace { private current?: Current @@ -157,6 +157,8 @@ export class Workspace { constructor( private readonly options: E2BSandboxOptions, private readonly cleanupHint: string, + /** Metadata that identifies this session's sandbox across processes. */ + private readonly tag: Record, ) { // The cloud default is five minutes. Do not override creation defaults. this.ttl = options.sandboxOptions?.timeoutMs ?? 300_000 @@ -198,33 +200,28 @@ export class Workspace { if (signal?.aborted) throw new OperationAbortedError() let current = this.current if (current) { + // connect() resumes a paused sandbox and only ever extends its lifetime, + // so it never shortens what another process granted. try { - await current.sandbox.setTimeout(this.ttl) + await current.sandbox.connect({ timeoutMs: this.ttl }) } catch (error) { check() - // A paused sandbox also reports 404 here; connect() resumes it. - // Only a sandbox that cannot be resumed is gone. - if ( - error instanceof SandboxNotFoundError && - !(await this.resume(current)) - ) { - check() - if (this.current === current) this.current = undefined - throw new WorkspaceExpiredError() - } + // Only a sandbox-level 404 is conclusive; anything else is best effort. + if (error instanceof SandboxNotFoundError) + throw this.gone(current, error) } } else { - current = await this.create(epoch) - info.created = true + current = await this.acquire(epoch, info) } // Setup awaited the SDK: recheck before any side effect. check() if (signal?.aborted) throw new OperationAbortedError() const { sandbox } = current const refresh = () => - sandbox.setTimeout(this.ttl).catch(() => { - // Best-effort maintenance must not replace an operation's result. - }) + sandbox.connect({ timeoutMs: this.ttl }).then( + () => undefined, + () => undefined, // Best-effort maintenance must not replace an operation's result. + ) let stopped = false let timer: ReturnType | undefined let pending: Promise = Promise.resolve() @@ -246,7 +243,7 @@ export class Workspace { return value } catch (error) { if (epoch !== this.epoch) throw new WorkspaceDestroyedError() - throw error + throw this.gone(current, error) } finally { stopped = true clearTimeout(timer) @@ -255,10 +252,57 @@ export class Workspace { } } - private async create(epoch: number): Promise { + /** The session's sandboxes, oldest first, as any process would see them. */ + private async find(): Promise { + const paginator = Sandbox.list({ + query: { metadata: this.tag, state: ['running', 'paused'] }, + }) + const infos: SandboxInfo[] = [] + while (paginator.hasNext) infos.push(...(await paginator.nextItems())) + return infos.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()) + } + + /** Connect to a listed sandbox; undefined if it vanished meanwhile. */ + private async connect(info: SandboxInfo): Promise { + try { + return await Sandbox.connect(info.sandboxId, { timeoutMs: this.ttl }) + } catch (error) { + if (error instanceof SandboxNotFoundError) return undefined + throw error + } + } + + /** Reuse the session's existing sandbox from any process, or create one. */ + private async acquire(epoch: number, info: RunInfo): Promise { + const [existing] = await this.find() + const found = existing && (await this.connect(existing)) + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + if (found) return (this.current = { sandbox: found }) + const sandbox = await this.create(epoch) + // Two processes may have created at once; the oldest wins everywhere. + const [oldest] = await this.find() + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + if (oldest && oldest.sandboxId !== sandbox.sandboxId) { + const winner = await this.connect(oldest) + if (epoch !== this.epoch) throw new WorkspaceDestroyedError() + if (winner) { + // ponytail: best effort; if this kill fails, the loser's TTL releases it. + await withTimeout(sandbox.kill(), RECOVERY_WAIT_MS).catch( + () => undefined, + ) + return (this.current = { sandbox: winner }) + } + } + info.created = true + return (this.current = { sandbox }) + } + + private async create(epoch: number): Promise { + const { metadata, ...rest } = this.options.sandboxOptions ?? {} + const opts = { ...rest, metadata: { ...metadata, ...this.tag } } const creating = this.options.template - ? Sandbox.create(this.options.template, this.options.sandboxOptions) - : Sandbox.create(this.options.sandboxOptions) + ? Sandbox.create(this.options.template, opts) + : Sandbox.create(opts) this.creating = creating.catch(() => undefined) let sandbox: Sandbox try { @@ -268,11 +312,27 @@ export class Workspace { } // Destroyed meanwhile: destroy() kills this sandbox and keeps it if that fails. if (epoch !== this.epoch) throw new WorkspaceDestroyedError() - return (this.current = { sandbox, contexts: new Map() }) + this.current = { sandbox } + return sandbox + } + + /** A sandbox-level 404 means the workspace is gone (expired, or destroyed elsewhere). */ + private gone(current: Current, error: unknown): unknown { + if (!(error instanceof SandboxNotFoundError)) return error + if (this.current === current) this.current = undefined + return new WorkspaceExpiredError() } - /** Block reuse until destroy() succeeds, unless this sandbox is already gone. */ - private block(current: Current, message: string): Error { + /** + * Block reuse until destroy() succeeds. If the sandbox itself is gone + * (destroyed from another process, or expired), report that instead. + */ + private async block(current: Current, message: string): Promise { + const alive = await Sandbox.getInfo(current.sandbox.sandboxId).then( + () => true, + (error: unknown) => !(error instanceof SandboxNotFoundError), + ) + if (!alive) return this.gone(current, new SandboxNotFoundError('')) as Error const error = new WorkspaceBlockedError(`${message} ${this.cleanupHint}`) if (this.current === current) this.blocked = error return error @@ -286,55 +346,40 @@ export class Workspace { { signal, timeoutMs }: RunOptions, ): Promise { return this.run( - async (sandbox, current, check) => { - // The deadline covers kernel start-up too. + async (sandbox, current) => { + // The deadline covers kernel start-up too. The sandbox keeps one + // kernel per language, shared by every process of this session. const stop = stopper(signal, timeoutMs) + const execution = sandbox.runCode(code, { + language, + envs, + timeoutMs: timeoutMs + SDK_GRACE_MS, + }) + execution.catch(() => undefined) try { - let context: Context - try { - context = await stop.race(this.context(current, language)) - } catch (error) { - // Nothing ran yet; the context stays cached for the next call. - if (stop.reason) - return { stopped: stop.reason, kernelRestarted: false } - throw error + return { execution: await stop.race(execution) } + } catch (error) { + const reason = + stop.reason ?? + (error instanceof TimeoutError ? 'timed_out' : undefined) + if (!reason) { + // Refused before the code ran: nothing to stop. + if (error instanceof InvalidArgumentError) throw error + if (error instanceof SandboxNotFoundError) throw error + if (error instanceof NotFoundError) + return { kernelLost: 'missing', cause: error } + // The stream broke, not necessarily the code: stop it first. + await this.restart(sandbox, current, language) + return { kernelLost: 'restarted', cause: error } } - check() - const execution = sandbox.runCode(code, { - context, - envs, - timeoutMs: timeoutMs + SDK_GRACE_MS, - }) - execution.catch(() => undefined) - try { - return { execution: await stop.race(execution) } - } catch (error) { - const reason = - stop.reason ?? - (error instanceof TimeoutError ? 'timed_out' : undefined) - if (!reason) { - // Refused before the code ran: nothing to stop. - if (error instanceof InvalidArgumentError) throw error - if ( - error instanceof NotFoundError && - !(error instanceof SandboxNotFoundError) - ) { - // The kernel server no longer knows this context. - current.contexts.delete(language) - return { kernelLost: 'missing', cause: error } - } - // The stream broke, not necessarily the code: stop it first. - await this.restart(sandbox, current, context, language) - return { kernelLost: 'restarted', cause: error } - } - // A timed-out kernel stays busy; only a restart stops the code. - await this.restart(sandbox, current, context, language) - // The restart closes the open stream; don't let it outlive this turn. + // A timed-out kernel stays busy; only a restart stops the code. + const restarted = await this.restart(sandbox, current, language) + // The restart closes the open stream; don't let it outlive this turn. + if (restarted) await withTimeout(execution, RECOVERY_WAIT_MS).catch( () => undefined, ) - return { stopped: reason, kernelRestarted: true } - } + return { stopped: reason, kernelRestarted: restarted } } finally { stop.dispose() } @@ -375,7 +420,7 @@ export class Workspace { (handle) => this.stopGroup(sandbox, handle), () => undefined, ) - throw this.block( + throw await this.block( current, 'The command was stopped while starting and could not be confirmed stopped.', ) @@ -389,9 +434,10 @@ export class Workspace { } catch (error) { if (!stop.reason) { if (error instanceof CommandExitError) return { result: error } + if (error instanceof SandboxNotFoundError) throw error // The stream broke, not necessarily the command: stop it first. if (!(await this.stopGroup(sandbox, handle))) - throw this.block( + throw await this.block( current, 'The connection to the command was lost and it could not be confirmed stopped.', ) @@ -400,7 +446,7 @@ export class Workspace { } } if (!(await this.stopGroup(sandbox, handle))) - throw this.block( + throw await this.block( current, 'The command could not be confirmed stopped.', ) @@ -457,25 +503,34 @@ export class Workspace { background: true, }) } catch (error) { - if (refusedToStart(error)) throw error + if (refusedToStart(error) || error instanceof SandboxNotFoundError) + throw error const name = error instanceof Error ? error.name : 'unknown error' - throw this.block( + throw await this.block( current, `The command start failed (${name}); it may still be running.`, ) } } + /** Restart the language's kernel; false if none exists yet, so nothing ran. */ private async restart( sandbox: Sandbox, current: Current, - context: Context, language: string, - ) { + ): Promise { try { + const contexts = await withTimeout( + sandbox.listCodeContexts(), + RECOVERY_WAIT_MS, + ) + const context = contexts.find((c) => c.language === language) + if (!context) return false await withTimeout(sandbox.restartCodeContext(context), RECOVERY_WAIT_MS) - } catch { - throw this.block( + return true + } catch (error) { + if (error instanceof SandboxNotFoundError) throw error + throw await this.block( current, `The ${language} kernel could not be restarted, so the code may still be running.`, ) @@ -490,6 +545,7 @@ export class Workspace { RECOVERY_WAIT_MS, ) } catch (error) { + if (error instanceof SandboxNotFoundError) throw error // kill reports "No such process" once the whole group has exited. // Any other failure leaves the group's state unknown. if (!( @@ -502,27 +558,10 @@ export class Workspace { return exited !== undefined } - private async resume(current: Current): Promise { - try { - await current.sandbox.connect({ timeoutMs: this.ttl }) - return true - } catch (error) { - if (error instanceof SandboxNotFoundError) return false - throw error - } - } - - private context(current: Current, language: string): Promise { - let context = current.contexts.get(language) - if (!context) { - context = current.sandbox.createCodeContext({ language }) - current.contexts.set(language, context) - context.catch(() => current.contexts.delete(language)) - } - return context - } - - /** Kill the sandbox now; queued and later-started work never recreates it. */ + /** + * Kill the session's sandbox, including one created by another process or + * before a restart; queued and later-started work never recreates it. + */ destroy(): Promise { this.destroying ??= this.kill().finally(() => { this.destroying = undefined @@ -534,11 +573,13 @@ export class Workspace { this.epoch++ // A create still in flight belongs to this destroy, even if it lands later. const late = await this.creating - if (late) this.current = { sandbox: late, contexts: new Map() } + if (late) this.current = { sandbox: late } const current = this.current - if (!current) return try { - await withTimeout(current.sandbox.kill(), RECOVERY_WAIT_MS) + if (current) await withTimeout(current.sandbox.kill(), RECOVERY_WAIT_MS) + // Sandboxes of this session that this process never held. + for (const info of await this.find()) + await withTimeout(Sandbox.kill(info.sandboxId), RECOVERY_WAIT_MS) } catch { // Retain the handle so a later destroy can retry; block reuse meanwhile. this.blocked = new SandboxCleanupError(this.cleanupHint) diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 6a776cd..7c9eaa0 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest' import { CodeExecutionLanguage as Language } from '@google/adk' import { TimeoutError } from '@e2b/code-interpreter' import { E2BCodeExecutor } from '../src/index.js' -import { context, execution, mockSandbox } from './helpers.js' +import { + context, + execution, + mockSandbox, + statefulSandboxes, +} from './helpers.js' describe('code executor', () => { it.each([ @@ -20,7 +25,7 @@ describe('code executor', () => { }), ).toEqual({ stdout: 'hello\n\n42', stderr: '', outputFiles: [] }) expect(sandbox.runCode).toHaveBeenCalledWith('code', { - context: { id: `ctx-${expected}`, language: expected, cwd: '/home/user' }, + language: expected, envs: undefined, timeoutMs: 65_000, }) @@ -123,6 +128,30 @@ describe('code executor', () => { expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) }) + it('tags its sandbox with the session so close() also finds one created elsewhere', async () => { + const { create, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + const input = { code: 'x', language: Language.PYTHON, inputFiles: [] } + await new E2BCodeExecutor().executeCode({ + invocationContext: context(), + codeExecutionInput: input, + }) + expect(registry.entries.get(boxes[0]!.sandboxId)?.metadata).toEqual({ + adkAppName: 'test', + adkUserId: 'user', + adkSessionId: 'session', + }) + // A second executor (another process) reuses it, and its close() kills it. + const other = new E2BCodeExecutor() + await other.executeCode({ + invocationContext: context(), + codeExecutionInput: input, + }) + expect(create).toHaveBeenCalledTimes(1) + await other.close() + expect(registry.running()).toHaveLength(0) + }) + it('bounds execution and restarts the kernel after a timeout', async () => { const { sandbox } = mockSandbox() sandbox.runCode.mockRejectedValueOnce(new TimeoutError('timed out')) diff --git a/tests/helpers.ts b/tests/helpers.ts index ba298e3..b8cc116 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -12,6 +12,7 @@ import { import { FileNotFoundError, Sandbox, + SandboxNotFoundError, type Execution, } from '@e2b/code-interpreter' @@ -62,17 +63,24 @@ export function commandHandle( } } +let nextId = 0 + export function fakeSandbox() { const handle = commandHandle() return { - sandboxId: 'fake-sandbox', + sandboxId: `fake-sandbox-${++nextId}`, handle, runCode: vi.fn().mockResolvedValue(execution()), - createCodeContext: vi.fn(async ({ language }: { language: string }) => ({ - id: `ctx-${language}`, - language, - cwd: '/home/user', - })), + // The sandbox keeps one default kernel per language (verified live). + listCodeContexts: vi.fn().mockImplementation(async () => + ['python', 'javascript', 'typescript', 'r', 'java', 'bash'].map( + (language) => ({ + id: `ctx-${language}`, + language, + cwd: '/home/user', + }), + ), + ), restartCodeContext: vi.fn().mockResolvedValue(undefined), commands: { // Background starts return the handle; foreground calls are kill commands. @@ -86,7 +94,6 @@ export function fakeSandbox() { list: vi.fn().mockResolvedValue([{ name: 'test.txt', type: 'file' }]), }, getHost: vi.fn().mockReturnValue('3000-fake.e2b.app'), - setTimeout: vi.fn().mockResolvedValue(undefined), connect: vi.fn(async function (this: unknown) { return this }), @@ -96,16 +103,133 @@ export function fakeSandbox() { export type FakeSandbox = ReturnType +interface Entry { + box: FakeSandbox + metadata: Record + startedAt: Date + state: 'running' | 'paused' | 'killed' +} + +/** + * The E2B control plane as every "process" in a test sees it: sandboxes are + * listed by metadata, connected by id (resuming a paused one), and killed by + * id. A second toolset or workspace over the same registry is another process. + */ +export function registry() { + const entries = new Map() + const info = (e: Entry) => ({ + sandboxId: e.box.sandboxId, + metadata: e.metadata, + startedAt: e.startedAt, + state: e.state, + }) + const alive = (id: string) => { + const e = entries.get(id) + return e && e.state !== 'killed' ? e : undefined + } + const reg = { + entries, + add(box: FakeSandbox, metadata: Record) { + const entry: Entry = { + box, + metadata, + startedAt: new Date(1_700_000_000_000 + entries.size * 1000), + state: 'running', + } + entries.set(box.sandboxId, entry) + box.kill.mockImplementation(async () => { + const was = entry.state !== 'killed' + entry.state = 'killed' + return was + }) + box.connect.mockImplementation(async () => { + if (entry.state === 'killed') throw new SandboxNotFoundError('gone') + entry.state = 'running' + return box + }) + return entry + }, + pause(id: string) { + alive(id)!.state = 'paused' + }, + kill(id: string) { + const e = alive(id) + if (e) e.state = 'killed' + }, + running: () => [...entries.values()].filter((e) => e.state === 'running'), + list(query?: { metadata?: Record; state?: string[] }) { + const states = query?.state ?? ['running', 'paused'] + return ( + [...entries.values()] + .filter( + (e) => + states.includes(e.state) && + Object.entries(query?.metadata ?? {}).every( + ([k, v]) => e.metadata[k] === v, + ), + ) + // Newest first, as the real API answers. + .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime()) + .map(info) + ) + }, + alive, + info, + } + return reg +} + +export type Registry = ReturnType + export function mockSandbox() { const sandbox = fakeSandbox() - const create = vi - .spyOn(Sandbox, 'create') - .mockResolvedValue(sandbox as unknown as Sandbox) + const reg = registry() + /** Configure this to change what a create returns; the registry wraps it. */ + const create = vi.fn().mockResolvedValue(sandbox as unknown as Sandbox) + vi.spyOn(Sandbox, 'create').mockImplementation(async (...args: unknown[]) => { + const opts = (typeof args[0] === 'string' ? args[1] : args[0]) as + { metadata?: Record } | undefined + const box = (await create(...args)) as FakeSandbox + reg.add(box, opts?.metadata ?? {}) + return box as unknown as Sandbox + }) + const list = vi.spyOn(Sandbox, 'list').mockImplementation(((opts?: { + query?: Parameters[0] + }) => { + let done = false + return { + get hasNext() { + return !done + }, + nextItems: async () => { + done = true + return reg.list(opts?.query) + }, + } + }) as unknown as typeof Sandbox.list) + const connect = vi.spyOn(Sandbox, 'connect').mockImplementation((async ( + id: string, + ) => { + const e = reg.alive(id) + if (!e) throw new SandboxNotFoundError(`${id} not found`) + e.state = 'running' + return e.box + }) as unknown as typeof Sandbox.connect) + const kill = vi.spyOn(Sandbox, 'kill').mockImplementation(async (id) => { + const e = reg.alive(id) + if (e) e.state = 'killed' + return Boolean(e) + }) + vi.spyOn(Sandbox, 'getInfo').mockImplementation((async (id: string) => { + const e = reg.alive(id) + if (!e) throw new SandboxNotFoundError(`${id} not found`) + return reg.info(e) + }) as unknown as typeof Sandbox.getInfo) // Tests must never contact a model or sandbox endpoint. vi.spyOn(globalThis, 'fetch').mockRejectedValue( new Error('Network disabled in tests'), ) - return { sandbox, create } + return { sandbox, create, registry: reg, list, connect, kill } } /** Every create returns a new sandbox with its own files, lost on kill. */ @@ -125,10 +249,6 @@ export function statefulSandboxes( if (!box.data.has(path)) throw new FileNotFoundError(`${path} missing`) return box.data.get(path) }) - box.kill.mockImplementation(async () => { - box.data.clear() - return true - }) boxes.push(box) return box as unknown as Sandbox }) diff --git a/tests/isolation.test.ts b/tests/isolation.test.ts index 7531aac..14ed2c5 100644 --- a/tests/isolation.test.ts +++ b/tests/isolation.test.ts @@ -111,7 +111,7 @@ describe('session-scoped workspaces', () => { ) expect(second.every((r) => r.success && !('workspace' in r))).toBe(true) expect(create).toHaveBeenCalledTimes(1) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) + expect(sandbox.runCode).toHaveBeenCalledTimes(2) expect(sandbox.kill).not.toHaveBeenCalled() }) @@ -322,4 +322,48 @@ describe('session-scoped workspaces', () => { await toolset.destroyAll() expect(boxes.map((box) => box.kill.mock.calls.length)).toEqual([1, 2, 1]) }) + + it('a second server instance continues the conversation in the same sandbox', async () => { + const { create } = mockSandbox() + const boxes = statefulSandboxes(create) + // Two E2BToolset instances are two processes sharing the E2B account. + const instanceA = app() + const instanceB = app() + const s = await instanceA.session('alice') + // The shared session store (a database in production) knows the session on B too. + await instanceB.runner.sessionService.createSession({ + appName: instanceB.runner.appName, + userId: 'alice', + sessionId: s.id, + }) + await instanceA.ask(s, plan(write('from-a'))) + expect((await instanceB.ask(s, plan(read)))[0]).toMatchObject({ + success: true, + content: 'from-a', + }) + expect(create).toHaveBeenCalledTimes(1) + await instanceB.toolset.destroySession(s) + expect(boxes[0]!.kill).toHaveBeenCalledTimes(1) + expect((await instanceA.ask(s, plan(read)))[0]).toMatchObject({ + success: false, + workspace_expired: true, + }) + expect((await instanceA.ask(s, plan(read)))[0]).toMatchObject({ + success: false, + workspace: 'new', + }) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('destroySession after a restart still finds and kills the session sandbox', async () => { + const { create, registry } = mockSandbox() + statefulSandboxes(create) + const before = app() + const s = await before.session('alice') + await before.ask(s, plan(write('x'))) + expect(registry.running()).toHaveLength(1) + // A fresh process that never served this session. + await new E2BToolset().destroySession(s) + expect(registry.running()).toHaveLength(0) + }) }) diff --git a/tests/runner.test.ts b/tests/runner.test.ts index 711e5d6..024dc40 100644 --- a/tests/runner.test.ts +++ b/tests/runner.test.ts @@ -44,7 +44,7 @@ describe('real ADK runner integration', () => { runner.runEphemeral({ userId: 'user', newMessage: message }), ) expect(sandbox.runCode).toHaveBeenCalledWith('6 * 7', { - context: { id: 'ctx-python', language: 'python', cwd: '/home/user' }, + language: 'python', envs: undefined, timeoutMs: 65_000, }) diff --git a/tests/tools.test.ts b/tests/tools.test.ts index fe199c8..7974e7f 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -4,7 +4,6 @@ import { FileNotFoundError, InvalidArgumentError, NotFoundError, - SandboxNotFoundError, } from '@e2b/code-interpreter' import { Context } from '@google/adk' import { E2BToolset } from '../src/index.js' @@ -62,10 +61,8 @@ describe('six tools', () => { workspace: 'new', }) expect(await invoke(toolset, 'run_code', args)).toEqual(expected) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) - expect(sandbox.createCodeContext).toHaveBeenCalledWith({ language }) expect(sandbox.runCode).toHaveBeenCalledWith('code', { - context: { id: `ctx-${language}`, language, cwd: '/home/user' }, + language, timeoutMs: 6200, envs: { X: '1' }, }) @@ -319,16 +316,14 @@ describe('six tools', () => { expect( await invoke(toolset, 'run_code', { code: 'x', language: 'r' }), ).toMatchObject({ success: true }) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(3) expect(create).toHaveBeenCalledTimes(1) }) it('surfaces sandbox expiry once, then a new workspace', async () => { - const { sandbox, create } = mockSandbox() + const { sandbox, create, registry } = mockSandbox() const toolset = new E2BToolset() await invoke(toolset, 'write_file', { path: 'a', content: 'x' }) - sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('gone')) - sandbox.connect.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + registry.kill(sandbox.sandboxId) expect(await invoke(toolset, 'read_file', { path: 'a' })).toEqual({ success: false, error: expect.stringContaining('expired or was deleted'), diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 17e7e21..9ff4f36 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -5,7 +5,6 @@ import { InvalidArgumentError, NotFoundError, SandboxError, - SandboxNotFoundError, TimeoutError, type Sandbox, } from '@e2b/code-interpreter' @@ -16,12 +15,20 @@ import { WorkspaceExpiredError, } from '../src/results.js' import { Workspace } from '../src/workspace.js' -import { deferred, execution, mockSandbox, tick } from './helpers.js' +import { + deferred, + execution, + mockSandbox, + statefulSandboxes, + tick, +} from './helpers.js' afterEach(() => vi.useRealTimers()) const info = () => ({ created: false }) -const workspace = (options = {}) => new Workspace(options, 'Retry destroy.') +const tag = { adkSessionId: 's' } +const workspace = (options = {}) => + new Workspace(options, 'Retry destroy.', tag) describe('workspace lifecycle', () => { it('is lazy, forwards only supplied SDK options and reports creation', async () => { @@ -36,12 +43,16 @@ describe('workspace lifecycle', () => { const first = info() await ws.run(async () => 1, first) expect(first.created).toBe(true) - expect(create).toHaveBeenCalledWith('custom', options) + expect(create).toHaveBeenCalledWith('custom', { + ...options, + metadata: { adkSessionId: 's' }, + }) const second = info() await ws.run(async () => 1, second) expect(second.created).toBe(false) expect(create).toHaveBeenCalledTimes(1) - expect(sandbox.setTimeout).toHaveBeenCalledWith(1200) + // Lifetime is extended through connect(), which never shortens it. + expect(sandbox.connect).toHaveBeenCalledWith({ timeoutMs: 1200 }) expect(sandbox.kill).not.toHaveBeenCalled() }) @@ -93,64 +104,22 @@ describe('workspace lifecycle', () => { expect(sandbox.restartCodeContext).not.toHaveBeenCalled() }) - it('uses one lazy code context per language and never passes language with it', async () => { + it('runs code in the sandbox default kernel of each language', async () => { const { sandbox } = mockSandbox() const ws = workspace() await ws.runCode('a', 'python', { X: '1' }, info(), { timeoutMs: 1000 }) - await ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }) await ws.runCode('c', 'r', undefined, info(), { timeoutMs: 1000 }) - expect(sandbox.createCodeContext.mock.calls).toEqual([ - [{ language: 'python' }], - [{ language: 'r' }], - ]) expect(sandbox.runCode).toHaveBeenNthCalledWith(1, 'a', { - context: { id: 'ctx-python', language: 'python', cwd: '/home/user' }, + language: 'python', envs: { X: '1' }, timeoutMs: 6000, }) + expect(sandbox.runCode).toHaveBeenNthCalledWith(2, 'c', { + language: 'r', + envs: undefined, + timeoutMs: 6000, + }) }) - - it.each([ - ['deadline', 'timed_out'], - ['abort', 'aborted'], - ['SDK timeout', 'timed_out'], - ])( - '%s during code restarts the kernel before the next call', - async (how, stopped) => { - vi.useFakeTimers() - const { sandbox } = mockSandbox() - const running = deferred() - running.promise.catch(() => undefined) - sandbox.runCode.mockReturnValueOnce(running.promise) - // Live-verified: a restart ends the open execution stream. - sandbox.restartCodeContext.mockImplementation(async () => - running.reject(new Error('UnexpectedEndOfExecution')), - ) - const ws = workspace() - const abort = new AbortController() - const result = ws.runCode('loop', 'python', undefined, info(), { - timeoutMs: 1000, - signal: abort.signal, - }) - await vi.advanceTimersByTimeAsync(10) - if (how === 'abort') abort.abort() - if (how === 'SDK timeout') running.reject(new TimeoutError('timed out')) - await vi.advanceTimersByTimeAsync(1000) - expect(await result).toEqual({ stopped, kernelRestarted: true }) - expect(sandbox.restartCodeContext).toHaveBeenCalledWith({ - id: 'ctx-python', - language: 'python', - cwd: '/home/user', - }) - const next = ws.runCode('1', 'python', undefined, info(), { - timeoutMs: 1000, - }) - await vi.advanceTimersByTimeAsync(0) - expect(await next).toHaveProperty('execution') - expect(vi.getTimerCount()).toBe(0) - }, - ) - it('blocks the workspace when a kernel restart fails, until destroy recovers it', async () => { const { sandbox, create } = mockSandbox() sandbox.runCode.mockRejectedValueOnce(new TimeoutError('timed out')) @@ -272,10 +241,10 @@ describe('workspace lifecycle', () => { 'Sandbox cleanup failed. Retry destroy.', ) const work = vi.fn() - const renewals = sandbox.setTimeout.mock.calls.length + const renewals = sandbox.connect.mock.calls.length await expect(ws.run(work, info())).rejects.toThrow('Retry destroy.') expect(work).not.toHaveBeenCalled() - expect(sandbox.setTimeout).toHaveBeenCalledTimes(renewals) + expect(sandbox.connect).toHaveBeenCalledTimes(renewals) expect(ws.idle).toBe(false) await ws.destroy() expect(sandbox.kill).toHaveBeenCalledTimes(2) @@ -283,43 +252,33 @@ describe('workspace lifecycle', () => { expect(create).toHaveBeenCalledTimes(2) }) - it('reports an expired sandbox once, resets code contexts, then starts a new workspace', async () => { - const { sandbox, create } = mockSandbox() + it('reports an expired sandbox once, then starts a new workspace', async () => { + const { sandbox, create, registry } = mockSandbox() const ws = workspace() await ws.runCode('x = 1', 'python', undefined, info(), { timeoutMs: 1000 }) - sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('gone')) - sandbox.connect.mockRejectedValueOnce(new SandboxNotFoundError('gone')) + registry.kill(sandbox.sandboxId) const work = vi.fn() await expect(ws.run(work, info())).rejects.toBeInstanceOf( WorkspaceExpiredError, ) expect(work).not.toHaveBeenCalled() const next = info() - await ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }) await ws.run(async () => 1, next) + expect(next.created).toBe(true) expect(create).toHaveBeenCalledTimes(2) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(2) }) - - it('resumes a paused sandbox, which the control plane also reports as missing', async () => { - const { sandbox, create } = mockSandbox() + it('resumes a paused sandbox instead of replacing it', async () => { + const { sandbox, create, registry } = mockSandbox() const ws = workspace({ sandboxOptions: { timeoutMs: 1200 } }) await ws.runCode('x = 1', 'python', undefined, info(), { timeoutMs: 1000 }) - sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('paused')) + registry.pause(sandbox.sandboxId) const resumed = info() expect(await ws.run(async () => 'kept', resumed)).toBe('kept') expect(sandbox.connect).toHaveBeenCalledWith({ timeoutMs: 1200 }) expect(resumed.created).toBe(false) - await ws.runCode('x', 'python', undefined, info(), { timeoutMs: 1000 }) - expect(create).toHaveBeenCalledTimes(1) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) - sandbox.setTimeout.mockRejectedValueOnce(new SandboxNotFoundError('paused')) - sandbox.connect.mockRejectedValueOnce(new Error('Transient')) - await expect(ws.run(async () => 1, info())).rejects.toThrow('Transient') - expect(await ws.run(async () => 2, info())).toBe(2) + expect(registry.alive(sandbox.sandboxId)?.state).toBe('running') expect(create).toHaveBeenCalledTimes(1) }) - it('does not treat a missing file or other refresh failure as expiry', async () => { const { sandbox, create } = mockSandbox() const ws = workspace() @@ -329,7 +288,7 @@ describe('workspace lifecycle', () => { throw new FileNotFoundError('missing') }, info()), ).rejects.toBeInstanceOf(FileNotFoundError) - sandbox.setTimeout.mockRejectedValue(new Error('Transient')) + sandbox.connect.mockRejectedValue(new Error('Transient')) expect(await ws.run(async () => 42, info())).toBe(42) expect(create).toHaveBeenCalledTimes(1) }) @@ -339,17 +298,17 @@ describe('workspace lifecycle', () => { const { sandbox } = mockSandbox() const ws = workspace({ sandboxOptions: { timeoutMs: 1000 } }) await ws.run(async () => 1, info()) - const calls = sandbox.setTimeout.mock.calls.length + const calls = sandbox.connect.mock.calls.length const finish = deferred() const work = ws.run(() => finish.promise, info()) await vi.advanceTimersByTimeAsync(2500) - expect(sandbox.setTimeout.mock.calls.length - calls).toBe(6) + expect(sandbox.connect.mock.calls.length - calls).toBe(6) const rejected = expect(work).rejects.toThrow('Execution failed') finish.reject(new Error('Execution failed')) await rejected expect(vi.getTimerCount()).toBe(0) await vi.advanceTimersByTimeAsync(10_000) - expect(sandbox.setTimeout.mock.calls.length - calls).toBe(7) + expect(sandbox.connect.mock.calls.length - calls).toBe(7) }) }) @@ -409,7 +368,7 @@ describe('cleanup and deadline boundaries', () => { const ws = workspace() await ws.run(async () => 1, info()) const refresh = deferred() - sandbox.setTimeout.mockReturnValueOnce(refresh.promise) + sandbox.connect.mockReturnValueOnce(refresh.promise) const effect = vi.fn(async () => 42) const run = ws.run(effect, info()) await tick() @@ -424,7 +383,7 @@ describe('cleanup and deadline boundaries', () => { const ws = workspace() await ws.run(async () => 1, info()) const refresh = deferred() - sandbox.setTimeout.mockReturnValueOnce(refresh.promise) + sandbox.connect.mockReturnValueOnce(refresh.promise) const effect = vi.fn(async () => 42) const run = ws.run(effect, info()) await tick() @@ -435,22 +394,6 @@ describe('cleanup and deadline boundaries', () => { expect(effect).not.toHaveBeenCalled() }) - it('does not run code whose workspace was destroyed while its context was created', async () => { - const { sandbox } = mockSandbox() - const ws = workspace() - await ws.run(async () => 1, info()) - const context = deferred<{ id: string; language: string; cwd: string }>() - sandbox.createCodeContext.mockReturnValueOnce(context.promise) - const run = ws.runCode('x', 'python', undefined, info(), { - timeoutMs: 10_000, - }) - await tick() - await ws.destroy() - context.resolve({ id: 'late', language: 'python', cwd: '/home/user' }) - await expect(run).rejects.toBeInstanceOf(WorkspaceDestroyedError) - expect(sandbox.runCode).not.toHaveBeenCalled() - }) - it('does not report success for an operation that finished after destroy', async () => { mockSandbox() const ws = workspace() @@ -576,21 +519,16 @@ describe('cleanup and deadline boundaries', () => { expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') }) - it('counts context creation against the deadline and does not claim a kernel restart', async () => { + it('does not claim a kernel restart when the deadline hits before the kernel exists', async () => { vi.useFakeTimers() const { sandbox } = mockSandbox() - const context = deferred<{ id: string; language: string; cwd: string }>() - sandbox.createCodeContext.mockReturnValueOnce(context.promise) + sandbox.runCode.mockReturnValueOnce(new Promise(() => {})) + sandbox.listCodeContexts.mockResolvedValueOnce([]) const ws = workspace() const run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) await vi.advanceTimersByTimeAsync(100) expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: false }) - expect(sandbox.runCode).not.toHaveBeenCalled() expect(sandbox.restartCodeContext).not.toHaveBeenCalled() - context.resolve({ id: 'ctx-java', language: 'java', cwd: '/home/user' }) - await vi.advanceTimersByTimeAsync(0) - await ws.runCode('y', 'java', undefined, info(), { timeoutMs: 100 }) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(1) }) }) @@ -699,20 +637,19 @@ describe('uncertain SDK outcomes', () => { ).toHaveProperty('execution') }) - it('forgets a code context the kernel server no longer knows', async () => { + it('reports a kernel the server no longer knows without restarting anything', async () => { const { sandbox } = mockSandbox() const ws = workspace() - await ws.runCode('a', 'python', undefined, info(), { timeoutMs: 1000 }) const missing = new NotFoundError('context missing') sandbox.runCode.mockRejectedValueOnce(missing) expect( await ws.runCode('b', 'python', undefined, info(), { timeoutMs: 1000 }), ).toEqual({ kernelLost: 'missing', cause: missing }) expect(sandbox.restartCodeContext).not.toHaveBeenCalled() - await ws.runCode('c', 'python', undefined, info(), { timeoutMs: 1000 }) - expect(sandbox.createCodeContext).toHaveBeenCalledTimes(2) + expect( + await ws.runCode('c', 'python', undefined, info(), { timeoutMs: 1000 }), + ).toHaveProperty('execution') }) - it('applies the same start rule to background commands', async () => { const { sandbox } = mockSandbox() const ws = workspace() @@ -733,3 +670,142 @@ describe('uncertain SDK outcomes', () => { ) }) }) + +describe('reconnect across processes', () => { + const other = () => new Workspace({}, 'Retry destroy.', tag) + + it('another process finds the session sandbox by metadata instead of creating one', async () => { + const { sandbox, create, list, connect } = mockSandbox() + const a = workspace() + const first = info() + await a.run(async () => 1, first) + expect(first.created).toBe(true) + const b = other() + const second = info() + expect(await b.run(async (box) => box.sandboxId, second)).toBe( + sandbox.sandboxId, + ) + expect(second.created).toBe(false) + expect(create).toHaveBeenCalledTimes(1) + expect(list).toHaveBeenCalledWith({ + query: { metadata: tag, state: ['running', 'paused'] }, + }) + expect(connect).toHaveBeenCalledWith(sandbox.sandboxId, { + timeoutMs: 300_000, + }) + }) + + it('another process resumes a paused session sandbox', async () => { + const { sandbox, create, registry } = mockSandbox() + await workspace().run(async () => 1, info()) + registry.pause(sandbox.sandboxId) + expect(await other().run(async (box) => box.sandboxId, info())).toBe( + sandbox.sandboxId, + ) + expect(registry.alive(sandbox.sandboxId)?.state).toBe('running') + expect(create).toHaveBeenCalledTimes(1) + }) + + it('never attaches to a sandbox tagged with another session', async () => { + const { create, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + await new Workspace({}, 'x', { adkSessionId: 'someone-else' }).run( + async () => 1, + info(), + ) + const mine = info() + await workspace().run(async () => 1, mine) + expect(mine.created).toBe(true) + expect(boxes).toHaveLength(2) + expect(registry.running()).toHaveLength(2) + }) + + it('resolves a create race by keeping the oldest sandbox and killing its own', async () => { + const { create, list, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + const a = workspace() + await a.run(async () => 1, info()) + // Process B listed before A's sandbox was visible, then created its own. + list.mockImplementationOnce(() => { + let done = false + return { + get hasNext() { + return !done + }, + nextItems: async () => { + done = true + return [] + }, + } as unknown as ReturnType + }) + const b = other() + const outcome = info() + const used = await b.run(async (box) => box.sandboxId, outcome) + expect(boxes).toHaveLength(2) + expect(used).toBe(boxes[0]!.sandboxId) + expect(outcome.created).toBe(false) + expect(boxes[1]!.kill).toHaveBeenCalledTimes(1) + expect(registry.running().map((e) => e.box.sandboxId)).toEqual([ + boxes[0]!.sandboxId, + ]) + }) + + it('a destroy from another process surfaces here as expiry, then a new workspace', async () => { + const { create } = mockSandbox() + statefulSandboxes(create) + const a = workspace() + await a.run(async () => 1, info()) + await other().destroy() + await expect(a.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceExpiredError, + ) + const fresh = info() + await a.run(async () => 1, fresh) + expect(fresh.created).toBe(true) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('destroy kills session sandboxes this process never held', async () => { + const { create, kill, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + await workspace().run(async () => 1, info()) + const b = other() + expect(b.idle).toBe(true) + await b.destroy() + expect(kill).toHaveBeenCalledWith(boxes[0]!.sandboxId) + expect(registry.running()).toHaveLength(0) + expect(b.idle).toBe(true) + }) + + it('a failed sweep kill blocks the workspace until a retry succeeds', async () => { + const { create, kill, registry } = mockSandbox() + statefulSandboxes(create) + await workspace().run(async () => 1, info()) + kill.mockRejectedValueOnce(new Error('down')) + const b = other() + await expect(b.destroy()).rejects.toThrow('Sandbox cleanup failed') + await expect(b.run(async () => 1, info())).rejects.toThrow('Retry destroy.') + await b.destroy() + expect(registry.running()).toHaveLength(0) + expect(await b.run(async () => 2, info())).toBe(2) + }) + + it('reports a sandbox that vanished mid-operation as expired, not blocked', async () => { + vi.useFakeTimers() + const { sandbox, registry } = mockSandbox() + sandbox.handle.wait.mockReturnValueOnce(new Promise(() => {})) + sandbox.commands.run.mockImplementation(async (_command, opts) => { + if (opts?.background) return sandbox.handle + // Killed elsewhere while the command ran: the kill request fails too. + registry.kill(sandbox.sandboxId) + throw new Error('connection lost') + }) + const ws = workspace() + const run = ws + .runCommand('loop', {}, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(100) + expect(await run).toBeInstanceOf(WorkspaceExpiredError) + expect(ws.idle).toBe(true) + }) +}) From 39ad50fe2f6df5a9cb0614ee77c2e15f1f33f9cc Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 25 Sep 2026 12:07:19 +0200 Subject: [PATCH 15/23] fix: namespace tag, fail-closed create race and kernel start-up deadline - A namespace option becomes adkNamespace in the sandbox metadata used to find, reuse and destroy a session's sandbox, so deployments that share one E2B API key and app name stay apart. - When the list after a create fails, the just-created sandbox is discarded instead of used; if that kill fails, the workspace stays blocked until destroySession() retries. - A deadline that fires before the language kernel exists waits for the in-flight request to settle within the recovery window and restarts the kernel that came up; if the request never settles, the workspace is blocked. - README and guide: operations are serialized per instance only, and create-race convergence is best effort. --- README.md | 5 +- scripts/live.ts | 14 +++-- src/code-executor.ts | 2 +- src/options.ts | 6 ++ src/toolset.ts | 12 +++- src/workspace.ts | 42 +++++++++++--- tests/tools.test.ts | 30 +++++++++- tests/workspace.test.ts | 118 ++++++++++++++++++++++++++++++++++++++-- 8 files changed, 206 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7200aac..c717ba7 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ The call that creates a workspace also returns `workspace: "new"`. If the sandbo ## Workspaces and cleanup -One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, the older one wins and the other is killed. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace; their operations run one at a time. +One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, both re-check the list and use the older one; the instance that created the newer one kills it, and if that kill fails its workspace stays blocked until `destroySession()` retries. E2B's list is not transactional, so this converges rather than locks; a losing sandbox whose creator crashed lives until its timeout. Set `namespace` when several deployments, such as staging and production, share one E2B API key and app name; it becomes part of the metadata used to find, reuse and destroy sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace. Within one server instance their operations run one at a time; across instances they run concurrently on the same sandbox, where commands are separate processes and each language kernel executes one request at a time. -ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroySession()` also kills sandboxes of that session created by other instances or before a restart. `destroyAll()` attempts every workspace this process knows before reporting failures; use it on shutdown. To sweep sandboxes of every session, for example after a crash, list them by the `adkAppName` metadata with the E2B SDK and kill them. +ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroySession()` also kills sandboxes of that session created by other instances or before a restart; an operation running on another instance then fails with `workspace_expired`. `destroyAll()` attempts every workspace this process knows before reporting failures; use it on shutdown. To sweep sandboxes of every session, for example after a crash, list them by the `adkAppName` metadata with the E2B SDK and kill them. A running sandbox is billed per second from the session's first tool call until it is destroyed or times out. Call `destroySession()` when a conversation ends; otherwise the idle timeout (five minutes by default) after the last message is billed as well. @@ -89,6 +89,7 @@ A running sandbox is billed per second from the session's first tool call until ```typescript const toolset = new E2BToolset({ template: 'your-code-interpreter-template', + namespace: 'production', maxOperationMs: 600_000, sandboxOptions: { timeoutMs: 300_000, diff --git a/scripts/live.ts b/scripts/live.ts index 84f78e7..1733ed2 100644 --- a/scripts/live.ts +++ b/scripts/live.ts @@ -225,18 +225,22 @@ async function stopMechanisms() { await stopped(ctx, '/tmp/start', 'sleep 600') console.log('PASS a deadline during command start kills the late process') - // ...and kernel start-up: code that never started does not claim a restart. + // ...and kernel start-up: the request was already sent, so the kernel that + // comes up is restarted before the result is reported. result = await raw( 'run_code', { code: 'System.out.println(1);', language: 'java', timeoutMs: 1 }, ctx, ) assert.equal(result.timed_out, true, JSON.stringify(result)) - assert.equal(result.kernel_restarted, undefined) - assert.match(String(result.error), /did not run/) - console.log( - 'PASS a deadline during kernel start-up reports that code did not run', + assert.equal(result.kernel_restarted, true, JSON.stringify(result)) + const afterStart = await call( + 'run_code', + { code: 'System.out.println(2);', language: 'java' }, + ctx, ) + assert.match(String(afterStart.stdout), /2/) + console.log('PASS a deadline during kernel start-up restarts the new kernel') const loops: Record = { python: ['v = 1', 'while True:\n pass', 'print(v)'], diff --git a/src/code-executor.ts b/src/code-executor.ts index cf8d59b..d50821a 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -71,7 +71,7 @@ export class E2BCodeExecutor extends BaseCodeExecutor { this.workspace ??= new Workspace( this.options, 'Retry close() before using this executor.', - sessionTag(invocationContext.session), + sessionTag(invocationContext.session, this.options.namespace), ) try { const outcome = await this.workspace.runCode( diff --git a/src/options.ts b/src/options.ts index 78070db..c546273 100644 --- a/src/options.ts +++ b/src/options.ts @@ -9,6 +9,12 @@ export interface E2BSandboxOptions { export interface E2BToolsetOptions extends E2BSandboxOptions { /** Longest a single code or command execution may run, in milliseconds. Default 600_000. */ maxOperationMs?: number + /** + * Separates the sandboxes of deployments that share one E2B API key and use + * the same ADK app name, such as staging and production. Part of the sandbox + * metadata used to find, reuse and destroy a session's sandbox. + */ + namespace?: string } export type E2BCodeExecutorOptions = E2BToolsetOptions diff --git a/src/toolset.ts b/src/toolset.ts index 3e94f4f..b1f5197 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -66,7 +66,11 @@ const key = ({ appName, userId, id }: SessionRef) => JSON.stringify([appName, userId, id]) /** Sandbox metadata that lets any process find a session's sandbox again. */ -export const sessionTag = ({ appName, userId, id }: SessionRef) => ({ +export const sessionTag = ( + { appName, userId, id }: SessionRef, + namespace?: string, +) => ({ + ...(namespace !== undefined && { adkNamespace: namespace }), adkAppName: appName, adkUserId: userId, adkSessionId: id, @@ -271,7 +275,11 @@ export class E2BToolset extends BaseToolset { private workspace(session: SessionRef): Workspace { let workspace = this.workspaces.get(key(session)) if (!workspace) { - workspace = new Workspace(this.options, CLEANUP_HINT, sessionTag(session)) + workspace = new Workspace( + this.options, + CLEANUP_HINT, + sessionTag(session, this.options.namespace), + ) this.workspaces.set(key(session), workspace) } return workspace diff --git a/src/workspace.ts b/src/workspace.ts index 1a6aab5..aed4763 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -280,16 +280,20 @@ export class Workspace { if (found) return (this.current = { sandbox: found }) const sandbox = await this.create(epoch) // Two processes may have created at once; the oldest wins everywhere. - const [oldest] = await this.find() + // Until that check succeeds, our sandbox is not the session's sandbox. + let oldest: SandboxInfo | undefined + try { + ;[oldest] = await this.find() + } catch (error) { + await this.discard(sandbox) + throw error + } if (epoch !== this.epoch) throw new WorkspaceDestroyedError() if (oldest && oldest.sandboxId !== sandbox.sandboxId) { const winner = await this.connect(oldest) if (epoch !== this.epoch) throw new WorkspaceDestroyedError() if (winner) { - // ponytail: best effort; if this kill fails, the loser's TTL releases it. - await withTimeout(sandbox.kill(), RECOVERY_WAIT_MS).catch( - () => undefined, - ) + await this.discard(sandbox) return (this.current = { sandbox: winner }) } } @@ -297,6 +301,18 @@ export class Workspace { return (this.current = { sandbox }) } + /** Give up a sandbox we created but must not use; keep it if the kill fails. */ + private async discard(sandbox: Sandbox) { + try { + await withTimeout(sandbox.kill(), RECOVERY_WAIT_MS) + } catch { + // Not confirmed dead: keep the handle so destroy() retries, block reuse. + this.blocked = new SandboxCleanupError(this.cleanupHint) + throw this.blocked + } + this.current = undefined + } + private async create(epoch: number): Promise { const { metadata, ...rest } = this.options.sandboxOptions ?? {} const opts = { ...rest, metadata: { ...metadata, ...this.tag } } @@ -373,12 +389,22 @@ export class Workspace { return { kernelLost: 'restarted', cause: error } } // A timed-out kernel stays busy; only a restart stops the code. - const restarted = await this.restart(sandbox, current, language) - // The restart closes the open stream; don't let it outlive this turn. - if (restarted) + let restarted = await this.restart(sandbox, current, language) + if (!restarted) { + // No kernel yet, but the request is in flight and may still start + // one. Wait for it to settle, then look again. + if (!(await settleWithin(execution, RECOVERY_WAIT_MS))) + throw await this.block( + current, + 'The code could not be confirmed stopped.', + ) + restarted = await this.restart(sandbox, current, language) + } else { + // The restart closes the open stream; don't let it outlive this turn. await withTimeout(execution, RECOVERY_WAIT_MS).catch( () => undefined, ) + } return { stopped: reason, kernelRestarted: restarted } } finally { stop.dispose() diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 7974e7f..35025a8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -7,7 +7,13 @@ import { } from '@e2b/code-interpreter' import { Context } from '@google/adk' import { E2BToolset } from '../src/index.js' -import { context, execution, mockSandbox, toolContext } from './helpers.js' +import { + context, + execution, + mockSandbox, + statefulSandboxes, + toolContext, +} from './helpers.js' async function invoke( toolset: E2BToolset, @@ -349,6 +355,28 @@ describe('six tools', () => { expect(create).toHaveBeenCalledTimes(3) }) + it('puts the namespace into the sandbox metadata used to find and destroy it', async () => { + const { create, registry } = mockSandbox() + statefulSandboxes(create) + const staging = new E2BToolset({ namespace: 'staging' }) + const prod = new E2BToolset({ namespace: 'prod' }) + await invoke(staging, 'list_files', {}) + expect(await invoke(prod, 'list_files', {})).toMatchObject({ + workspace: 'new', + }) + expect(create).toHaveBeenCalledTimes(2) + expect([...registry.entries.values()].map((e) => e.metadata)).toEqual([ + expect.objectContaining({ adkNamespace: 'staging', adkAppName: 'test' }), + expect.objectContaining({ adkNamespace: 'prod', adkAppName: 'test' }), + ]) + await prod.destroySession({ + appName: 'test', + userId: 'user', + id: 'session', + }) + expect(registry.running()).toHaveLength(1) + }) + it('requires an ADK invocation context', async () => { const { create } = mockSandbox() const tool = (await new E2BToolset().getTools())[2]! diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 9ff4f36..6d66782 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -21,6 +21,7 @@ import { mockSandbox, statefulSandboxes, tick, + type FakeSandbox, } from './helpers.js' afterEach(() => vi.useRealTimers()) @@ -519,16 +520,42 @@ describe('cleanup and deadline boundaries', () => { expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') }) - it('does not claim a kernel restart when the deadline hits before the kernel exists', async () => { + it('waits for an in-flight request before saying no kernel ran, and blocks if it never settles', async () => { vi.useFakeTimers() const { sandbox } = mockSandbox() - sandbox.runCode.mockReturnValueOnce(new Promise(() => {})) - sandbox.listCodeContexts.mockResolvedValueOnce([]) const ws = workspace() - const run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + // (a) the request dies after the deadline without a kernel: nothing ran + const dead = deferred() + dead.promise.catch(() => undefined) + sandbox.runCode.mockReturnValueOnce(dead.promise) + sandbox.listCodeContexts.mockResolvedValue([]) + let run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) await vi.advanceTimersByTimeAsync(100) + dead.reject(new Error('never started')) expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: false }) expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + // (b) the kernel appears after the deadline: it is restarted + const late = deferred() + late.promise.catch(() => undefined) + sandbox.runCode.mockReturnValueOnce(late.promise) + sandbox.listCodeContexts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 'ctx-java', language: 'java', cwd: '/home/user' }, + ]) + run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + late.reject(new Error('closed')) + expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: true }) + expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) + // (c) the request never settles: fail closed + sandbox.runCode.mockReturnValueOnce(new Promise(() => {})) + sandbox.listCodeContexts.mockResolvedValue([]) + const blocked = ws + .runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(31_000) + expect(await blocked).toBeInstanceOf(WorkspaceBlockedError) }) }) @@ -808,4 +835,87 @@ describe('reconnect across processes', () => { expect(await run).toBeInstanceOf(WorkspaceExpiredError) expect(ws.idle).toBe(true) }) + + it('never uses its own sandbox when the race check fails; a retry reconciles', async () => { + const { create, list, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + const a = workspace() + await a.run(async () => 1, info()) + const empty = () => { + let done = false + return { + get hasNext() { + return !done + }, + nextItems: async () => { + done = true + return [] + }, + } as unknown as ReturnType + } + list.mockImplementationOnce(empty).mockImplementationOnce(() => { + throw new Error('list failed') + }) + const b = other() + await expect(b.run(async () => 1, info())).rejects.toThrow('list failed') + expect(boxes[1]!.kill).toHaveBeenCalledTimes(1) + expect(b.idle).toBe(true) + // The next call finds the session's sandbox, not the discarded one. + expect(await b.run(async (box) => box.sandboxId, info())).toBe( + boxes[0]!.sandboxId, + ) + expect(registry.running().map((e) => e.box.sandboxId)).toEqual([ + boxes[0]!.sandboxId, + ]) + }) + + it('keeps a discarded sandbox blocked and retryable when its kill fails', async () => { + const { create, list } = mockSandbox() + const boxes = statefulSandboxes(create) + await workspace().run(async () => 1, info()) + const factory = create.getMockImplementation()! + create.mockImplementationOnce(async (...args: unknown[]) => { + const box = (await factory(...args)) as FakeSandbox + box.kill.mockRejectedValueOnce(new Error('down')) + return box + }) + const empty = () => { + let done = false + return { + get hasNext() { + return !done + }, + nextItems: async () => { + done = true + return [] + }, + } as unknown as ReturnType + } + list.mockImplementationOnce(empty).mockImplementationOnce(() => { + throw new Error('list failed') + }) + const b = other() + await expect(b.run(async () => 1, info())).rejects.toThrow( + 'Sandbox cleanup failed', + ) + expect(boxes).toHaveLength(2) + await expect(b.run(async () => 1, info())).rejects.toThrow('Retry destroy.') + await b.destroy() + expect(b.idle).toBe(true) + }) + it('scopes the session tag by namespace so deployments on one key stay apart', async () => { + const { create, registry } = mockSandbox() + const boxes = statefulSandboxes(create) + const staging = new Workspace({}, 'x', { adkNamespace: 'staging', ...tag }) + const prod = new Workspace({}, 'x', { adkNamespace: 'prod', ...tag }) + await staging.run(async () => 1, info()) + const mine = info() + await prod.run(async () => 1, mine) + expect(mine.created).toBe(true) + expect(boxes).toHaveLength(2) + await prod.destroy() + expect(registry.running().map((e) => e.box.sandboxId)).toEqual([ + boxes[0]!.sandboxId, + ]) + }) }) From 885ef45034072f001e7d0e5455e26a17af106737 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 25 Sep 2026 12:49:06 +0200 Subject: [PATCH 16/23] fix: always tag a namespace and fail closed when stopped code cannot be confirmed stopped - adkNamespace is always written ('default' when unset): E2B's list matches any sandbox containing the queried pairs, so an untagged instance could find and destroy a namespaced deployment's sandbox - one stop path for code whose request already went out: restart the listed kernel, else let the request settle and restart the kernel it started, return the result if the code finished, otherwise block the workspace; kernel_restarted is reported only after an actual restart - maxOperationMs is capped at the largest delay setTimeout honours - README/guide: race resolution is best effort; destroyAll() covers the workspaces this instance knows --- README.md | 4 +-- scripts/live.ts | 16 ++++++--- src/code-executor.ts | 2 +- src/options.ts | 3 +- src/toolset.ts | 17 ++++----- src/workspace.ts | 68 +++++++++++++++++++++++------------- tests/executor.test.ts | 1 + tests/tools.test.ts | 22 +++++++----- tests/workspace.test.ts | 77 ++++++++++++++++++++++++++++++----------- 9 files changed, 137 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index c717ba7..acb8737 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The call that creates a workspace also returns `workspace: "new"`. If the sandbo ## Workspaces and cleanup -One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, both re-check the list and use the older one; the instance that created the newer one kills it, and if that kill fails its workspace stays blocked until `destroySession()` retries. E2B's list is not transactional, so this converges rather than locks; a losing sandbox whose creator crashed lives until its timeout. Set `namespace` when several deployments, such as staging and production, share one E2B API key and app name; it becomes part of the metadata used to find, reuse and destroy sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace. Within one server instance their operations run one at a time; across instances they run concurrently on the same sandbox, where commands are separate processes and each language kernel executes one request at a time. +One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, each re-checks the list and switches to the older sandbox it sees, killing its own; if that kill fails its workspace stays blocked until `destroySession()` retries. E2B's list is not transactional, so this is best effort, not a lock: an instance whose re-check does not show the other sandbox yet keeps its own, and a losing sandbox whose creator crashed lives until its timeout. One workspace per session across processes is not guaranteed without coordination outside E2B. Set `namespace` when several deployments, such as staging and production, share one E2B API key and app name; it is written as `adkNamespace` (`default` when unset) and is part of the metadata used to find, reuse and destroy sandboxes, so an instance without a namespace never touches a namespaced deployment's sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace. Within one server instance their operations run one at a time; across instances they run concurrently on the same sandbox, where commands are separate processes and each language kernel executes one request at a time. ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroySession()` also kills sandboxes of that session created by other instances or before a restart; an operation running on another instance then fails with `workspace_expired`. `destroyAll()` attempts every workspace this process knows before reporting failures; use it on shutdown. To sweep sandboxes of every session, for example after a crash, list them by the `adkAppName` metadata with the E2B SDK and kill them. @@ -102,7 +102,7 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are not billed and do not count toward the concurrency limit, but E2B keeps them until they are killed. Choose pause only if your application destroys finished sessions. -`maxOperationMs` (default 10 minutes) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; code stopped before it starts reports that it did not run. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. +`maxOperationMs` (default 10 minutes, at most 2,147,483,647) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; if no kernel is listed yet, the adapter waits for the request to settle and restarts the kernel it started, returns the result if the code finished meanwhile, and otherwise blocks the workspace. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome, including a failed code request with no kernel to restart, blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. diff --git a/scripts/live.ts b/scripts/live.ts index 1733ed2..0f920b0 100644 --- a/scripts/live.ts +++ b/scripts/live.ts @@ -225,22 +225,28 @@ async function stopMechanisms() { await stopped(ctx, '/tmp/start', 'sleep 600') console.log('PASS a deadline during command start kills the late process') - // ...and kernel start-up: the request was already sent, so the kernel that - // comes up is restarted before the result is reported. + // ...and kernel start-up: the request was already sent. Either the kernel + // that comes up is restarted, or the code finished before it was listed + // and its result is returned; "did not run" is never claimed. result = await raw( 'run_code', { code: 'System.out.println(1);', language: 'java', timeoutMs: 1 }, ctx, ) - assert.equal(result.timed_out, true, JSON.stringify(result)) - assert.equal(result.kernel_restarted, true, JSON.stringify(result)) + assert.ok( + (result.timed_out === true && result.kernel_restarted === true) || + (result.success === true && result.stdout === '1\n'), + JSON.stringify(result), + ) const afterStart = await call( 'run_code', { code: 'System.out.println(2);', language: 'java' }, ctx, ) assert.match(String(afterStart.stdout), /2/) - console.log('PASS a deadline during kernel start-up restarts the new kernel') + console.log( + 'PASS a deadline during kernel start-up never claims the code did not run', + ) const loops: Record = { python: ['v = 1', 'while True:\n pass', 'print(v)'], diff --git a/src/code-executor.ts b/src/code-executor.ts index d50821a..8252c71 100644 --- a/src/code-executor.ts +++ b/src/code-executor.ts @@ -83,7 +83,7 @@ export class E2BCodeExecutor extends BaseCodeExecutor { ) if ('stopped' in outcome) return fail( - `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. ${outcome.kernelRestarted ? 'The kernel was restarted; its variables and imports were lost.' : 'The code did not run.'}`, + `Execution ${outcome.stopped === 'timed_out' ? `exceeded ${this.timeoutMs} ms` : 'was cancelled'}. The kernel was restarted; its variables and imports were lost.`, ) if ('kernelLost' in outcome) return fail(kernelLost(language, outcome)) const { execution } = outcome diff --git a/src/options.ts b/src/options.ts index c546273..380c6d2 100644 --- a/src/options.ts +++ b/src/options.ts @@ -12,7 +12,8 @@ export interface E2BToolsetOptions extends E2BSandboxOptions { /** * Separates the sandboxes of deployments that share one E2B API key and use * the same ADK app name, such as staging and production. Part of the sandbox - * metadata used to find, reuse and destroy a session's sandbox. + * metadata used to find, reuse and destroy a session's sandbox; 'default' + * when unset. */ namespace?: string } diff --git a/src/toolset.ts b/src/toolset.ts index b1f5197..31f104a 100644 --- a/src/toolset.ts +++ b/src/toolset.ts @@ -20,6 +20,8 @@ import { operationLimit, Workspace, type RunInfo } from './workspace.js' /** SDK default for code and command execution when the model sets no limit. */ const DEFAULT_TIMEOUT_MS = 60_000 +/** Always tagged: E2B's list matches any sandbox that has the given pairs. */ +const DEFAULT_NAMESPACE = 'default' const CLEANUP_HINT = 'The application must retry destroySession() before this workspace can be used.' @@ -70,7 +72,7 @@ export const sessionTag = ( { appName, userId, id }: SessionRef, namespace?: string, ) => ({ - ...(namespace !== undefined && { adkNamespace: namespace }), + adkNamespace: namespace ?? DEFAULT_NAMESPACE, adkAppName: appName, adkUserId: userId, adkSessionId: id, @@ -121,15 +123,10 @@ export class E2BToolset extends BaseToolset { outcome.stopped === 'timed_out' ? `exceeded ${ms} ms` : 'was cancelled' - return outcome.kernelRestarted - ? failure( - `Execution ${why}. The ${input.language} kernel was restarted; its variables and imports were lost.`, - { [outcome.stopped]: true, kernel_restarted: true }, - ) - : failure( - `Execution ${why} while the ${input.language} kernel was starting; the code did not run.`, - { [outcome.stopped]: true }, - ) + return failure( + `Execution ${why}. The ${input.language} kernel was restarted; its variables and imports were lost.`, + { [outcome.stopped]: true, kernel_restarted: true }, + ) } if ('kernelLost' in outcome) return failure( diff --git a/src/workspace.ts b/src/workspace.ts index aed4763..1a96278 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -23,11 +23,14 @@ import { const RECOVERY_WAIT_MS = 30_000 /** Lets our own deadline fire before the SDK's, so one path handles both. */ const SDK_GRACE_MS = 5_000 +/** Longest delay setTimeout honours; larger values fire immediately. */ +const MAX_TIMER_MS = 2_147_483_647 export type Stop = 'timed_out' | 'aborted' export type CodeOutcome = | { execution: Execution } - | { stopped: Stop; kernelRestarted: boolean } + /** Stopped by restarting its kernel, whose variables and imports are gone. */ + | { stopped: Stop } /** The kernel's state is gone: restarted after a lost stream, or unknown to the server. */ | { kernelLost: 'restarted' | 'missing'; cause: unknown } export type CommandOutcome = @@ -55,8 +58,10 @@ export function operationLimit(options: E2BToolsetOptions): number { 'sandboxOptions.timeoutMs must be a positive finite number.', ) const max = options.maxOperationMs ?? 600_000 - if (!Number.isSafeInteger(max) || max <= 0) - throw new Error('maxOperationMs must be a positive integer.') + if (!Number.isSafeInteger(max) || max <= 0 || max > MAX_TIMER_MS) + throw new Error( + `maxOperationMs must be an integer from 1 to ${MAX_TIMER_MS}.`, + ) return max } @@ -384,28 +389,19 @@ export class Workspace { if (error instanceof SandboxNotFoundError) throw error if (error instanceof NotFoundError) return { kernelLost: 'missing', cause: error } - // The stream broke, not necessarily the code: stop it first. - await this.restart(sandbox, current, language) - return { kernelLost: 'restarted', cause: error } } - // A timed-out kernel stays busy; only a restart stops the code. - let restarted = await this.restart(sandbox, current, language) - if (!restarted) { - // No kernel yet, but the request is in flight and may still start - // one. Wait for it to settle, then look again. - if (!(await settleWithin(execution, RECOVERY_WAIT_MS))) - throw await this.block( - current, - 'The code could not be confirmed stopped.', - ) - restarted = await this.restart(sandbox, current, language) - } else { - // The restart closes the open stream; don't let it outlive this turn. - await withTimeout(execution, RECOVERY_WAIT_MS).catch( - () => undefined, - ) - } - return { stopped: reason, kernelRestarted: restarted } + // The request went out, whether the stream broke or the deadline + // passed: only a kernel restart proves the code stopped. + const finished = await this.stopCode( + execution, + sandbox, + current, + language, + ) + if (finished) return { execution: finished } + return reason + ? { stopped: reason } + : { kernelLost: 'restarted', cause: error } } finally { stop.dispose() } @@ -540,6 +536,30 @@ export class Workspace { } /** Restart the language's kernel; false if none exists yet, so nothing ran. */ + /** + * Stop code whose request already went out. Restart its kernel; if none is + * listed yet the request may still start one, so let it settle and look + * again. Returns the result if the code finished meanwhile; blocks the + * workspace when the code cannot be confirmed stopped. + */ + private async stopCode( + execution: Promise, + sandbox: Sandbox, + current: Current, + language: string, + ): Promise { + if (await this.restart(sandbox, current, language)) { + // The restart closes the open stream; don't let it outlive this turn. + await withTimeout(execution, RECOVERY_WAIT_MS).catch(() => undefined) + return undefined + } + const settled = await settleWithin(execution, RECOVERY_WAIT_MS) + if (settled && 'value' in settled) return settled.value + if (settled && (await this.restart(sandbox, current, language))) + return undefined + throw await this.block(current, 'The code could not be confirmed stopped.') + } + private async restart( sandbox: Sandbox, current: Current, diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 7c9eaa0..61a6700 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -137,6 +137,7 @@ describe('code executor', () => { codeExecutionInput: input, }) expect(registry.entries.get(boxes[0]!.sandboxId)?.metadata).toEqual({ + adkNamespace: 'default', adkAppName: 'test', adkUserId: 'user', adkSessionId: 'session', diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 35025a8..5abcf45 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -114,9 +114,8 @@ describe('six tools', () => { expect(create).not.toHaveBeenCalled() await invoke(toolset, 'run_code', { code: 'x' }) expect(sandbox.runCode.mock.calls[0]![1]).toMatchObject({ timeoutMs: 6000 }) - expect(() => new E2BToolset({ maxOperationMs: 0 })).toThrow( - 'maxOperationMs', - ) + for (const maxOperationMs of [0, 2_147_483_648]) + expect(() => new E2BToolset({ maxOperationMs })).toThrow('maxOperationMs') expect(() => new E2BToolset({ sandboxOptions: { timeoutMs: 0 } })).toThrow( 'sandboxOptions.timeoutMs', ) @@ -360,20 +359,25 @@ describe('six tools', () => { statefulSandboxes(create) const staging = new E2BToolset({ namespace: 'staging' }) const prod = new E2BToolset({ namespace: 'prod' }) + const unset = new E2BToolset() await invoke(staging, 'list_files', {}) expect(await invoke(prod, 'list_files', {})).toMatchObject({ workspace: 'new', }) - expect(create).toHaveBeenCalledTimes(2) + // Without a namespace, an instance must not find the namespaced sandboxes. + expect(await invoke(unset, 'list_files', {})).toMatchObject({ + workspace: 'new', + }) + expect(create).toHaveBeenCalledTimes(3) expect([...registry.entries.values()].map((e) => e.metadata)).toEqual([ expect.objectContaining({ adkNamespace: 'staging', adkAppName: 'test' }), expect.objectContaining({ adkNamespace: 'prod', adkAppName: 'test' }), + expect.objectContaining({ adkNamespace: 'default', adkAppName: 'test' }), ]) - await prod.destroySession({ - appName: 'test', - userId: 'user', - id: 'session', - }) + const session = { appName: 'test', userId: 'user', id: 'session' } + await unset.destroySession(session) + expect(registry.running()).toHaveLength(2) + await prod.destroySession(session) expect(registry.running()).toHaveLength(1) }) diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 6d66782..4905014 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -520,21 +520,10 @@ describe('cleanup and deadline boundaries', () => { expect(sandbox.commands.run).toHaveBeenCalledWith('kill -9 -- -101') }) - it('waits for an in-flight request before saying no kernel ran, and blocks if it never settles', async () => { + it('lets a deadline request without a kernel settle, then restarts the kernel it started', async () => { vi.useFakeTimers() const { sandbox } = mockSandbox() const ws = workspace() - // (a) the request dies after the deadline without a kernel: nothing ran - const dead = deferred() - dead.promise.catch(() => undefined) - sandbox.runCode.mockReturnValueOnce(dead.promise) - sandbox.listCodeContexts.mockResolvedValue([]) - let run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) - await vi.advanceTimersByTimeAsync(100) - dead.reject(new Error('never started')) - expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: false }) - expect(sandbox.restartCodeContext).not.toHaveBeenCalled() - // (b) the kernel appears after the deadline: it is restarted const late = deferred() late.promise.catch(() => undefined) sandbox.runCode.mockReturnValueOnce(late.promise) @@ -543,19 +532,65 @@ describe('cleanup and deadline boundaries', () => { .mockResolvedValueOnce([ { id: 'ctx-java', language: 'java', cwd: '/home/user' }, ]) - run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + const run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) await vi.advanceTimersByTimeAsync(100) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() late.reject(new Error('closed')) - expect(await run).toEqual({ stopped: 'timed_out', kernelRestarted: true }) + expect(await run).toEqual({ stopped: 'timed_out' }) expect(sandbox.restartCodeContext).toHaveBeenCalledTimes(1) - // (c) the request never settles: fail closed - sandbox.runCode.mockReturnValueOnce(new Promise(() => {})) + }) + + it('returns the result when the code finishes while no kernel is listed', async () => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const ws = workspace() + const slow = deferred() + sandbox.runCode.mockReturnValueOnce(slow.promise) sandbox.listCodeContexts.mockResolvedValue([]) - const blocked = ws - .runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) - .catch((error) => error) - await vi.advanceTimersByTimeAsync(31_000) - expect(await blocked).toBeInstanceOf(WorkspaceBlockedError) + const run = ws.runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + slow.resolve({ logs: { stdout: ['1'], stderr: [] } }) + expect(await run).toEqual({ + execution: { logs: { stdout: ['1'], stderr: [] } }, + }) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + }) + + it.each([ + ['dies after the deadline', 100], + ['never settles', 31_000], + ])( + 'blocks the workspace when a deadline request without a kernel %s', + async (_case, wait) => { + vi.useFakeTimers() + const { sandbox } = mockSandbox() + const ws = workspace() + const request = deferred() + request.promise.catch(() => undefined) + sandbox.runCode.mockReturnValueOnce(request.promise) + sandbox.listCodeContexts.mockResolvedValue([]) + const run = ws + .runCode('x', 'java', undefined, info(), { timeoutMs: 100 }) + .catch((error) => error) + await vi.advanceTimersByTimeAsync(wait) + if (wait === 100) request.reject(new Error('never started')) + expect(await run).toBeInstanceOf(WorkspaceBlockedError) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() + await expect(ws.run(async () => 1, info())).rejects.toBeInstanceOf( + WorkspaceBlockedError, + ) + }, + ) + + it('blocks the workspace when a stream breaks before any kernel is listed', async () => { + const { sandbox } = mockSandbox() + const ws = workspace() + sandbox.runCode.mockRejectedValueOnce(new Error('connection reset')) + sandbox.listCodeContexts.mockResolvedValue([]) + await expect( + ws.runCode('x', 'java', undefined, info(), { timeoutMs: 1_000 }), + ).rejects.toBeInstanceOf(WorkspaceBlockedError) + expect(sandbox.restartCodeContext).not.toHaveBeenCalled() }) }) From 36c33c20d743042fcd1c88de0231b60f5f7fc1ae Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 25 Sep 2026 13:15:01 +0200 Subject: [PATCH 17/23] docs: a timeout does not undo files the code already wrote --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acb8737..fdcf5c8 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ const toolset = new E2BToolset({ `template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are not billed and do not count toward the concurrency limit, but E2B keeps them until they are killed. Choose pause only if your application destroys finished sessions. -`maxOperationMs` (default 10 minutes, at most 2,147,483,647) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The deadline also covers starting the command or kernel; if no kernel is listed yet, the adapter waits for the request to settle and restarts the kernel it started, returns the result if the code finished meanwhile, and otherwise blocks the workspace. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome, including a failed code request with no kernel to restart, blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. +`maxOperationMs` (default 10 minutes, at most 2,147,483,647) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The code may have finished just before the restart, and a timeout does not undo the files it wrote. The deadline also covers starting the command or kernel; if no kernel is listed yet, the adapter waits for the request to settle and restarts the kernel it started, returns the result if the code finished meanwhile, and otherwise blocks the workspace. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome, including a failed code request with no kernel to restart, blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. `start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. From fe66b41998a02a9e65d1bc2cc144cdf5783cfe07 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Mon, 12 Oct 2026 16:48:57 +0200 Subject: [PATCH 18/23] docs: improve readme --- README.md | 89 +++++++++++++++++++++++-------------------------------- 1 file changed, 37 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index fdcf5c8..6254300 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,28 @@ -# E2B for Google ADK +# E2B for Google ADK (TypeScript) -Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools in an isolated [E2B](https://e2b.dev) sandbox. `E2BToolset` gives each ADK session its own lazily created workspace, which persists across the messages of that conversation until your application releases it or the sandbox times out. - -> Sandboxes are tagged with their ADK session, so any server instance, and a restarted one, finds a conversation's sandbox again. Several instances behind a load balancer, such as Cloud Run, can serve one conversation. +Give [Google ADK](https://github.com/google/adk-js) agents code, shell, file and background-process tools that run in an isolated [E2B](https://e2b.dev) sandbox. Each ADK session gets its own sandbox, which keeps files and kernel state across the messages of that conversation. ## Install -Requires Node.js 22 or later and an ESM project (`"type": "module"` in `package.json`). Tested with Google ADK 2.1.0. +Requires Node.js 22 or later and an ESM project (`"type": "module"`). Tested with Google ADK 2.1.0. ```bash npm install @google/adk @e2b/adk npm install --save-dev tsx typescript @types/node ``` -Set credentials in your application's environment: - ```bash export E2B_API_KEY="..." export GOOGLE_API_KEY="..." ``` -The integration is model-independent. The examples use Gemini 2.5 Flash through ADK's built-in provider. Credentials are used by the local SDKs and are not forwarded into the sandbox unless explicitly supplied in `envs`. - -## Agent tools +## Quick start ```typescript import { E2BToolset } from '@e2b/adk' import { InMemoryRunner, LlmAgent } from '@google/adk' -// Create once per application, like any ADK root agent. +// One toolset per application, like the root agent. const toolset = new E2BToolset() const runner = new InMemoryRunner({ agent: new LlmAgent({ @@ -59,36 +53,40 @@ try { } ``` -| Tool | Arguments | Result fields besides `success` | -| -------------------------- | ------------------------------------------------------ | ------------------------------------------ | -| `run_code` | `code`, optional `language`, `envs`, `timeoutMs` | `stdout`, `stderr`, `text`, `error` | -| `run_command` | `command`, optional `cwd`, `envs`, `timeoutMs` | `stdout`, `stderr`, `exit_code` | -| `write_file` | `path`, `content` | `path` | -| `read_file` | `path` | `path`, `content` | -| `list_files` | optional `path` (default `.`) | `entries`, `truncated` | -| `start_background_command` | `command`, optional `cwd`, `envs`, `timeoutMs`, `port` | `pid`, `preview_url`, optional `readiness` | +## Tools -`run_code` supports `python` (default), `javascript`, `typescript`, `r`, `java` and `bash`, with one stateful kernel per language in each sandbox, shared by every instance serving the session. Commands start independent processes; their working directory and environment changes do not carry into the next command, but files do. +| Tool | Arguments | Returns | +| -------------------------- | --------------------------------------------- | ----------------------------------- | +| `run_code` | `code`, `language`, `envs`, `timeoutMs` | `stdout`, `stderr`, `text`, `error` | +| `run_command` | `command`, `cwd`, `envs`, `timeoutMs` | `stdout`, `stderr`, `exit_code` | +| `write_file` | `path`, `content` | `path` | +| `read_file` | `path` | `path`, `content` | +| `list_files` | `path` (default `.`) | `entries`, `truncated` | +| `start_background_command` | `command`, `cwd`, `envs`, `timeoutMs`, `port` | `pid`, `preview_url`, `readiness` | -`success` means the operation ran. Code that raises still returns `success: true` with an `error`; a command exiting nonzero returns `success: true` with its `exit_code`. Invalid arguments and SDK/transport failures return `success: false` with an `error` rather than aborting the agent loop. SDK error messages are sanitized to their error class, such as `(FileNotFoundError)`. Each text output is limited to 10,000 characters; directory listings contain at most 100 entries and indicate truncation. +`run_code` takes `python` (default), `javascript`, `typescript`, `r`, `java` or `bash`, with one kernel per language per sandbox. -The call that creates a workspace also returns `workspace: "new"`. If the sandbox has expired or was deleted, the next call returns `success: false` with `workspace_expired: true`; the call after that starts a new, empty workspace. Agent instructions should tell the model to check these fields. +- `success: true` means the operation ran. Code that raises returns an `error`; a nonzero exit returns `exit_code`. +- `success: false` means it could not run, for example invalid arguments or a transport failure. The agent loop keeps going. +- The call that creates a workspace returns `workspace: "new"`. If the sandbox expired, the next call returns `workspace_expired: true`, and the call after it starts an empty workspace. -## Workspaces and cleanup +Tell the model to check these fields. -One `E2BToolset` can serve a module-level agent, the pattern `adk web` and `adk api_server` use. Workspaces are keyed by the ADK session (`appName`, `userId` and session `id`), never by model arguments, so concurrent sessions get separate sandboxes. The key is written into the sandbox's metadata as `adkAppName`, `adkUserId` and `adkSessionId`; a process that does not hold the sandbox looks it up by that metadata and connects, resuming it if it was paused. This is how E2B's Vercel AI SDK and Mastra integrations work. If two instances create a sandbox for the same session at the same moment, each re-checks the list and switches to the older sandbox it sees, killing its own; if that kill fails its workspace stays blocked until `destroySession()` retries. E2B's list is not transactional, so this is best effort, not a lock: an instance whose re-check does not show the other sandbox yet keeps its own, and a losing sandbox whose creator crashed lives until its timeout. One workspace per session across processes is not guaranteed without coordination outside E2B. Set `namespace` when several deployments, such as staging and production, share one E2B API key and app name; it is written as `adkNamespace` (`default` when unset) and is part of the metadata used to find, reuse and destroy sandboxes, so an instance without a namespace never touches a namespaced deployment's sandboxes. Later messages and runs resumed after a tool confirmation reuse their session's files, kernels and background processes. Overlapping runs of one session share its workspace. Within one server instance their operations run one at a time; across instances they run concurrently on the same sandbox, where commands are separate processes and each language kernel executes one request at a time. +## Sessions and cleanup -ADK `close()` does not release session workspaces: ADK 2.1.0 calls it after every `runAsync()` without saying which run ended. The application owns cleanup through `destroySession(session)` and `destroyAll()`; the sandbox timeout remains the fallback. `destroySession()` kills the session's sandbox (waiting for one that is still being created), fails its pending operations without recreating anything, and lets later messages in that session start a new workspace. If a kill fails, the call rejects, the workspace stays blocked, and a later `destroySession()` or `destroyAll()` retries. `destroySession()` also kills sandboxes of that session created by other instances or before a restart; an operation running on another instance then fails with `workspace_expired`. `destroyAll()` attempts every workspace this process knows before reporting failures; use it on shutdown. To sweep sandboxes of every session, for example after a crash, list them by the `adkAppName` metadata with the E2B SDK and kill them. +Workspaces are keyed by ADK session (`appName`, `userId` and session `id`). The key is stored in the sandbox metadata, so any server instance, including a restarted one, finds the conversation's sandbox and connects to it. Several instances behind a load balancer can serve one conversation without session affinity. -A running sandbox is billed per second from the session's first tool call until it is destroyed or times out. Call `destroySession()` when a conversation ends; otherwise the idle timeout (five minutes by default) after the last message is billed as well. +- **ADK's `close()` does not release a workspace.** ADK calls it after every run without saying which one ended, so the toolset treats it as a no-op. +- **Release sessions yourself.** Call `destroySession(session)` when a conversation ends, and `destroyAll()` on shutdown. +- **Billing.** A sandbox is billed from its first tool call until it is destroyed or times out. The default idle timeout is five minutes. -`runEphemeral()` creates a session whose id your code never sees, so each call leaves a workspace that only `destroyAll()` or the sandbox timeout releases. Prefer `runAsync()` with sessions you create. +Set `namespace` when staging and production share one E2B API key and app name, so each deployment finds only its own sandboxes. -## Configuration and limits +## Configuration ```typescript const toolset = new E2BToolset({ - template: 'your-code-interpreter-template', + template: 'your-template', namespace: 'production', maxOperationMs: 600_000, sandboxOptions: { @@ -100,40 +98,27 @@ const toolset = new E2BToolset({ }) ``` -`template` selects the E2B template; omit it to use the Code Interpreter default. Custom templates must support the interpreters you intend to use and provide util-linux `setsid`. `sandboxOptions` accepts the E2B SDK's creation options except `template`; unspecified options keep the SDK defaults, so an idle sandbox is killed after five minutes. With `lifecycle: { onTimeout: 'pause' }` an idle sandbox is paused instead, and the next tool call in that session resumes it with its files and kernel state. Paused sandboxes are not billed and do not count toward the concurrency limit, but E2B keeps them until they are killed. Choose pause only if your application destroys finished sessions. +- `template`: E2B template. It must include the interpreters you use and util-linux `setsid`. +- `sandboxOptions`: the E2B SDK creation options, except `template`. Unset options keep the SDK defaults. +- `maxOperationMs`: upper bound for one `run_code` or `run_command`, default 10 minutes. The model's `timeoutMs` cannot exceed it. +- `namespace`: separates deployments that share an API key and app name. -`maxOperationMs` (default 10 minutes, at most 2,147,483,647) bounds a single `run_code` or `run_command`. The model may pass `timeoutMs` from 1 up to that limit; omitted, it defaults to 60 seconds, as in the E2B SDK. The deadline starts when the operation starts running, not while it waits for an earlier one. When a deadline passes or the ADK run is aborted (`runAsync({ abortSignal })`), a command is killed with all its child processes and returns `timed_out` or `aborted`; code is stopped by restarting its kernel and returns `kernel_restarted: true`, because its variables are lost. The code may have finished just before the restart, and a timeout does not undo the files it wrote. The deadline also covers starting the command or kernel; if no kernel is listed yet, the adapter waits for the request to settle and restarts the kernel it started, returns the result if the code finished meanwhile, and otherwise blocks the workspace. If a kernel cannot be restarted, or the adapter cannot confirm that a stopped command and its process group are gone, the workspace is blocked until `destroySession()`. The same applies when the connection fails while a command starts or runs, because the command may still be running: a lost command stream is followed by a process-group kill, a lost code stream by a kernel restart (reported with `kernel_restarted: true`), and a start with an unknown outcome, including a failed code request with no kernel to restart, blocks the workspace. A start the sandbox refuses, such as a missing `cwd`, is an ordinary error the model can correct. If the kernel server no longer knows a language's context, the result has `kernel_reset: true` and the next call starts an empty kernel. Files in the sandbox are kept in both cases. `maxOperationMs` covers execution, including command and kernel start-up; creating or resuming the sandbox is bounded by the SDK's request timeout. Processes that start their own session with `setsid` escape this cleanup. Active operations keep extending the sandbox timeout. - -`start_background_command` returns immediately and keeps the process running until it exits, its optional `timeoutMs` passes, or the workspace ends. A preview URL is syntactic and has `readiness: "unknown"`. - -Two instances can run code in the same kernel at once; the kernel executes serially, and a timeout in one instance restarts the kernel for both. The E2B SDK buffers command and code output in memory before it is truncated. The adapter does not log tool arguments or SDK error bodies. Configure ADK and SDK tracing separately if your application handles sensitive inputs. +With `lifecycle: { onTimeout: 'pause' }`, an idle sandbox is paused and resumed by the next call in its session, with files and kernel state intact. Paused sandboxes are not billed, but they stay until destroyed, so use pause only if your application calls `destroySession()`. ## Direct code executor -`E2BCodeExecutor` implements ADK's `BaseCodeExecutor`. Call `executeCode({ invocationContext, codeExecutionInput })` and call `close()` when you are done. See the complete [direct executor example](examples/code-executor.ts). - -**ADK 2.1.0 limitation:** setting `LlmAgent({ codeExecutor })` alone does not execute model responses: ADK does not register its code execution response processor. Use `E2BToolset` for an autonomous agent. This package does not install a custom processor or import ADK internals. +`E2BCodeExecutor` implements ADK's `BaseCodeExecutor` for direct `executeCode()` calls. It keeps kernel state until `close()`. See the [direct executor example](examples/code-executor.ts). -Direct calls accept ADK's `CodeExecutionLanguage.PYTHON`, `JAVASCRIPT`, `TYPESCRIPT` and `SHELL` (mapped to bash). Unsupported languages, nonempty `inputFiles` and nonempty `args` return a descriptive `stderr` without creating a sandbox. Output contains bounded `stdout`/`stderr` and `outputFiles: []`; automatic file staging and artifact collection are not supported. Each call is limited to 60 seconds (or `maxOperationMs`, if lower); a timed-out call restarts the kernel. The executor keeps kernel state until `close()`, which releases the sandbox; a later call starts a new one. It stays bound to its first ADK session to prevent accidental cross-session sharing. +Setting `LlmAgent({ codeExecutor })` alone does not run model-generated code in ADK 2.1.0. Use `E2BToolset` for an autonomous agent. ## Examples and development -- [Data analysis agent](examples/data-analysis.ts): a two-message conversation that saves a CSV once and reuses it. -- [Direct code executor](examples/code-executor.ts): reuse a stateful Python kernel through the ADK executor contract. +- [Data analysis agent](examples/data-analysis.ts): two messages in one conversation; the CSV saved by the first is reused by the second. +- [Direct code executor](examples/code-executor.ts) ```bash pnpm install --frozen-lockfile pnpm check ``` -Tests use real ADK runtime objects with mocked models and E2B SDK calls. Network access is blocked by the test fixtures; no API credentials are needed for CI. CI runs on Node 22 and 24. - -For examples and paid live checks, copy `.env.example` to `.env` and supply your credentials locally: - -```bash -pnpm example:analysis -pnpm example:executor -pnpm test:live -``` - -Live checks create real E2B sandboxes and make Gemini requests (`LIVE_MODEL=openai pnpm test:live agent` uses a small OpenAI model through a test-only adapter instead). They verify the tools in every language, stopping commands and code, pause and resume, expiry, and a multi-user conversation with confirmation and cancellation. They clean up only workspaces tagged with their unique test-run ID and are never part of `pnpm check` or CI. +Unit tests use real ADK objects with mocked models and E2B SDK calls, so CI needs no credentials. Live checks use real E2B sandboxes and a model. Copy `.env.example` to `.env`, then run `pnpm test:live`. They are never part of `pnpm check` or CI. From be08c13af5b841199e3d88d02f1483de57d2c6e6 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Mon, 12 Oct 2026 17:55:55 +0200 Subject: [PATCH 19/23] chore: move @e2b/adk into packages/e2b-adk-js --- .env.example => packages/e2b-adk-js/.env.example | 0 {.github => packages/e2b-adk-js/.github}/workflows/ci.yml | 0 .gitignore => packages/e2b-adk-js/.gitignore | 0 .prettierignore => packages/e2b-adk-js/.prettierignore | 0 .prettierrc.json => packages/e2b-adk-js/.prettierrc.json | 0 LICENSE => packages/e2b-adk-js/LICENSE | 0 README.md => packages/e2b-adk-js/README.md | 0 eslint.config.js => packages/e2b-adk-js/eslint.config.js | 0 {examples => packages/e2b-adk-js/examples}/code-executor.ts | 0 {examples => packages/e2b-adk-js/examples}/data-analysis.ts | 0 package.json => packages/e2b-adk-js/package.json | 0 pnpm-lock.yaml => packages/e2b-adk-js/pnpm-lock.yaml | 0 {scripts => packages/e2b-adk-js/scripts}/live-processes.ts | 0 {scripts => packages/e2b-adk-js/scripts}/live.ts | 0 {scripts => packages/e2b-adk-js/scripts}/openai-model.ts | 0 {src => packages/e2b-adk-js/src}/code-executor.ts | 0 {src => packages/e2b-adk-js/src}/index.ts | 0 {src => packages/e2b-adk-js/src}/options.ts | 0 {src => packages/e2b-adk-js/src}/results.ts | 0 {src => packages/e2b-adk-js/src}/toolset.ts | 0 {src => packages/e2b-adk-js/src}/workspace.ts | 0 {tests => packages/e2b-adk-js/tests}/executor.test.ts | 0 {tests => packages/e2b-adk-js/tests}/helpers.ts | 0 {tests => packages/e2b-adk-js/tests}/isolation.test.ts | 0 {tests => packages/e2b-adk-js/tests}/runner.test.ts | 0 {tests => packages/e2b-adk-js/tests}/tools.test.ts | 0 {tests => packages/e2b-adk-js/tests}/workspace.test.ts | 0 tsconfig.build.json => packages/e2b-adk-js/tsconfig.build.json | 0 tsconfig.json => packages/e2b-adk-js/tsconfig.json | 0 vitest.config.ts => packages/e2b-adk-js/vitest.config.ts | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename .env.example => packages/e2b-adk-js/.env.example (100%) rename {.github => packages/e2b-adk-js/.github}/workflows/ci.yml (100%) rename .gitignore => packages/e2b-adk-js/.gitignore (100%) rename .prettierignore => packages/e2b-adk-js/.prettierignore (100%) rename .prettierrc.json => packages/e2b-adk-js/.prettierrc.json (100%) rename LICENSE => packages/e2b-adk-js/LICENSE (100%) rename README.md => packages/e2b-adk-js/README.md (100%) rename eslint.config.js => packages/e2b-adk-js/eslint.config.js (100%) rename {examples => packages/e2b-adk-js/examples}/code-executor.ts (100%) rename {examples => packages/e2b-adk-js/examples}/data-analysis.ts (100%) rename package.json => packages/e2b-adk-js/package.json (100%) rename pnpm-lock.yaml => packages/e2b-adk-js/pnpm-lock.yaml (100%) rename {scripts => packages/e2b-adk-js/scripts}/live-processes.ts (100%) rename {scripts => packages/e2b-adk-js/scripts}/live.ts (100%) rename {scripts => packages/e2b-adk-js/scripts}/openai-model.ts (100%) rename {src => packages/e2b-adk-js/src}/code-executor.ts (100%) rename {src => packages/e2b-adk-js/src}/index.ts (100%) rename {src => packages/e2b-adk-js/src}/options.ts (100%) rename {src => packages/e2b-adk-js/src}/results.ts (100%) rename {src => packages/e2b-adk-js/src}/toolset.ts (100%) rename {src => packages/e2b-adk-js/src}/workspace.ts (100%) rename {tests => packages/e2b-adk-js/tests}/executor.test.ts (100%) rename {tests => packages/e2b-adk-js/tests}/helpers.ts (100%) rename {tests => packages/e2b-adk-js/tests}/isolation.test.ts (100%) rename {tests => packages/e2b-adk-js/tests}/runner.test.ts (100%) rename {tests => packages/e2b-adk-js/tests}/tools.test.ts (100%) rename {tests => packages/e2b-adk-js/tests}/workspace.test.ts (100%) rename tsconfig.build.json => packages/e2b-adk-js/tsconfig.build.json (100%) rename tsconfig.json => packages/e2b-adk-js/tsconfig.json (100%) rename vitest.config.ts => packages/e2b-adk-js/vitest.config.ts (100%) diff --git a/.env.example b/packages/e2b-adk-js/.env.example similarity index 100% rename from .env.example rename to packages/e2b-adk-js/.env.example diff --git a/.github/workflows/ci.yml b/packages/e2b-adk-js/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/ci.yml rename to packages/e2b-adk-js/.github/workflows/ci.yml diff --git a/.gitignore b/packages/e2b-adk-js/.gitignore similarity index 100% rename from .gitignore rename to packages/e2b-adk-js/.gitignore diff --git a/.prettierignore b/packages/e2b-adk-js/.prettierignore similarity index 100% rename from .prettierignore rename to packages/e2b-adk-js/.prettierignore diff --git a/.prettierrc.json b/packages/e2b-adk-js/.prettierrc.json similarity index 100% rename from .prettierrc.json rename to packages/e2b-adk-js/.prettierrc.json diff --git a/LICENSE b/packages/e2b-adk-js/LICENSE similarity index 100% rename from LICENSE rename to packages/e2b-adk-js/LICENSE diff --git a/README.md b/packages/e2b-adk-js/README.md similarity index 100% rename from README.md rename to packages/e2b-adk-js/README.md diff --git a/eslint.config.js b/packages/e2b-adk-js/eslint.config.js similarity index 100% rename from eslint.config.js rename to packages/e2b-adk-js/eslint.config.js diff --git a/examples/code-executor.ts b/packages/e2b-adk-js/examples/code-executor.ts similarity index 100% rename from examples/code-executor.ts rename to packages/e2b-adk-js/examples/code-executor.ts diff --git a/examples/data-analysis.ts b/packages/e2b-adk-js/examples/data-analysis.ts similarity index 100% rename from examples/data-analysis.ts rename to packages/e2b-adk-js/examples/data-analysis.ts diff --git a/package.json b/packages/e2b-adk-js/package.json similarity index 100% rename from package.json rename to packages/e2b-adk-js/package.json diff --git a/pnpm-lock.yaml b/packages/e2b-adk-js/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to packages/e2b-adk-js/pnpm-lock.yaml diff --git a/scripts/live-processes.ts b/packages/e2b-adk-js/scripts/live-processes.ts similarity index 100% rename from scripts/live-processes.ts rename to packages/e2b-adk-js/scripts/live-processes.ts diff --git a/scripts/live.ts b/packages/e2b-adk-js/scripts/live.ts similarity index 100% rename from scripts/live.ts rename to packages/e2b-adk-js/scripts/live.ts diff --git a/scripts/openai-model.ts b/packages/e2b-adk-js/scripts/openai-model.ts similarity index 100% rename from scripts/openai-model.ts rename to packages/e2b-adk-js/scripts/openai-model.ts diff --git a/src/code-executor.ts b/packages/e2b-adk-js/src/code-executor.ts similarity index 100% rename from src/code-executor.ts rename to packages/e2b-adk-js/src/code-executor.ts diff --git a/src/index.ts b/packages/e2b-adk-js/src/index.ts similarity index 100% rename from src/index.ts rename to packages/e2b-adk-js/src/index.ts diff --git a/src/options.ts b/packages/e2b-adk-js/src/options.ts similarity index 100% rename from src/options.ts rename to packages/e2b-adk-js/src/options.ts diff --git a/src/results.ts b/packages/e2b-adk-js/src/results.ts similarity index 100% rename from src/results.ts rename to packages/e2b-adk-js/src/results.ts diff --git a/src/toolset.ts b/packages/e2b-adk-js/src/toolset.ts similarity index 100% rename from src/toolset.ts rename to packages/e2b-adk-js/src/toolset.ts diff --git a/src/workspace.ts b/packages/e2b-adk-js/src/workspace.ts similarity index 100% rename from src/workspace.ts rename to packages/e2b-adk-js/src/workspace.ts diff --git a/tests/executor.test.ts b/packages/e2b-adk-js/tests/executor.test.ts similarity index 100% rename from tests/executor.test.ts rename to packages/e2b-adk-js/tests/executor.test.ts diff --git a/tests/helpers.ts b/packages/e2b-adk-js/tests/helpers.ts similarity index 100% rename from tests/helpers.ts rename to packages/e2b-adk-js/tests/helpers.ts diff --git a/tests/isolation.test.ts b/packages/e2b-adk-js/tests/isolation.test.ts similarity index 100% rename from tests/isolation.test.ts rename to packages/e2b-adk-js/tests/isolation.test.ts diff --git a/tests/runner.test.ts b/packages/e2b-adk-js/tests/runner.test.ts similarity index 100% rename from tests/runner.test.ts rename to packages/e2b-adk-js/tests/runner.test.ts diff --git a/tests/tools.test.ts b/packages/e2b-adk-js/tests/tools.test.ts similarity index 100% rename from tests/tools.test.ts rename to packages/e2b-adk-js/tests/tools.test.ts diff --git a/tests/workspace.test.ts b/packages/e2b-adk-js/tests/workspace.test.ts similarity index 100% rename from tests/workspace.test.ts rename to packages/e2b-adk-js/tests/workspace.test.ts diff --git a/tsconfig.build.json b/packages/e2b-adk-js/tsconfig.build.json similarity index 100% rename from tsconfig.build.json rename to packages/e2b-adk-js/tsconfig.build.json diff --git a/tsconfig.json b/packages/e2b-adk-js/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/e2b-adk-js/tsconfig.json diff --git a/vitest.config.ts b/packages/e2b-adk-js/vitest.config.ts similarity index 100% rename from vitest.config.ts rename to packages/e2b-adk-js/vitest.config.ts From 245742491824108cf0832ed244865880c52fbfc2 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Mon, 12 Oct 2026 17:56:07 +0200 Subject: [PATCH 20/23] refactor: move e2b-adk into packages/e2b-adk-python --- .../e2b-adk-python/.env.example | 0 packages/e2b-adk-python/LICENSE | 21 +++++++++++++++++++ .../e2b-adk-python/README.md | 0 .../e2b-adk-python/e2b_adk}/__init__.py | 0 .../e2b-adk-python/e2b_adk}/_results.py | 0 .../e2b-adk-python/e2b_adk}/_sandbox.py | 0 .../e2b-adk-python/e2b_adk}/plugin.py | 0 .../e2b-adk-python/e2b_adk}/py.typed | 0 .../e2b-adk-python/e2b_adk}/tools/__init__.py | 0 .../e2b-adk-python/e2b_adk}/tools/_common.py | 0 .../e2b_adk}/tools/list_files.py | 0 .../e2b_adk}/tools/read_file.py | 0 .../e2b-adk-python/e2b_adk}/tools/run_code.py | 0 .../e2b_adk}/tools/run_command.py | 0 .../tools/start_background_command.py | 0 .../e2b_adk}/tools/write_file.py | 0 .../examples}/code_generator.py | 0 .../e2b-adk-python/examples}/data_analysis.py | 0 .../e2b-adk-python/pyproject.toml | 0 .../e2b-adk-python/tests}/__init__.py | 0 .../e2b-adk-python/tests}/conftest.py | 0 .../e2b-adk-python/tests}/test_live_smoke.py | 0 .../e2b-adk-python/tests}/test_plugin.py | 0 .../e2b-adk-python/tests}/test_results.py | 0 .../e2b-adk-python/tests}/test_sandbox.py | 0 .../e2b-adk-python/tests}/test_tools.py | 0 uv.lock => packages/e2b-adk-python/uv.lock | 0 27 files changed, 21 insertions(+) rename .env.example => packages/e2b-adk-python/.env.example (100%) create mode 100644 packages/e2b-adk-python/LICENSE rename README.md => packages/e2b-adk-python/README.md (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/__init__.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/_results.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/_sandbox.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/plugin.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/py.typed (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/__init__.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/_common.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/list_files.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/read_file.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/run_code.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/run_command.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/start_background_command.py (100%) rename {e2b_adk => packages/e2b-adk-python/e2b_adk}/tools/write_file.py (100%) rename {examples => packages/e2b-adk-python/examples}/code_generator.py (100%) rename {examples => packages/e2b-adk-python/examples}/data_analysis.py (100%) rename pyproject.toml => packages/e2b-adk-python/pyproject.toml (100%) rename {tests => packages/e2b-adk-python/tests}/__init__.py (100%) rename {tests => packages/e2b-adk-python/tests}/conftest.py (100%) rename {tests => packages/e2b-adk-python/tests}/test_live_smoke.py (100%) rename {tests => packages/e2b-adk-python/tests}/test_plugin.py (100%) rename {tests => packages/e2b-adk-python/tests}/test_results.py (100%) rename {tests => packages/e2b-adk-python/tests}/test_sandbox.py (100%) rename {tests => packages/e2b-adk-python/tests}/test_tools.py (100%) rename uv.lock => packages/e2b-adk-python/uv.lock (100%) diff --git a/.env.example b/packages/e2b-adk-python/.env.example similarity index 100% rename from .env.example rename to packages/e2b-adk-python/.env.example diff --git a/packages/e2b-adk-python/LICENSE b/packages/e2b-adk-python/LICENSE new file mode 100644 index 0000000..d4b8c19 --- /dev/null +++ b/packages/e2b-adk-python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FoundryLabs, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/packages/e2b-adk-python/README.md similarity index 100% rename from README.md rename to packages/e2b-adk-python/README.md diff --git a/e2b_adk/__init__.py b/packages/e2b-adk-python/e2b_adk/__init__.py similarity index 100% rename from e2b_adk/__init__.py rename to packages/e2b-adk-python/e2b_adk/__init__.py diff --git a/e2b_adk/_results.py b/packages/e2b-adk-python/e2b_adk/_results.py similarity index 100% rename from e2b_adk/_results.py rename to packages/e2b-adk-python/e2b_adk/_results.py diff --git a/e2b_adk/_sandbox.py b/packages/e2b-adk-python/e2b_adk/_sandbox.py similarity index 100% rename from e2b_adk/_sandbox.py rename to packages/e2b-adk-python/e2b_adk/_sandbox.py diff --git a/e2b_adk/plugin.py b/packages/e2b-adk-python/e2b_adk/plugin.py similarity index 100% rename from e2b_adk/plugin.py rename to packages/e2b-adk-python/e2b_adk/plugin.py diff --git a/e2b_adk/py.typed b/packages/e2b-adk-python/e2b_adk/py.typed similarity index 100% rename from e2b_adk/py.typed rename to packages/e2b-adk-python/e2b_adk/py.typed diff --git a/e2b_adk/tools/__init__.py b/packages/e2b-adk-python/e2b_adk/tools/__init__.py similarity index 100% rename from e2b_adk/tools/__init__.py rename to packages/e2b-adk-python/e2b_adk/tools/__init__.py diff --git a/e2b_adk/tools/_common.py b/packages/e2b-adk-python/e2b_adk/tools/_common.py similarity index 100% rename from e2b_adk/tools/_common.py rename to packages/e2b-adk-python/e2b_adk/tools/_common.py diff --git a/e2b_adk/tools/list_files.py b/packages/e2b-adk-python/e2b_adk/tools/list_files.py similarity index 100% rename from e2b_adk/tools/list_files.py rename to packages/e2b-adk-python/e2b_adk/tools/list_files.py diff --git a/e2b_adk/tools/read_file.py b/packages/e2b-adk-python/e2b_adk/tools/read_file.py similarity index 100% rename from e2b_adk/tools/read_file.py rename to packages/e2b-adk-python/e2b_adk/tools/read_file.py diff --git a/e2b_adk/tools/run_code.py b/packages/e2b-adk-python/e2b_adk/tools/run_code.py similarity index 100% rename from e2b_adk/tools/run_code.py rename to packages/e2b-adk-python/e2b_adk/tools/run_code.py diff --git a/e2b_adk/tools/run_command.py b/packages/e2b-adk-python/e2b_adk/tools/run_command.py similarity index 100% rename from e2b_adk/tools/run_command.py rename to packages/e2b-adk-python/e2b_adk/tools/run_command.py diff --git a/e2b_adk/tools/start_background_command.py b/packages/e2b-adk-python/e2b_adk/tools/start_background_command.py similarity index 100% rename from e2b_adk/tools/start_background_command.py rename to packages/e2b-adk-python/e2b_adk/tools/start_background_command.py diff --git a/e2b_adk/tools/write_file.py b/packages/e2b-adk-python/e2b_adk/tools/write_file.py similarity index 100% rename from e2b_adk/tools/write_file.py rename to packages/e2b-adk-python/e2b_adk/tools/write_file.py diff --git a/examples/code_generator.py b/packages/e2b-adk-python/examples/code_generator.py similarity index 100% rename from examples/code_generator.py rename to packages/e2b-adk-python/examples/code_generator.py diff --git a/examples/data_analysis.py b/packages/e2b-adk-python/examples/data_analysis.py similarity index 100% rename from examples/data_analysis.py rename to packages/e2b-adk-python/examples/data_analysis.py diff --git a/pyproject.toml b/packages/e2b-adk-python/pyproject.toml similarity index 100% rename from pyproject.toml rename to packages/e2b-adk-python/pyproject.toml diff --git a/tests/__init__.py b/packages/e2b-adk-python/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to packages/e2b-adk-python/tests/__init__.py diff --git a/tests/conftest.py b/packages/e2b-adk-python/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to packages/e2b-adk-python/tests/conftest.py diff --git a/tests/test_live_smoke.py b/packages/e2b-adk-python/tests/test_live_smoke.py similarity index 100% rename from tests/test_live_smoke.py rename to packages/e2b-adk-python/tests/test_live_smoke.py diff --git a/tests/test_plugin.py b/packages/e2b-adk-python/tests/test_plugin.py similarity index 100% rename from tests/test_plugin.py rename to packages/e2b-adk-python/tests/test_plugin.py diff --git a/tests/test_results.py b/packages/e2b-adk-python/tests/test_results.py similarity index 100% rename from tests/test_results.py rename to packages/e2b-adk-python/tests/test_results.py diff --git a/tests/test_sandbox.py b/packages/e2b-adk-python/tests/test_sandbox.py similarity index 100% rename from tests/test_sandbox.py rename to packages/e2b-adk-python/tests/test_sandbox.py diff --git a/tests/test_tools.py b/packages/e2b-adk-python/tests/test_tools.py similarity index 100% rename from tests/test_tools.py rename to packages/e2b-adk-python/tests/test_tools.py diff --git a/uv.lock b/packages/e2b-adk-python/uv.lock similarity index 100% rename from uv.lock rename to packages/e2b-adk-python/uv.lock From 0a5df30bbb36c432ce8b5318d823cad553072e00 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Mon, 12 Oct 2026 17:56:36 +0200 Subject: [PATCH 21/23] build: add pnpm workspace for both packages --- .gitignore | 36 +- README.md | 38 + package.json | 19 + packages/e2b-adk-js/.gitignore | 13 - packages/e2b-adk-js/package.json | 9 +- packages/e2b-adk-python/package.json | 15 + .../pnpm-lock.yaml => pnpm-lock.yaml | 869 ++++++++++++++++-- pnpm-workspace.yaml | 13 + 8 files changed, 891 insertions(+), 121 deletions(-) create mode 100644 README.md create mode 100644 package.json delete mode 100644 packages/e2b-adk-js/.gitignore create mode 100644 packages/e2b-adk-python/package.json rename packages/e2b-adk-js/pnpm-lock.yaml => pnpm-lock.yaml (80%) create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 697bf63..2b6bf0c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,32 +2,40 @@ /.claude/ /.planning/ -# Python +# Credentials. +.env +.env.* +!.env.example +/.claude/verification/auth-state.json + +# Dependencies and tool caches. +node_modules/ .venv/ __pycache__/ *.pyc -dist/ -build/ -*.egg-info/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +# Build output: regenerate, don't commit. +dist/ +build/ +coverage/ +*.egg-info/ +*.tgz +work/ +.DS_Store + # Docs staged here for separate PRs to other repos (e2b-dev/docs, google/adk-docs) /docs/ -# Features +# Personal agent notes. /features/ /CLAUDE.md /AGENTS.md /CLAUDE_ADDITIONS.md /AGENTS_ADDITIONS.md -/DECISIONS.md - -# Environment -.env -!.env.example - -# Verification credentials (never commit) -.env.verification -/.claude/verification/auth-state.json \ No newline at end of file +DECISIONS.md +EXECUTION_PLAN.md +VALIDATION.md +HANDOFF.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0c6a48 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# E2B for Google ADK + +Give [Google ADK](https://google.github.io/adk-docs/) agents code, shell, file and background-process tools that run in +an isolated [E2B](https://e2b.dev) sandbox. Available for Python and TypeScript. + +| | Package | Registry | Docs | +| --- | --- | --- | --- | +| Python | `e2b-adk` | [PyPI](https://pypi.org/project/e2b-adk/) | [packages/e2b-adk-python](packages/e2b-adk-python/README.md) | +| TypeScript | `@e2b/adk` | npm (with the first release) | [packages/e2b-adk-js](packages/e2b-adk-js/README.md) | + +## Setup + +```bash +pip install e2b-adk +# or +npm install @google/adk @e2b/adk +``` + +Set `E2B_API_KEY` ([get one](https://e2b.dev/dashboard)) and a model key such as `GOOGLE_API_KEY`. + +## Configuration + +- Python: [plugin configuration](packages/e2b-adk-python/README.md#configuration) +- TypeScript: [toolset configuration](packages/e2b-adk-js/README.md#configuration) + +## Development + +```bash +pnpm install +pnpm check # both packages: lint, types, tests, build +``` + +The Python package uses [uv](https://docs.astral.sh/uv/); run `uv sync --extra dev` in `packages/e2b-adk-python` first. +Releases go through [changesets](.changeset/README.md): both packages share one version. + +## License + +[MIT](LICENSE) diff --git a/package.json b/package.json new file mode 100644 index 0000000..227c797 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "e2b-adk-plugin", + "private": true, + "type": "module", + "packageManager": "pnpm@11.19.0", + "scripts": { + "build": "pnpm --filter @e2b/adk build", + "test": "pnpm --filter @e2b/adk test", + "test:py": "pnpm --filter @e2b/adk-python test", + "test:all": "pnpm test && pnpm test:py", + "check": "pnpm -r run check", + "changeset": "changeset", + "version": "changeset version && pnpm -r run postVersion && pnpm -r run lock", + "release": "pnpm build && pnpm --filter @e2b/adk-python run release && changeset publish" + }, + "devDependencies": { + "@changesets/cli": "2.31.1" + } +} diff --git a/packages/e2b-adk-js/.gitignore b/packages/e2b-adk-js/.gitignore deleted file mode 100644 index 3102276..0000000 --- a/packages/e2b-adk-js/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -node_modules/ -dist/ -coverage/ -.env -.env.* -!.env.example -*.tgz -.DS_Store -DECISIONS.md -EXECUTION_PLAN.md -VALIDATION.md -HANDOFF.md -work/ diff --git a/packages/e2b-adk-js/package.json b/packages/e2b-adk-js/package.json index 4fabba0..73c6f3e 100644 --- a/packages/e2b-adk-js/package.json +++ b/packages/e2b-adk-js/package.json @@ -4,10 +4,17 @@ "description": "E2B sandbox tools and code execution for Google ADK.", "type": "module", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/e2b-dev/e2b-adk-plugin.git", + "directory": "packages/e2b-adk-js" + }, + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=22" }, - "packageManager": "pnpm@10.34.5", "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/e2b-adk-python/package.json b/packages/e2b-adk-python/package.json new file mode 100644 index 0000000..96ffd63 --- /dev/null +++ b/packages/e2b-adk-python/package.json @@ -0,0 +1,15 @@ +{ + "name": "@e2b/adk-python", + "private": true, + "version": "0.1.0", + "scripts": { + "test": "uv run --frozen --extra dev pytest", + "lint": "uv run --frozen --extra dev ruff check e2b_adk tests examples", + "typecheck": "uv run --frozen --extra dev mypy e2b_adk", + "build": "rm -rf dist && uv build", + "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build", + "postVersion": "uv version --frozen $(pnpm pkg get version | tr -d '\"')", + "lock": "uv lock", + "release": "rm -rf dist && uv build && uv publish --trusted-publishing always --check-url https://pypi.org/simple/" + } +} diff --git a/packages/e2b-adk-js/pnpm-lock.yaml b/pnpm-lock.yaml similarity index 80% rename from packages/e2b-adk-js/pnpm-lock.yaml rename to pnpm-lock.yaml index 417765f..57a189a 100644 --- a/packages/e2b-adk-js/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,12 @@ settings: importers: .: + devDependencies: + '@changesets/cli': + specifier: 2.31.1 + version: 2.31.1(@types/node@22.20.4) + + packages/e2b-adk-js: dependencies: '@e2b/code-interpreter': specifier: ^2.8.0 @@ -20,13 +26,13 @@ importers: version: 9.39.5 '@google/adk': specifier: 2.1.0 - version: 2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5) + version: 2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5)(supports-color@7.2.0) '@types/node': specifier: ^22.0.0 version: 22.20.4 eslint: specifier: ^9.0.0 - version: 9.39.5 + version: 9.39.5(supports-color@7.2.0) prettier: specifier: ^3.0.0 version: 3.9.8 @@ -38,10 +44,12 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.0.0 - version: 8.70.0(eslint@9.39.5)(typescript@5.9.3) + version: 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@22.20.4)(tsx@4.23.15) + version: 3.2.7(@types/node@22.20.4)(supports-color@7.2.0)(tsx@4.23.15) + + packages/e2b-adk-python: {} packages: @@ -60,12 +68,71 @@ packages: express: optional: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} '@bufbuild/protobuf@2.15.0': resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -377,6 +444,15 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -399,6 +475,12 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mikro-orm/core@7.2.1': resolution: {integrity: sha512-+gtJoScbin/C3wPItSZkj5vlMTq/LphBLIxHyi5brCbeA6VDcYuoMopiqoFUMQxdhXXrZlKNIHXGrhb8r3biVg==} engines: {node: '>= 22.17.0'} @@ -415,6 +497,18 @@ packages: os: [linux] libc: [glibc] + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.222.0': resolution: {integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==} engines: {node: '>=8.0.0'} @@ -697,6 +791,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -821,6 +918,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -829,9 +930,16 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -855,6 +963,10 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -868,6 +980,10 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -898,6 +1014,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -970,6 +1089,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + docker-modem@5.0.7: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} @@ -997,6 +1124,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1044,6 +1175,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -1070,15 +1206,25 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1099,6 +1245,14 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1120,6 +1274,14 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1145,6 +1307,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1157,6 +1323,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -1173,6 +1343,9 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -1185,6 +1358,14 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-id@4.2.2: + resolution: {integrity: sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw==} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1219,16 +1400,32 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + js-yaml@4.3.2: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true @@ -1249,6 +1446,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonpath-plus@10.4.0: resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} engines: {node: '>=18.0.0'} @@ -1270,6 +1470,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1283,6 +1487,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -1300,6 +1507,14 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1318,6 +1533,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1366,18 +1585,44 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1394,6 +1639,10 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1404,10 +1653,18 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} @@ -1419,6 +1676,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.9.8: resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} engines: {node: '>=14'} @@ -1435,6 +1697,16 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1447,15 +1719,26 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.63.4: resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1482,13 +1765,27 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2@1.17.0: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} @@ -1513,6 +1810,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1535,6 +1836,10 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -1560,6 +1865,10 @@ packages: resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -1611,6 +1920,10 @@ packages: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1793,11 +2106,156 @@ snapshots: '@bufbuild/protobuf': 2.15.0 '@grpc/grpc-js': 1.14.5 + '@babel/runtime@7.29.7': {} + '@balena/dockerignore@1.0.2': optional: true '@bufbuild/protobuf@2.15.0': {} + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.5 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.5 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.1(@types/node@22.20.4)': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@22.20.4) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.5 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.5 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.3.2 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.2 + prettier: 2.8.8 + '@colors/colors@1.6.0': {} '@colors/colors@1.6.1': {} @@ -1899,17 +2357,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@7.2.0))': dependencies: - eslint: 9.39.5 + eslint: 9.39.5(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -1922,10 +2380,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.7': + '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -1945,10 +2403,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@google-cloud/vertexai@1.12.0': + '@google-cloud/vertexai@1.12.0(supports-color@7.2.0)': dependencies: - '@google/genai': 1.52.0 - google-auth-library: 9.15.1 + '@google/genai': 1.52.0(supports-color@7.2.0) + google-auth-library: 9.15.1(supports-color@7.2.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -1956,18 +2414,18 @@ snapshots: - supports-color - utf-8-validate - '@google/adk@2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5)': + '@google/adk@2.1.0(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5)(supports-color@7.2.0)': dependencies: '@a2a-js/sdk': 0.3.14(@bufbuild/protobuf@2.15.0)(@grpc/grpc-js@1.14.5) - '@google-cloud/vertexai': 1.12.0 - '@google/genai': 2.23.0 + '@google-cloud/vertexai': 1.12.0(supports-color@7.2.0) + '@google/genai': 2.23.0(supports-color@7.2.0) '@mikro-orm/core': 7.2.1 '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.222.0 '@opentelemetry/exporter-logs-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-gcp': 0.40.3(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.40.3(@opentelemetry/api@1.9.0)(supports-color@7.2.0) '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.0) @@ -1975,7 +2433,7 @@ snapshots: '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.0) '@types/dockerode': 4.0.1 adm-zip: 0.5.18 - google-auth-library: 10.9.1 + google-auth-library: 10.9.1(supports-color@7.2.0) js-yaml: 4.3.2 jsonpath-plus: 10.4.0 lodash-es: 4.18.1 @@ -1983,7 +2441,7 @@ snapshots: zod: 4.6.5 zod-to-json-schema: 3.25.2(zod@4.6.5) optionalDependencies: - dockerode: 5.0.1 + dockerode: 5.0.1(supports-color@7.2.0) transitivePeerDependencies: - '@bufbuild/protobuf' - '@grpc/grpc-js' @@ -1993,9 +2451,9 @@ snapshots: - supports-color - utf-8-validate - '@google/genai@1.52.0': + '@google/genai@1.52.0(supports-color@7.2.0)': dependencies: - google-auth-library: 10.9.1 + google-auth-library: 10.9.1(supports-color@7.2.0) p-retry: 4.6.2 protobufjs: 7.6.6 ws: 8.21.3 @@ -2004,9 +2462,9 @@ snapshots: - supports-color - utf-8-validate - '@google/genai@2.23.0': + '@google/genai@2.23.0(supports-color@7.2.0)': dependencies: - google-auth-library: 10.9.1 + google-auth-library: 10.9.1(supports-color@7.2.0) p-retry: 4.6.2 protobufjs: 7.6.6 ws: 8.21.3 @@ -2053,6 +2511,13 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/external-editor@1.0.3(@types/node@22.20.4)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.4 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -2070,11 +2535,39 @@ snapshots: dependencies: jsep: 1.4.0 + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + '@mikro-orm/core@7.2.1': {} '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + '@opentelemetry/api-logs@0.222.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -2129,12 +2622,12 @@ snapshots: '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-gcp@0.40.3(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-gcp@0.40.3(@opentelemetry/api@1.9.0)(supports-color@7.2.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.0) - gcp-metadata: 6.1.1 + gcp-metadata: 6.1.1(supports-color@7.2.0) transitivePeerDependencies: - encoding - supports-color @@ -2305,6 +2798,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@12.20.55': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -2321,15 +2816,15 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.0 - eslint: 9.39.5 + eslint: 9.39.5(supports-color@7.2.0) ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2337,23 +2832,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.0 '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3 - eslint: 9.39.5 + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.70.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) '@typescript-eslint/types': 8.70.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2367,13 +2862,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.5 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2381,13 +2876,13 @@ snapshots: '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.70.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.70.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.70.0(supports-color@7.2.0)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3) '@typescript-eslint/types': 8.70.0 '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -2396,13 +2891,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)': + '@typescript-eslint/utils@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.70.0 '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - eslint: 9.39.5 + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.5(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2471,15 +2966,22 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-regex@5.0.1: - optional: true + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} + array-union@2.1.0: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -2500,6 +3002,10 @@ snapshots: tweetnacl: 0.14.5 optional: true + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + bignumber.js@9.3.1: {} bl@4.1.0: @@ -2518,6 +3024,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} buffer@5.7.1: @@ -2548,6 +3058,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.2.0: {} + check-error@2.1.3: {} chownr@1.1.4: @@ -2601,17 +3113,25 @@ snapshots: data-uri-to-buffer@4.0.1: {} - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-eql@5.0.2: {} deep-is@0.1.4: {} - docker-modem@5.0.7: + detect-indent@6.1.0: {} + + dir-glob@3.0.1: dependencies: - debug: 4.4.3 + path-type: 4.0.0 + + docker-modem@5.0.7(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.17.0 @@ -2624,12 +3144,12 @@ snapshots: vscode-languageserver-textdocument: 1.0.14 vscode-languageserver-types: 3.18.3 - dockerode@5.0.1: + dockerode@5.0.1(supports-color@7.2.0): dependencies: '@balena/dockerignore': 1.0.2 '@grpc/grpc-js': 1.14.5 '@grpc/proto-loader': 0.7.15 - docker-modem: 5.0.7 + docker-modem: 5.0.7(supports-color@7.2.0) protobufjs: 7.6.6 tar-fs: 2.1.5 transitivePeerDependencies: @@ -2666,6 +3186,11 @@ snapshots: once: 1.4.0 optional: true + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + es-module-lexer@1.7.0: {} esbuild@0.28.2: @@ -2713,14 +3238,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5: + eslint@9.39.5(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7 + '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -2730,7 +3255,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -2758,6 +3283,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -2778,12 +3305,26 @@ snapshots: extend@3.0.2: {} + extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2799,6 +3340,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2820,13 +3370,25 @@ snapshots: fs-constants@1.0.0: optional: true + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fsevents@2.3.3: optional: true - gaxios@6.7.1: + gaxios@6.7.1(supports-color@7.2.0): dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-stream: 2.0.1 node-fetch: 2.7.0 uuid: 9.0.1 @@ -2834,26 +3396,26 @@ snapshots: - encoding - supports-color - gaxios@7.3.1: + gaxios@7.3.1(supports-color@7.2.0): dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) node-fetch: 3.3.2 transitivePeerDependencies: - supports-color - gcp-metadata@6.1.1: + gcp-metadata@6.1.1(supports-color@7.2.0): dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@7.2.0) google-logging-utils: 0.0.2 json-bigint: 1.0.0 transitivePeerDependencies: - encoding - supports-color - gcp-metadata@8.1.2: + gcp-metadata@8.1.2(supports-color@7.2.0): dependencies: - gaxios: 7.3.1 + gaxios: 7.3.1(supports-color@7.2.0) google-logging-utils: 1.1.3 json-bigint: 1.0.0 transitivePeerDependencies: @@ -2862,6 +3424,10 @@ snapshots: get-caller-file@2.0.5: optional: true + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2874,24 +3440,33 @@ snapshots: globals@14.0.0: {} - google-auth-library@10.9.1: + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + google-auth-library@10.9.1(supports-color@7.2.0): dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 7.3.1 - gcp-metadata: 8.1.2 + gaxios: 7.3.1(supports-color@7.2.0) + gcp-metadata: 8.1.2(supports-color@7.2.0) google-logging-utils: 1.1.3 jws: 4.0.1 transitivePeerDependencies: - supports-color - google-auth-library@9.15.1: + google-auth-library@9.15.1(supports-color@7.2.0): dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 + gaxios: 6.7.1(supports-color@7.2.0) + gcp-metadata: 6.1.1(supports-color@7.2.0) + gtoken: 7.1.0(supports-color@7.2.0) jws: 4.0.1 transitivePeerDependencies: - encoding @@ -2901,9 +3476,11 @@ snapshots: google-logging-utils@1.1.3: {} - gtoken@7.1.0: + graceful-fs@4.2.11: {} + + gtoken@7.1.0(supports-color@7.2.0): dependencies: - gaxios: 6.7.1 + gaxios: 6.7.1(supports-color@7.2.0) jws: 4.0.1 transitivePeerDependencies: - encoding @@ -2911,13 +3488,19 @@ snapshots: has-flag@4.0.0: {} - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color + human-id@4.2.2: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: optional: true @@ -2943,12 +3526,25 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-number@7.0.0: {} + is-stream@2.0.1: {} + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-windows@1.0.2: {} + isexe@2.0.0: {} js-tokens@9.0.1: {} + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -2965,6 +3561,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonpath-plus@10.4.0: dependencies: '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) @@ -2993,6 +3593,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3004,6 +3608,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash.startcase@4.4.0: {} + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -3023,6 +3629,13 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 @@ -3040,6 +3653,8 @@ snapshots: mkdirp-classic@0.5.3: optional: true + mri@1.2.0: {} + ms@2.1.3: {} nan@2.29.0: @@ -3085,19 +3700,41 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-map@2.1.0: {} + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3111,14 +3748,20 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} + pify@4.0.1: {} + platform@1.3.6: {} postcss@8.5.28: @@ -3129,6 +3772,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@2.8.8: {} + prettier@3.9.8: {} protobufjs@7.6.6: @@ -3153,6 +3798,17 @@ snapshots: punycode@2.3.1: {} + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.15.2 + pify: 4.0.1 + strip-bom: 3.0.0 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3164,8 +3820,12 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} + rollup@4.63.4: dependencies: '@types/estree': 1.0.9 @@ -3198,12 +3858,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.4 fsevents: 2.3.3 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: - optional: true + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -3215,11 +3878,22 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + + slash@3.0.0: {} + source-map-js@1.2.1: {} + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + split-ca@1.0.1: optional: true + sprintf-js@1.0.3: {} + ssh2@1.17.0: dependencies: asn1: 0.2.6 @@ -3249,7 +3923,8 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - optional: true + + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -3286,6 +3961,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + term-size@2.2.1: {} + text-hex@1.0.0: {} tinybench@2.9.0: {} @@ -3303,6 +3980,10 @@ snapshots: tinyspy@4.0.6: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + tr46@0.0.3: {} triple-beam@1.4.1: {} @@ -3324,13 +4005,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.70.0(eslint@9.39.5)(typescript@5.9.3): + typescript-eslint@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.0(eslint@9.39.5)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@9.39.5)(typescript@5.9.3) - eslint: 9.39.5 + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.5(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3346,6 +4027,8 @@ snapshots: undici@8.10.2: optional: true + universalify@0.1.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3356,10 +4039,10 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@22.20.4)(tsx@4.23.15): + vite-node@3.2.4(@types/node@22.20.4)(supports-color@7.2.0)(tsx@4.23.15): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.6(@types/node@22.20.4)(tsx@4.23.15) @@ -3390,7 +4073,7 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.15 - vitest@3.2.7(@types/node@22.20.4)(tsx@4.23.15): + vitest@3.2.7(@types/node@22.20.4)(supports-color@7.2.0)(tsx@4.23.15): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -3401,7 +4084,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -3413,7 +4096,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.3.6(@types/node@22.20.4)(tsx@4.23.15) - vite-node: 3.2.4(@types/node@22.20.4)(tsx@4.23.15) + vite-node: 3.2.4(@types/node@22.20.4)(supports-color@7.2.0)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..fc3a859 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +packages: + - packages/* + +# Supply-chain guard: skip package versions published in the last 7 days (in minutes). +minimumReleaseAge: 10080 + +# No dependency needs its install script: esbuild ships its binary as an optional package. +allowBuilds: + '@google/genai': false + cpu-features: false + esbuild: false + protobufjs: false + ssh2: false From 51952e436c0f5637eec104c455c5fc166bfd52f8 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Mon, 12 Oct 2026 17:56:36 +0200 Subject: [PATCH 22/23] ci: release both packages with changesets --- .changeset/README.md | 5 + .changeset/config.json | 14 ++ .github/scripts/is_release.sh | 5 + .github/workflows/ci.yml | 40 +++-- .github/workflows/release.yml | 178 ++++++++++++++----- packages/e2b-adk-js/.github/workflows/ci.yml | 19 -- 6 files changed, 180 insertions(+), 81 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100755 .github/scripts/is_release.sh delete mode 100644 packages/e2b-adk-js/.github/workflows/ci.yml diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..0a3a479 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +Describe each change with `pnpm changeset` and commit the generated file with it. A release runs `pnpm run version` and +`pnpm run release` in CI (`.github/workflows/release.yml`); do not run `changeset version` locally, or the release +finds nothing to publish. The npm and PyPI packages share one version. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..196f43e --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [["@e2b/adk", "@e2b/adk-python"]], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "privatePackages": { + "version": true, + "tag": true + } +} diff --git a/.github/scripts/is_release.sh b/.github/scripts/is_release.sh new file mode 100755 index 0000000..3b88bd0 --- /dev/null +++ b/.github/scripts/is_release.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Prints "true" when .changeset/ holds pending changesets, which is what gates a release. +set -euo pipefail +count=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ') +[ "$count" -gt 0 ] && echo "true" || echo "false" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e31f5eb..6fcf03a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,16 @@ permissions: contents: read jobs: - lint-type-test: + python: + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] + defaults: + run: + working-directory: packages/e2b-adk-python steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -23,7 +27,7 @@ jobs: with: python-version: ${{ matrix.python-version }} enable-cache: true - cache-dependency-glob: uv.lock + cache-dependency-glob: packages/e2b-adk-python/uv.lock - name: Install dependencies run: uv sync --locked --extra dev @@ -35,26 +39,30 @@ jobs: run: uv run mypy e2b_adk - name: Test - # Tests mock the E2B sandbox — no credentials or network access needed. + # Tests mock the E2B sandbox: no credentials or network access needed. run: uv run pytest - build: + - name: Build sdist and wheel + if: matrix.python-version == '3.12' + run: uv build + + js: + name: Node ${{ matrix.node }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - python-version: "3.12" - enable-cache: true - cache-dependency-glob: uv.lock + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - - name: Install dependencies - run: uv sync --locked --extra dev + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: ${{ matrix.node }} + cache: pnpm - - name: Build sdist and wheel - run: uv run python -m build + - run: pnpm install --frozen-lockfile - - name: Check package metadata - run: uv run twine check dist/* + - run: pnpm --filter @e2b/adk check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf3abf5..e82134d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,72 +1,158 @@ name: Release +# Releases are cut by hand: write changesets (`pnpm changeset`), merge them, then +# `gh workflow run release.yml --ref main`. One run versions and publishes both packages: +# @e2b/adk to npm and e2b-adk to PyPI, both over OIDC trusted publishing (no tokens). +# Both registries trust this file and the `deployment` environment. on: - release: - types: [published] + workflow_dispatch: {} + +concurrency: release-${{ github.ref }} permissions: contents: read jobs: - build: - name: Build and test distributions + preflight: + name: Release preflight runs-on: ubuntu-latest - + outputs: + release: ${{ steps.version.outputs.release }} steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Check the ref + # `workflow_dispatch` offers every branch in the picker, and a feature branch + # carrying changesets would otherwise publish real packages from that branch. + if: github.ref != 'refs/heads/main' + env: + REF: ${{ github.ref }} + run: | + echo "::error::Releases must run on main; this run is on ${REF}." + exit 1 - - name: Set up uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: "0.11.19" - python-version: "3.13" - enable-cache: true - cache-dependency-glob: uv.lock + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Install dependencies - run: uv sync --locked --extra dev + - name: Check for pending changesets + id: version + run: echo "release=$(./.github/scripts/is_release.sh)" >> "$GITHUB_OUTPUT" + + test: + name: Build and test + needs: preflight + if: needs.preflight.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Run unit tests - run: uv run pytest + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - - name: Run lint checks - run: uv run ruff check e2b_adk tests examples + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + cache: pnpm - - name: Run type checks - run: uv run mypy e2b_adk + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: '3.13' + enable-cache: true + cache-dependency-glob: packages/e2b-adk-python/uv.lock - - name: Build distributions - run: uv run python -m build + - run: pnpm install --frozen-lockfile - - name: Check package metadata - run: uv run twine check dist/* + - run: uv sync --frozen --extra dev + working-directory: packages/e2b-adk-python - - name: Upload distributions - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: python-package-distributions - path: dist/* - if-no-files-found: error - retention-days: 7 + - run: pnpm check publish: - name: Publish to PyPI - needs: build + name: Version and publish + needs: [preflight, test] + if: needs.preflight.outputs.release == 'true' runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/e2b-adk + # Both trusted publishers (npm and PyPI) are pinned to this environment, and its + # deployment branch policy only allows main. + environment: deployment permissions: - contents: read + contents: write id-token: write - steps: - - name: Download distributions - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + id: app-token + with: + app-id: ${{ vars.VERSION_BUMPER_APPID }} + private-key: ${{ secrets.VERSION_BUMPER_SECRET }} + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + registry-url: 'https://registry.npmjs.org' + cache: pnpm + + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: '3.13' + enable-cache: true + cache-dependency-glob: packages/e2b-adk-python/uv.lock + + - name: Update npm + # npm trusted publishing needs npm 11.5.1 or newer. + run: | + npm install -g npm@^11.6 + npm --version + + - run: pnpm install --frozen-lockfile + + - run: uv sync --frozen --extra dev + working-directory: packages/e2b-adk-python + + - name: No local dependencies + # A file: or link: dependency would publish a local path in package.json or the + # Python sdist, and nothing outside this repo could install it. + run: | + if grep -nE '"(file|link):' package.json packages/*/package.json || grep -nE '(file|path) *=' packages/e2b-adk-python/pyproject.toml; then + echo "::error::Local dependency paths found; point them at published versions before releasing." + exit 1 + fi + + - name: Create new versions + run: pnpm run version + + - name: Commit new versions + id: commit + # Commit before publishing, because `changeset publish` tags whatever HEAD it + # publishes from. + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # `add -A`: `changeset version` writes each CHANGELOG.md as a new file the first time. + git add -A + if git diff --cached --quiet; then + echo "::error::'changeset version' produced no changes, so there is nothing to publish or tag." + exit 1 + fi + git commit -m "[skip ci] Release new versions" + + - name: Publish + id: release + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: - name: python-package-distributions - path: dist + publish: pnpm run release + createGithubReleases: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish distributions to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + - name: Push new versions + # Keep the version bump even after a partial publish, so a later release cannot + # reuse a version that one registry already has. + if: always() && steps.commit.outcome == 'success' && steps.release.outcome != 'skipped' + run: | + if ! git push; then + git fetch origin "${GITHUB_REF_NAME}" + git merge --no-edit FETCH_HEAD + git push + fi diff --git a/packages/e2b-adk-js/.github/workflows/ci.yml b/packages/e2b-adk-js/.github/workflows/ci.yml deleted file mode 100644 index bb30c9b..0000000 --- a/packages/e2b-adk-js/.github/workflows/ci.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: CI -on: [push, pull_request] -permissions: - contents: read -jobs: - check: - runs-on: ubuntu-latest - strategy: - matrix: - node: [22, 24] - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm check From 51da567bb0dd30ab3ed0c9dcbec603bfd7ba9043 Mon Sep 17 00:00:00 2001 From: Ondrej Drapalik Date: Mon, 26 Oct 2026 14:28:10 +0100 Subject: [PATCH 23/23] chore: start both packages at 0.1.1 PyPI already has e2b-adk 0.1.1, and the fixed changesets group counts from package.json, so the first shared release lands on 0.1.2. --- packages/e2b-adk-js/package.json | 8 ++++---- packages/e2b-adk-python/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/e2b-adk-js/package.json b/packages/e2b-adk-js/package.json index 73c6f3e..5ac1c9a 100644 --- a/packages/e2b-adk-js/package.json +++ b/packages/e2b-adk-js/package.json @@ -1,6 +1,6 @@ { "name": "@e2b/adk", - "version": "0.1.0", + "version": "0.1.1", "description": "E2B sandbox tools and code execution for Google ADK.", "type": "module", "license": "MIT", @@ -48,14 +48,14 @@ "zod": "^4.2.1" }, "devDependencies": { + "@eslint/js": "^9.0.0", "@google/adk": "2.1.0", "@types/node": "^22.0.0", "eslint": "^9.0.0", - "@eslint/js": "^9.0.0", - "typescript-eslint": "^8.0.0", "prettier": "^3.0.0", - "typescript": "~5.9.3", "tsx": "^4.20.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.0.0", "vitest": "^3.2.4" } } diff --git a/packages/e2b-adk-python/package.json b/packages/e2b-adk-python/package.json index 96ffd63..a8359c1 100644 --- a/packages/e2b-adk-python/package.json +++ b/packages/e2b-adk-python/package.json @@ -1,7 +1,7 @@ { "name": "@e2b/adk-python", "private": true, - "version": "0.1.0", + "version": "0.1.1", "scripts": { "test": "uv run --frozen --extra dev pytest", "lint": "uv run --frozen --extra dev ruff check e2b_adk tests examples",