diff --git a/.github/workflows/publish-js-sdks.yml b/.github/workflows/publish-js-sdks.yml new file mode 100644 index 00000000..1b9b7a66 --- /dev/null +++ b/.github/workflows/publish-js-sdks.yml @@ -0,0 +1,72 @@ +name: Publish JavaScript SDKs + +on: + push: + tags: + - "js/sandbox/v*" + - "js/code-interpreter/v*" + +permissions: + contents: read + +jobs: + publish: + name: Publish (${{ matrix.sdk.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: + - name: sandbox + tagPrefix: sandbox + workingDirectory: sdks/sandbox/javascript + packageName: "@alibaba-group/opensandbox" + - name: code-interpreter + tagPrefix: code-interpreter + workingDirectory: sdks/code-interpreter/javascript + packageName: "@alibaba-group/opensandbox-code-interpreter" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Enable corepack + run: corepack enable + + - name: Get pnpm store path + id: pnpm-store + run: echo "STORE_PATH=$(corepack pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('sdks/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Install workspace dependencies + working-directory: sdks + run: corepack pnpm install --frozen-lockfile + + - name: Build SDK + working-directory: sdks + run: corepack pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build + + - name: Publish to npm + if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) + working-directory: ${{ matrix.sdk.workingDirectory }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + corepack pnpm publish --access public --no-git-checks diff --git a/.github/workflows/real-e2e.yml b/.github/workflows/real-e2e.yml index 7200b11e..3e1c85d3 100644 --- a/.github/workflows/real-e2e.yml +++ b/.github/workflows/real-e2e.yml @@ -114,3 +114,57 @@ jobs: name: java-test-report path: tests/java/build/reports/tests/test/ retention-days: 5 + + javascript-e2e: + name: JavaScript E2E (docker bridge) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install uv + run: pip install uv + + - name: Run tests + env: + TAG: latest + run: | + set -e + + # Create config file (match other E2E jobs) + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + log_level = "INFO" + api_key = "" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:${TAG}" + [docker] + network_mode = "bridge" + EOF + + bash ./scripts/javascript-e2e.sh + + - name: Eval logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: javascript-test-report + path: tests/javascript/build/test-results/junit.xml + retention-days: 5 diff --git a/README.md b/README.md index 3751577a..f6cd76a8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ OpenSandbox is a **universal sandbox platform** for AI application scenarios, pr ## Features -- **Multi-language SDKs**: Provides sandbox SDKs in Python, Java, TypeScript (Roadmap),Go(Roadmap) and other languages. +- **Multi-language SDKs**: Provides sandbox SDKs in Python, Java/Kotlin, JavaScript/TypeScript, Go (Roadmap), and more. - **Sandbox Protocol**: Defines sandbox lifecycle management API and sandbox execution API. You can extend your own sandbox runtime through these sandbox protocols. - **Sandbox Runtime**: Implements sandbox lifecycle management by default, supports Docker and Kubernetes runtimes, enabling large-scale distributed sandbox scheduling. - **Sandbox Environments**: Built-in implementations for Command, Filesystem, Code Interpreter. And provides examples for Coding Agents (Claude Code, etc.), Browser automation (Chrome, Playwright), and Desktop environments (VNC, VS Code). @@ -176,7 +176,7 @@ For more details, please refer to [examples](examples/README.md) and the README | [`components/execd/`](components/execd/README.md) | Sandbox execution daemon (commands and file operations) | | [`components/ingress/`](components/ingress/README.md) | Sandbox traffic ingress proxy | | [`components/egress/`](components/egress/README.md) | Sandbox network egress control | -| [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin) | +| [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin, JavaScript/TypeScript) | | [`sandboxes/`](sandboxes/) | Sandbox runtime images (e.g., code-interpreter) | | [`kubernetes/`](kubernetes/README.md) | Kubernetes operator and batch sandbox support | | [`specs/`](specs/README.md) | OpenAPI specifications | @@ -192,8 +192,8 @@ For detailed architecture, see [docs/architecture.md](docs/architecture.md). - [docs/architecture.md](docs/architecture.md) – Overall architecture & design philosophy - SDK - - Sandbox base SDK ([Java\Kotlin SDK](sdks/sandbox/kotlin/README.md), [Python SDK](sdks/sandbox/python/README.md)) - includes sandbox lifecycle, command execution, file operations - - Code Interpreter SDK ([Java\Kotlin SDK](sdks/code-interpreter/kotlin/README.md), [Python SDK](sdks/code-interpreter/python/README.md)) - code interpreter + - Sandbox base SDK ([Java\Kotlin SDK](sdks/sandbox/kotlin/README.md), [Python SDK](sdks/sandbox/python/README.md), [JavaScript/TypeScript SDK](sdks/sandbox/javascript/README.md)) - includes sandbox lifecycle, command execution, file operations + - Code Interpreter SDK ([Java\Kotlin SDK](sdks/code-interpreter/kotlin/README.md), [Python SDK](sdks/code-interpreter/python/README.md), [JavaScript/TypeScript SDK](sdks/code-interpreter/javascript/README.md)) - code interpreter - [specs/README.md](specs/README.md) - Contains OpenAPI definitions for sandbox lifecycle API and sandbox execution API - [server/README.md](server/README.md) - Contains sandbox server startup and configuration, currently supports Docker Runtime, will support Kubernetes Runtime in the future @@ -207,7 +207,6 @@ You can use OpenSandbox for personal or commercial projects in compliance with t ### SDK -- [ ] **TypeScript SDK** - TypeScript/JavaScript client SDK for sandbox lifecycle management and command execution, file operations. - [ ] **Go SDK** - Go client SDK for sandbox lifecycle management and command execution, file operations. ### Server Runtime diff --git a/components/execd/bootstrap.sh b/components/execd/bootstrap.sh index 326932d8..d8dc612c 100755 --- a/components/execd/bootstrap.sh +++ b/components/execd/bootstrap.sh @@ -33,4 +33,5 @@ export EXECD_ENVS echo "starting OpenSandbox execd daemon at $EXECD" $EXECD & +set -x exec "$@" diff --git a/docs/README_zh.md b/docs/README_zh.md index 99f53252..74e3b6c8 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -17,7 +17,7 @@ OpenSandbox 是一个面向 AI 应用场景设计的「通用沙箱平台」, ## 核心特性 -- **多语言 SDK**:提供 Python、Java、TypeScript (Roadmap)、Go (Roadmap) 等语言的客户端 SDK。 +- **多语言 SDK**:提供 Python、Java/Kotlin、JavaScript/TypeScript 等语言的客户端 SDK,Go SDK 仍在规划中。 - **沙箱协议**:定义了沙箱生命周期管理 API 和沙箱执行 API。你可以通过这些沙箱协议扩展自己的沙箱运行时。 - **沙箱运行时**:默认实现沙箱生命周期管理,支持 Docker 和 Kubernetes 运行时,实现大规模分布式沙箱调度。 - **沙箱环境**:内置 Command、Filesystem、Code Interpreter 实现。并提供 Coding Agent(Claude Code 等)、浏览器自动化(Chrome、Playwright)和桌面环境(VNC、VS Code)等示例。 @@ -175,7 +175,7 @@ OpenSandbox 提供了丰富的示例来演示不同场景下的沙箱使用方 | [`components/execd/`](../components/execd/README_zh.md) | 沙箱执行守护进程,负责命令和文件操作 | | [`components/ingress/`](../components/ingress/README.md) | 沙箱流量入口代理 | | [`components/egress/`](../components/egress/README.md) | 沙箱网络 Egress 访问控制 | -| [`sdks/`](../sdks/) | 多语言 SDK(Python、Java/Kotlin) | +| [`sdks/`](../sdks/) | 多语言 SDK(Python、Java/Kotlin、JavaScript/TypeScript) | | [`sandboxes/`](../sandboxes/) | 沙箱运行时镜像(如 code-interpreter) | | [`kubernetes/`](../kubernetes/README-ZH.md) | Kubernetes Operator 和批量沙箱支持 | | [`specs/`](../specs/README_zh.md) | OpenAPI 规范 | @@ -191,8 +191,8 @@ OpenSandbox 提供了丰富的示例来演示不同场景下的沙箱使用方 - [docs/architecture.md](architecture.md) – 整体架构 & 设计理念 - SDK - - Sandbox 基础 SDK([Java\Kotlin SDK](../sdks/sandbox/kotlin/README_zh.md)、[Python SDK](../sdks/sandbox/python/README_zh.md))-包含沙箱生命周期、命令执行、文件操作 - - Code Interpreter SDK([Java\Kotlin SDK](../sdks/code-interpreter/kotlin/README_zh.md) 、[Python SDK](../sdks/code-interpreter/python/README_zh.md))- 代码解释器 + - Sandbox 基础 SDK([Java\Kotlin SDK](../sdks/sandbox/kotlin/README_zh.md)、[Python SDK](../sdks/sandbox/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/sandbox/javascript/README_zh.md))- 包含沙箱生命周期、命令执行、文件操作 + - Code Interpreter SDK([Java\Kotlin SDK](../sdks/code-interpreter/kotlin/README_zh.md) 、[Python SDK](../sdks/code-interpreter/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/code-interpreter/javascript/README_zh.md))- 代码解释器 - [specs/README.md](../specs/README_zh.md) - 包含沙箱生命周期 API 和沙箱执行 API 的 OpenAPI 定义 - [server/README.md](../server/README_zh.md) - 包含沙箱 Server 的启动和配置,目前支持 Docker Runtime,后续将支持 Kubernetes Runtime @@ -206,7 +206,6 @@ OpenSandbox 提供了丰富的示例来演示不同场景下的沙箱使用方 ### SDK -- [ ] **TypeScript SDK** - TypeScript/JavaScript 客户端 SDK,用于沙箱生命周期管理、命令执行和文件操作 - [ ] **Go SDK** - Go 客户端 SDK,用于沙箱生命周期管理、命令执行和文件操作 ### Server Runtime diff --git a/examples/code-interpreter/README.md b/examples/code-interpreter/README.md index ec5ed6ef..a6dc0131 100644 --- a/examples/code-interpreter/README.md +++ b/examples/code-interpreter/README.md @@ -63,6 +63,141 @@ The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and p 3 + 4 = 7 +=== TypeScript example === +[TypeScript stdout] Hello from TypeScript! + +[TypeScript stdout] sum = 6 +``` + +# Code Interpreter Sandbox from pool + +## Start OpenSandbox server [k8s] + +Install the k8s OpenSandbox operator, and create a pool: +```yaml +apiVersion: sandbox.opensandbox.io/v1alpha1 +kind: Pool +metadata: + labels: + app.kubernetes.io/name: sandbox-k8s + app.kubernetes.io/managed-by: kustomize + name: pool-sample + namespace: opensandbox +spec: + template: + metadata: + labels: + app: example + spec: + volumes: + - name: sandbox-storage + emptyDir: { } + - name: opensandbox-bin + emptyDir: { } + initContainers: + - name: task-executor-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp /workspace/server /opt/opensandbox/bin/task-executor && + chmod +x /opt/opensandbox/bin/task-executor + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + - name: execd-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp ./execd /opt/opensandbox/bin/execd && + cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && + chmod +x /opt/opensandbox/bin/execd && + chmod +x /opt/opensandbox/bin/bootstrap.sh + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + containers: + - name: sandbox + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest + command: + - "/bin/sh" + - "-c" + - | + /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 + env: + - name: SANDBOX_MAIN_CONTAINER + value: main + - name: EXECD_ENVS + value: /opt/opensandbox/.env + - name: EXECD + value: /opt/opensandbox/bin/execd + volumeMounts: + - name: sandbox-storage + mountPath: /var/lib/sandbox + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin + tolerations: + - operator: "Exists" + capacitySpec: + bufferMax: 3 + bufferMin: 1 + poolMax: 5 + poolMin: 0 +``` + +Start the k8s OpenSandbox server: + +```shell +git clone git@github.com:alibaba/OpenSandbox.git +cd OpenSandbox/server + +# replace with your k8s cluster config, kubeconfig etc. +cp example.config.k8s.toml ~/.sandbox.toml +cp example.batchsandbox-template.yaml ~/batchsandbox-template.yaml + +uv sync +uv run python -m src.main +``` + +## Create and access the Code Interpreter Sandbox + +```shell +# Install OpenSandbox packages +uv pip install opensandbox opensandbox-code-interpreter + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) +uv run python examples/code-interpreter/main_use_pool.py +``` + +The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance. + +## Environment variables + +- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) +- `SANDBOX_API_KEY`: API key if your server requires authentication +- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest`) + +## Example output + +```text +=== Verify Environment Variable === +[ENV Check] TEST_ENV value: test + +[ENV Result] 'test' + +=== Java example === +[Java stdout] Hello from Java! + +[Java stdout] 2 + 3 = 5 + +[Java result] 5 + +=== Go example === +[Go stdout] Hello from Go! +3 + 4 = 7 + + === TypeScript example === [TypeScript stdout] Hello from TypeScript! diff --git a/examples/code-interpreter/main_use_pool.py b/examples/code-interpreter/main_use_pool.py new file mode 100644 index 00000000..4e119387 --- /dev/null +++ b/examples/code-interpreter/main_use_pool.py @@ -0,0 +1,117 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import os +from datetime import timedelta + +from code_interpreter import CodeInterpreter, SupportedLanguage +from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig + + +async def main() -> None: + domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") + api_key = os.getenv("SANDBOX_API_KEY") + image = os.getenv( + "SANDBOX_IMAGE", + "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest", + ) + + config = ConnectionConfig( + domain=domain, + api_key=api_key, + request_timeout=timedelta(seconds=60), + ) + + sandbox = await Sandbox.create( + image, + connection_config=config, + extensions={"poolRef":"pool-sample"}, + entrypoint=["/opt/opensandbox/code-interpreter.sh"], + env={ + "TEST_ENV": "test", + }, + ) + + async with sandbox: + interpreter = await CodeInterpreter.create(sandbox=sandbox) + + # Verify environment variable is set + print("\n=== Verify Environment Variable ===") + env_check = await interpreter.codes.run( + "import os\n" + "test_env = os.getenv('TEST_ENV', 'NOT_SET')\n" + "print(f'TEST_ENV value: {test_env}')\n" + "test_env", + language=SupportedLanguage.PYTHON, + ) + for msg in env_check.logs.stdout: + print(f"[ENV Check] {msg.text}") + if env_check.result: + for res in env_check.result: + print(f"[ENV Result] {res.text}") + + # Java example: print to stdout and return the final result line. + java_exec = await interpreter.codes.run( + "System.out.println(\"Hello from Java!\");\n" + "int result = 2 + 3;\n" + "System.out.println(\"2 + 3 = \" + result);\n" + "result", + language=SupportedLanguage.JAVA, + ) + print("\n=== Java example ===") + for msg in java_exec.logs.stdout: + print(f"[Java stdout] {msg.text}") + if java_exec.result: + for res in java_exec.result: + print(f"[Java result] {res.text}") + if java_exec.error: + print(f"[Java error] {java_exec.error.name}: {java_exec.error.value}") + + # Go example: print logs and demonstrate a main function structure. + go_exec = await interpreter.codes.run( + "package main\n" + "import \"fmt\"\n" + "func main() {\n" + " fmt.Println(\"Hello from Go!\")\n" + " sum := 3 + 4\n" + " fmt.Println(\"3 + 4 =\", sum)\n" + "}", + language=SupportedLanguage.GO, + ) + print("\n=== Go example ===") + for msg in go_exec.logs.stdout: + print(f"[Go stdout] {msg.text}") + if go_exec.error: + print(f"[Go error] {go_exec.error.name}: {go_exec.error.value}") + + # TypeScript example: use typing and sum an array. + ts_exec = await interpreter.codes.run( + "console.log('Hello from TypeScript!');\n" + "const nums: number[] = [1, 2, 3];\n" + "console.log('sum =', nums.reduce((a, b) => a + b, 0));", + language=SupportedLanguage.TYPESCRIPT, + ) + print("\n=== TypeScript example ===") + for msg in ts_exec.logs.stdout: + print(f"[TypeScript stdout] {msg.text}") + if ts_exec.error: + print(f"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}") + + await sandbox.kill() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml index 9516e9e0..a3e1cb16 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-sample - namespace: sandbox-k8s + namespace: opensandbox spec: replicas: 2 template: diff --git a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml index d4621452..8c94fc72 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-sample - namespace: sandbox-k8s + namespace: opensandbox spec: replicas: 1 poolRef: pool-sample diff --git a/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml b/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml index 01f0ec81..8112905a 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_pool.yaml @@ -5,63 +5,61 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: pool-sample - namespace: sandbox-k8s + namespace: opensandbox spec: template: metadata: labels: app: example spec: - shareProcessNamespace: true volumes: - name: sandbox-storage emptyDir: { } - name: opensandbox-bin emptyDir: { } initContainers: + - name: task-executor-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:latest + command: [ "/bin/sh", "-c" ] + args: + - | + cp /workspace/server /opt/opensandbox/bin/task-executor && + chmod +x /opt/opensandbox/bin/task-executor + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox/bin - name: execd-installer image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest command: [ "/bin/sh", "-c" ] args: - | - cp ./execd /opt/opensandbox/execd/execd && - chmod +x /opt/opensandbox/execd/execd + cp ./execd /opt/opensandbox/bin/execd && + cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && + chmod +x /opt/opensandbox/bin/execd && + chmod +x /opt/opensandbox/bin/bootstrap.sh volumeMounts: - name: opensandbox-bin - mountPath: /opt/opensandbox/execd + mountPath: /opt/opensandbox/bin containers: - - name: main + - name: sandbox image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest command: - "/bin/sh" - "-c" - args: - - "/opt/opensandbox/execd/execd > /tmp/execd.log 2>&1" + - | + /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 env: - name: SANDBOX_MAIN_CONTAINER value: main + - name: EXECD_ENVS + value: /opt/opensandbox/.env + - name: EXECD + value: /opt/opensandbox/bin/execd volumeMounts: - name: sandbox-storage mountPath: /var/lib/sandbox - name: opensandbox-bin - mountPath: /opt/opensandbox/execd - - command: - - /workspace/server - - -listen-addr=0.0.0.0:5758 - - -enable-sidecar-mode=true - image: task-executor:dev - name: task-executor - securityContext: - capabilities: - add: - - SYS_PTRACE - - SYS_ADMIN - - NET_ADMIN - volumeMounts: - - name: sandbox-storage - mountPath: /var/lib/sandbox - - name: opensandbox-bin - mountPath: /opt/opensandbox/execd + mountPath: /opt/opensandbox/bin tolerations: - operator: "Exists" capacitySpec: diff --git a/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml b/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml index e57927fd..efbb84d1 100644 --- a/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml +++ b/kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml @@ -5,7 +5,7 @@ metadata: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: batchsandbox-pool-sample - namespace: sandbox-k8s + namespace: opensandbox spec: poolRef: pool-sample replicas: 2 diff --git a/kubernetes/internal/controller/batchsandbox_controller.go b/kubernetes/internal/controller/batchsandbox_controller.go index 88d36b07..48c08434 100644 --- a/kubernetes/internal/controller/batchsandbox_controller.go +++ b/kubernetes/internal/controller/batchsandbox_controller.go @@ -50,7 +50,6 @@ import ( "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations" "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex" "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/requeueduration" - api "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor" ) var ( @@ -193,7 +192,8 @@ func (r *BatchSandboxReconciler) Reconcile(ctx context.Context, req ctrl.Request } // task schedule - if batchSbx.Spec.TaskTemplate != nil { + taskStrategy := NewTaskSchedulingStrategy(batchSbx) + if taskStrategy.NeedTaskScheduling(batchSbx) { // Because tasks are in-memory and there is no event mechanism, periodic reconciliation is required. DurationStore.Push(types.NamespacedName{Namespace: batchSbx.Namespace, Name: batchSbx.Name}.String(), 3*time.Second) sch, err := r.getTaskScheduler(batchSbx, pods) @@ -321,7 +321,8 @@ func (r *BatchSandboxReconciler) getTaskScheduler(batchSbx *sandboxv1alpha1.Batc if batchSbx.Spec.TaskResourcePolicyWhenCompleted != nil { policy = *batchSbx.Spec.TaskResourcePolicyWhenCompleted } - taskSpecs, err := generaTaskSpec(batchSbx) + taskStrategy := NewTaskSchedulingStrategy(batchSbx) + taskSpecs, err := taskStrategy.GenerateTaskSpecs(batchSbx) if err != nil { return nil, err } @@ -349,52 +350,6 @@ func (r *BatchSandboxReconciler) deleteTaskScheduler(batchSbx *sandboxv1alpha1.B r.taskSchedulers.Delete(key) } -func generaTaskSpec(batchSbx *sandboxv1alpha1.BatchSandbox) ([]*api.Task, error) { - ret := make([]*api.Task, *batchSbx.Spec.Replicas) - for idx := range int(*batchSbx.Spec.Replicas) { - task, err := getTaskSpec(batchSbx, idx) - if err != nil { - return ret, err - } - ret[idx] = task - } - return ret, nil -} - -// TODO: Consider handling container task dispatch with template & shardPatches under resource acceleration mode -func getTaskSpec(batchSbx *sandboxv1alpha1.BatchSandbox, idx int) (*api.Task, error) { - task := &api.Task{ - Name: fmt.Sprintf("%s-%d", batchSbx.Name, idx), - } - if len(batchSbx.Spec.ShardTaskPatches) > 0 && idx < len(batchSbx.Spec.ShardTaskPatches) { - taskTemplate := batchSbx.Spec.TaskTemplate.DeepCopy() - cloneBytes, _ := json.Marshal(taskTemplate) - patch := batchSbx.Spec.ShardTaskPatches[idx] - modified, err := strategicpatch.StrategicMergePatch(cloneBytes, patch.Raw, &sandboxv1alpha1.TaskTemplateSpec{}) - if err != nil { - return nil, fmt.Errorf("batchsandbox: failed to merge patch raw %s, idx %d, err %w", patch.Raw, idx, err) - } - newTaskTemplate := &sandboxv1alpha1.TaskTemplateSpec{} - if err = json.Unmarshal(modified, newTaskTemplate); err != nil { - return nil, fmt.Errorf("batchsandbox: failed to unmarshal %s to TaskTemplateSpec, idx %d, err %w", modified, idx, err) - } - task.Process = &api.Process{ - Command: newTaskTemplate.Spec.Process.Command, - Args: newTaskTemplate.Spec.Process.Args, - Env: newTaskTemplate.Spec.Process.Env, - WorkingDir: newTaskTemplate.Spec.Process.WorkingDir, - } - } else if batchSbx.Spec.TaskTemplate != nil && batchSbx.Spec.TaskTemplate.Spec.Process != nil { - task.Process = &api.Process{ - Command: batchSbx.Spec.TaskTemplate.Spec.Process.Command, - Args: batchSbx.Spec.TaskTemplate.Spec.Process.Args, - Env: batchSbx.Spec.TaskTemplate.Spec.Process.Env, - WorkingDir: batchSbx.Spec.TaskTemplate.Spec.Process.WorkingDir, - } - } - return task, nil -} - func (r *BatchSandboxReconciler) scheduleTasks(ctx context.Context, tSch taskscheduler.TaskScheduler, batchSbx *sandboxv1alpha1.BatchSandbox) error { if err := tSch.Schedule(); err != nil { return err diff --git a/kubernetes/internal/controller/batchsandbox_controller_test.go b/kubernetes/internal/controller/batchsandbox_controller_test.go index 11b8ccd8..a5ff3f3f 100644 --- a/kubernetes/internal/controller/batchsandbox_controller_test.go +++ b/kubernetes/internal/controller/batchsandbox_controller_test.go @@ -51,7 +51,6 @@ import ( taskscheduler "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler" mock_scheduler "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler/mock" "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex" - api "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor" ) func init() { @@ -696,158 +695,6 @@ func TestBatchSandboxReconciler_scheduleTasks(t *testing.T) { } } -func Test_getTaskSpec(t *testing.T) { - type args struct { - batchSbx *sandboxv1alpha1.BatchSandbox - idx int - } - tests := []struct { - name string - args args - want *api.Task - wantErr bool - }{ - { - name: "basic task spec without patches", - args: args{ - batchSbx: &sandboxv1alpha1.BatchSandbox{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-bs", - Namespace: "default", - }, - Spec: sandboxv1alpha1.BatchSandboxSpec{ - TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ - Spec: sandboxv1alpha1.TaskSpec{ - Process: &sandboxv1alpha1.ProcessTask{ - Command: []string{"echo", "hello"}, - }, - }, - }, - }, - }, - idx: 0, - }, - want: &api.Task{ - Name: "test-bs-0", - Process: &api.Process{ - Command: []string{"echo", "hello"}, - }, - }, - wantErr: false, - }, - { - name: "task spec with shard patch", - args: args{ - batchSbx: &sandboxv1alpha1.BatchSandbox{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-bs", - Namespace: "default", - }, - Spec: sandboxv1alpha1.BatchSandboxSpec{ - TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ - Spec: sandboxv1alpha1.TaskSpec{ - Process: &sandboxv1alpha1.ProcessTask{ - Command: []string{"echo", "hello"}, - }, - }, - }, - ShardTaskPatches: []runtime.RawExtension{ - { - Raw: []byte(`{"spec":{"process":{"command":["echo","world"]}}}`), - }, - }, - }, - }, - idx: 0, - }, - want: &api.Task{ - Name: "test-bs-0", - Process: &api.Process{ - Command: []string{"echo", "world"}, - }, - }, - wantErr: false, - }, - { - name: "task spec with invalid patch", - args: args{ - batchSbx: &sandboxv1alpha1.BatchSandbox{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-bs", - Namespace: "default", - }, - Spec: sandboxv1alpha1.BatchSandboxSpec{ - TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ - Spec: sandboxv1alpha1.TaskSpec{ - Process: &sandboxv1alpha1.ProcessTask{ - Command: []string{"echo", "hello"}, - }, - }, - }, - ShardTaskPatches: []runtime.RawExtension{ - { - Raw: []byte(`{"invalid json`), - }, - }, - }, - }, - idx: 0, - }, - want: nil, - wantErr: true, - }, - { - name: "task spec with index out of range patch", - args: args{ - batchSbx: &sandboxv1alpha1.BatchSandbox{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-bs", - Namespace: "default", - }, - Spec: sandboxv1alpha1.BatchSandboxSpec{ - TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ - Spec: sandboxv1alpha1.TaskSpec{ - Process: &sandboxv1alpha1.ProcessTask{ - Command: []string{"echo", "hello"}, - }, - }, - }, - ShardTaskPatches: []runtime.RawExtension{ - { - Raw: []byte(`{"spec":{"process":{"command":["echo","world"]}}}`), - }, - }, - }, - }, - idx: 1, - }, - want: &api.Task{ - Name: "test-bs-1", - Process: &api.Process{ - Command: []string{"echo", "hello"}, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := getTaskSpec(tt.args.batchSbx, tt.args.idx) - if (err != nil) != tt.wantErr { - t.Errorf("getTaskSpec() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr { - if got.Name != tt.want.Name { - t.Errorf("getTaskSpec() name = %v, want %v", got.Name, tt.want.Name) - } - if !reflect.DeepEqual(got.Process, tt.want.Process) { - t.Errorf("getTaskSpec() spec = %v, want %v", got.Process, tt.want.Process) - } - } - }) - } -} func Test_parseIndex(t *testing.T) { type args struct { @@ -1136,4 +983,4 @@ func Test_calPodIndex(t *testing.T) { } }) } -} +} \ No newline at end of file diff --git a/kubernetes/internal/controller/task_scheduling_strategy.go b/kubernetes/internal/controller/task_scheduling_strategy.go new file mode 100644 index 00000000..b00e7cfb --- /dev/null +++ b/kubernetes/internal/controller/task_scheduling_strategy.go @@ -0,0 +1,31 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1" + api "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor" +) + +// TaskSchedulingStrategy defines the strategy interface for task scheduling. +// Different implementations can provide custom logic for determining whether +// task scheduling is needed and how to generate task specifications. +type TaskSchedulingStrategy interface { + // NeedTaskScheduling determines whether the BatchSandbox requires task scheduling. + NeedTaskScheduling(batchSbx *sandboxv1alpha1.BatchSandbox) bool + + // GenerateTaskSpecs generates the complete list of task specifications for the BatchSandbox. + GenerateTaskSpecs(batchSbx *sandboxv1alpha1.BatchSandbox) ([]*api.Task, error) +} diff --git a/kubernetes/internal/controller/task_scheduling_strategy_default.go b/kubernetes/internal/controller/task_scheduling_strategy_default.go new file mode 100644 index 00000000..1593bf66 --- /dev/null +++ b/kubernetes/internal/controller/task_scheduling_strategy_default.go @@ -0,0 +1,86 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "fmt" + + "k8s.io/apimachinery/pkg/util/strategicpatch" + + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1" + api "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor" +) + +// DefaultTaskSchedulingStrategy implements the default task scheduling strategy. +type DefaultTaskSchedulingStrategy struct{} + +// NewDefaultTaskSchedulingStrategy creates a new default task scheduling strategy. +func NewDefaultTaskSchedulingStrategy() *DefaultTaskSchedulingStrategy { + return &DefaultTaskSchedulingStrategy{} +} + +// NeedTaskScheduling determines whether task scheduling is needed based on TaskTemplate. +func (s *DefaultTaskSchedulingStrategy) NeedTaskScheduling(batchSbx *sandboxv1alpha1.BatchSandbox) bool { + return batchSbx.Spec.TaskTemplate != nil +} + +// GenerateTaskSpecs generates task specifications for all replicas. +func (s *DefaultTaskSchedulingStrategy) GenerateTaskSpecs(batchSbx *sandboxv1alpha1.BatchSandbox) ([]*api.Task, error) { + ret := make([]*api.Task, *batchSbx.Spec.Replicas) + for idx := range int(*batchSbx.Spec.Replicas) { + task, err := s.getTaskSpec(batchSbx, idx) + if err != nil { + return ret, err + } + ret[idx] = task + } + return ret, nil +} + +// getTaskSpec generates a single task specification for the given index. +// It applies ShardTaskPatches if available, otherwise uses the base TaskTemplate. +func (s *DefaultTaskSchedulingStrategy) getTaskSpec(batchSbx *sandboxv1alpha1.BatchSandbox, idx int) (*api.Task, error) { + task := &api.Task{ + Name: fmt.Sprintf("%s-%d", batchSbx.Name, idx), + } + if len(batchSbx.Spec.ShardTaskPatches) > 0 && idx < len(batchSbx.Spec.ShardTaskPatches) { + taskTemplate := batchSbx.Spec.TaskTemplate.DeepCopy() + cloneBytes, _ := json.Marshal(taskTemplate) + patch := batchSbx.Spec.ShardTaskPatches[idx] + modified, err := strategicpatch.StrategicMergePatch(cloneBytes, patch.Raw, &sandboxv1alpha1.TaskTemplateSpec{}) + if err != nil { + return nil, fmt.Errorf("batchsandbox: failed to merge patch raw %s, idx %d, err %w", patch.Raw, idx, err) + } + newTaskTemplate := &sandboxv1alpha1.TaskTemplateSpec{} + if err = json.Unmarshal(modified, newTaskTemplate); err != nil { + return nil, fmt.Errorf("batchsandbox: failed to unmarshal %s to TaskTemplateSpec, idx %d, err %w", modified, idx, err) + } + task.Process = &api.Process{ + Command: newTaskTemplate.Spec.Process.Command, + Args: newTaskTemplate.Spec.Process.Args, + Env: newTaskTemplate.Spec.Process.Env, + WorkingDir: newTaskTemplate.Spec.Process.WorkingDir, + } + } else if batchSbx.Spec.TaskTemplate != nil && batchSbx.Spec.TaskTemplate.Spec.Process != nil { + task.Process = &api.Process{ + Command: batchSbx.Spec.TaskTemplate.Spec.Process.Command, + Args: batchSbx.Spec.TaskTemplate.Spec.Process.Args, + Env: batchSbx.Spec.TaskTemplate.Spec.Process.Env, + WorkingDir: batchSbx.Spec.TaskTemplate.Spec.Process.WorkingDir, + } + } + return task, nil +} diff --git a/kubernetes/internal/controller/task_scheduling_strategy_default_test.go b/kubernetes/internal/controller/task_scheduling_strategy_default_test.go new file mode 100644 index 00000000..991c99e8 --- /dev/null +++ b/kubernetes/internal/controller/task_scheduling_strategy_default_test.go @@ -0,0 +1,215 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1" + api "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor" +) + +func TestDefaultTaskSchedulingStrategy_NeedTaskScheduling(t *testing.T) { + strategy := NewDefaultTaskSchedulingStrategy() + tests := []struct { + name string + batchSbx *sandboxv1alpha1.BatchSandbox + want bool + }{ + { + name: "with task template", + batchSbx: &sandboxv1alpha1.BatchSandbox{ + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{}, + }, + }, + want: true, + }, + { + name: "without task template", + batchSbx: &sandboxv1alpha1.BatchSandbox{ + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: nil, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strategy.NeedTaskScheduling(tt.batchSbx); got != tt.want { + t.Errorf("DefaultTaskSchedulingStrategy.NeedTaskScheduling() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDefaultTaskSchedulingStrategy_getTaskSpec(t *testing.T) { + strategy := NewDefaultTaskSchedulingStrategy() + type args struct { + batchSbx *sandboxv1alpha1.BatchSandbox + idx int + } + tests := []struct { + name string + args args + want *api.Task + wantErr bool + }{ + { + name: "basic task spec without patches", + args: args{ + batchSbx: &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "default", + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ + Spec: sandboxv1alpha1.TaskSpec{ + Process: &sandboxv1alpha1.ProcessTask{ + Command: []string{"echo", "hello"}, + }, + }, + }, + }, + }, + idx: 0, + }, + want: &api.Task{ + Name: "test-bs-0", + Process: &api.Process{ + Command: []string{"echo", "hello"}, + }, + }, + wantErr: false, + }, + { + name: "task spec with shard patch", + args: args{ + batchSbx: &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "default", + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ + Spec: sandboxv1alpha1.TaskSpec{ + Process: &sandboxv1alpha1.ProcessTask{ + Command: []string{"echo", "hello"}, + }, + }, + }, + ShardTaskPatches: []runtime.RawExtension{ + { + Raw: []byte(`{"spec":{"process":{"command":["echo","world"]}}}`), + }, + }, + }, + }, + idx: 0, + }, + want: &api.Task{ + Name: "test-bs-0", + Process: &api.Process{ + Command: []string{"echo", "world"}, + }, + }, + wantErr: false, + }, + { + name: "task spec with invalid patch", + args: args{ + batchSbx: &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "default", + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ + Spec: sandboxv1alpha1.TaskSpec{ + Process: &sandboxv1alpha1.ProcessTask{ + Command: []string{"echo", "hello"}, + }, + }, + }, + ShardTaskPatches: []runtime.RawExtension{ + { + Raw: []byte(`{"invalid json`), + }, + }, + }, + }, + idx: 0, + }, + want: nil, + wantErr: true, + }, + { + name: "task spec with index out of range patch", + args: args{ + batchSbx: &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "default", + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{ + Spec: sandboxv1alpha1.TaskSpec{ + Process: &sandboxv1alpha1.ProcessTask{ + Command: []string{"echo", "hello"}, + }, + }, + }, + ShardTaskPatches: []runtime.RawExtension{ + { + Raw: []byte(`{"spec":{"process":{"command":["echo","world"]}}}`), + }, + }, + }, + }, + idx: 1, + }, + want: &api.Task{ + Name: "test-bs-1", + Process: &api.Process{ + Command: []string{"echo", "hello"}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := strategy.getTaskSpec(tt.args.batchSbx, tt.args.idx) + if (err != nil) != tt.wantErr { + t.Errorf("DefaultTaskSchedulingStrategy.getTaskSpec() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got.Name != tt.want.Name { + t.Errorf("DefaultTaskSchedulingStrategy.getTaskSpec() name = %v, want %v", got.Name, tt.want.Name) + } + if !reflect.DeepEqual(got.Process, tt.want.Process) { + t.Errorf("DefaultTaskSchedulingStrategy.getTaskSpec() spec = %v, want %v", got.Process, tt.want.Process) + } + } + }) + } +} diff --git a/kubernetes/internal/controller/task_scheduling_strategy_factory.go b/kubernetes/internal/controller/task_scheduling_strategy_factory.go new file mode 100644 index 00000000..51285ff8 --- /dev/null +++ b/kubernetes/internal/controller/task_scheduling_strategy_factory.go @@ -0,0 +1,25 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1" +) + +// NewTaskSchedulingStrategy creates a task scheduling strategy based on BatchSandbox properties. +// This function is designed to be easily customizable for different implementations: +func NewTaskSchedulingStrategy(batchSbx *sandboxv1alpha1.BatchSandbox) TaskSchedulingStrategy { + return NewDefaultTaskSchedulingStrategy() +} diff --git a/scripts/add-license.sh b/scripts/add-license.sh index d4c80fbd..0b608a7b 100755 --- a/scripts/add-license.sh +++ b/scripts/add-license.sh @@ -143,7 +143,7 @@ style_for_file() { case "$ext" in sh|py|toml|tf|sql) echo "line:#"; return ;; - go|java|kt|kts|ts|tsx|js|jsx) echo "line://"; return ;; + go|java|kt|kts|ts|tsx|js|jsx|mjs|cjs|mts|cts) echo "line://"; return ;; css) echo "block:css"; return ;; html) echo "block:html"; return ;; esac @@ -207,7 +207,11 @@ process_file() { main() { local files - IFS=$'\n' read -r -d '' -a files < <(git ls-files && printf '\0') + if [[ "$#" -gt 0 ]]; then + IFS=$'\n' read -r -d '' -a files < <(git ls-files -- "$@" && printf '\0') + else + IFS=$'\n' read -r -d '' -a files < <(git ls-files && printf '\0') + fi for f in "${files[@]}"; do process_file "$f" done diff --git a/scripts/javascript-e2e.sh b/scripts/javascript-e2e.sh new file mode 100644 index 00000000..1cc9cc4b --- /dev/null +++ b/scripts/javascript-e2e.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euxo pipefail + +TAG=${TAG:-latest} + +# build execd image locally +cd components/execd && docker build -t opensandbox/execd:${TAG} . +cd ../.. + +# prepare required images from registry +docker pull opensandbox/code-interpreter:${TAG} + +# setup server +cd server +uv sync && uv run python -m src.main > server.log 2>&1 & +cd .. + +# wait for server +sleep 10 + +# run JavaScript/TypeScript e2e (SDK builds are handled by the test script) +cd tests/javascript + +# Pin pnpm via corepack (repo expects pnpm@9.x) +corepack enable +corepack prepare pnpm@9.15.0 --activate + +pnpm install + +# Ensure SDK workspace deps exist before running build steps (CI does not have prebuilt node_modules). +pnpm -C ../../sdks install --frozen-lockfile + +# Align with other E2E jobs: local server does not require API key by default. +# Ensure tests do not send an auth header. +export OPENSANDBOX_TEST_API_KEY="" +export OPENSANDBOX_SANDBOX_DEFAULT_IMAGE="opensandbox/code-interpreter:${TAG}" + +pnpm test:ci + diff --git a/sdks/code-interpreter/javascript/.nvmrc b/sdks/code-interpreter/javascript/.nvmrc new file mode 100644 index 00000000..4ca17c4e --- /dev/null +++ b/sdks/code-interpreter/javascript/.nvmrc @@ -0,0 +1,3 @@ +20 + + diff --git a/sdks/code-interpreter/javascript/README.md b/sdks/code-interpreter/javascript/README.md new file mode 100644 index 00000000..048970d7 --- /dev/null +++ b/sdks/code-interpreter/javascript/README.md @@ -0,0 +1,187 @@ +# Alibaba Code Interpreter SDK for JavaScript/TypeScript + +English | [中文](README_zh.md) + +A TypeScript/JavaScript SDK for executing code in secure, isolated sandboxes. It provides a high-level API for running Python, Java, Go, TypeScript, and other languages safely, with support for code execution contexts. + +## Prerequisites + +This SDK requires a Docker image containing the Code Interpreter runtime environment. You must use the `opensandbox/code-interpreter` image (or a derivative) which includes pre-installed runtimes for Python, Java, Go, Node.js, etc. + +For detailed information about supported languages and versions, please refer to the [Environment Documentation](../../../sandboxes/code-interpreter/README.md). + +## Installation + +### npm + +```bash +npm install @alibaba-group/opensandbox-code-interpreter +``` + +### pnpm + +```bash +pnpm add @alibaba-group/opensandbox-code-interpreter +``` + +### yarn + +```bash +yarn add @alibaba-group/opensandbox-code-interpreter +``` + +## Quick Start + +The following example demonstrates how to create a sandbox with a specific runtime configuration and execute a simple script. + +> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions. + +```ts +import { ConnectionConfig, Sandbox } from "@alibaba-group/opensandbox"; +import { CodeInterpreter, SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +// 1. Configure connection +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-api-key", +}); + +// 2. Create a Sandbox with the code-interpreter image + runtime versions +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "opensandbox/code-interpreter:latest", + entrypoint: ["/opt/opensandbox/code-interpreter.sh"], + env: { + PYTHON_VERSION: "3.11", + JAVA_VERSION: "17", + NODE_VERSION: "20", + GO_VERSION: "1.24", + }, + timeoutSeconds: 15 * 60, +}); + +// 3. Create CodeInterpreter wrapper +const ci = await CodeInterpreter.create(sandbox); + +// 4. Create an execution context (Python) +const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + +// 5. Run code +const result = await ci.codes.run("import sys\nprint(sys.version)\nresult = 2 + 2\nresult", { + context: ctx, +}); + +// 6. Print output +console.log(result.result[0]?.text); + +// 7. Cleanup remote instance (optional but recommended) +await sandbox.kill(); +``` + +## Runtime Configuration + +### Docker Image + +The Code Interpreter SDK relies on a specialized environment. Ensure your sandbox provider has the `opensandbox/code-interpreter` image available. + +### Language Version Selection + +You can specify the desired version of a programming language by setting the corresponding environment variable when creating the `Sandbox`. + +| Language | Environment Variable | Example Value | Default (if unset) | +| --- | --- | --- | --- | +| Python | `PYTHON_VERSION` | `3.11` | Image default | +| Java | `JAVA_VERSION` | `17` | Image default | +| Node.js | `NODE_VERSION` | `20` | Image default | +| Go | `GO_VERSION` | `1.24` | Image default | + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "opensandbox/code-interpreter:latest", + entrypoint: ["/opt/opensandbox/code-interpreter.sh"], + env: { + JAVA_VERSION: "17", + GO_VERSION: "1.24", + }, +}); +``` + +## Usage Examples + +### 0. Run with `language` (default language context) + +If you don't need to manage explicit context IDs, you can run code by specifying only `language`. +When `context.id` is omitted, execd can create/reuse a default session for that language, so state can persist across runs. + +```ts +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +await ci.codes.run("x = 42", { language: SupportedLanguages.PYTHON }); +const execution = await ci.codes.run("result = x\nresult", { language: SupportedLanguages.PYTHON }); +console.log(execution.result[0]?.text); // "42" +``` + +### 0.1 Context management (list/get/delete) + +You can manage contexts explicitly (aligned with Python/Kotlin SDKs): + +```ts +const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + +const same = await ci.codes.getContext(ctx.id!); +console.log(same.id, same.language); + +const all = await ci.codes.listContexts(); +const pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON); + +await ci.codes.deleteContext(ctx.id!); +await ci.codes.deleteContexts(SupportedLanguages.PYTHON); // bulk cleanup +``` + +### 1. Java Code Execution + +```ts +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +const javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA); +const execution = await ci.codes.run( + [ + 'System.out.println("Calculating sum...");', + "int a = 10;", + "int b = 20;", + "int sum = a + b;", + 'System.out.println("Sum: " + sum);', + "sum", + ].join("\n"), + { context: javaCtx }, +); +console.log(execution.logs.stdout.map((m) => m.text)); +``` + +### 2. Streaming Output Handling + +Handle stdout/stderr and execution events in real-time. + +```ts +import type { ExecutionHandlers } from "@alibaba-group/opensandbox"; +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +const handlers: ExecutionHandlers = { + onStdout: (m) => console.log("STDOUT:", m.text), + onStderr: (m) => console.error("STDERR:", m.text), + onResult: (r) => console.log("RESULT:", r.text), +}; + +const pyCtx = await ci.codes.createContext(SupportedLanguages.PYTHON); +await ci.codes.run("import time\nfor i in range(5):\n print(i)\n time.sleep(0.2)", { + context: pyCtx, + handlers, +}); +``` + +## Notes + +- **Lifecycle**: `CodeInterpreter` wraps an existing `Sandbox` instance and reuses its connection configuration. +- **Default context**: `codes.run(..., { language })` uses a language default context (state can persist across runs). + diff --git a/sdks/code-interpreter/javascript/README_zh.md b/sdks/code-interpreter/javascript/README_zh.md new file mode 100644 index 00000000..54154fa6 --- /dev/null +++ b/sdks/code-interpreter/javascript/README_zh.md @@ -0,0 +1,187 @@ +# Alibaba Code Interpreter JavaScript/TypeScript SDK + +中文 | [English](README.md) + +一个用于在安全、隔离的沙箱环境中执行代码的 TypeScript/JavaScript SDK。该 SDK 提供了高级 API,支持安全地运行 Python、Java、Go、TypeScript 等语言,并具备“代码执行上下文(Context)”能力。 + +## 前置要求 + +本 SDK 需要配合包含 Code Interpreter 运行时环境的特定 Docker 镜像使用。请务必使用 `opensandbox/code-interpreter` 镜像(或其衍生镜像),其中预装了 Python、Java、Go、Node.js 等语言的运行环境。 + +关于支持的语言与具体版本信息,请参考 [环境文档](../../../sandboxes/code-interpreter/README_zh.md)。 + +## 安装指南 + +### npm + +```bash +npm install @alibaba-group/opensandbox-code-interpreter +``` + +### pnpm + +```bash +pnpm add @alibaba-group/opensandbox-code-interpreter +``` + +### yarn + +```bash +yarn add @alibaba-group/opensandbox-code-interpreter +``` + +## 快速开始 + +以下示例展示了如何创建带指定运行时配置的 Sandbox,并执行一段简单脚本。 + +> **注意**: 在运行此示例之前,请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。 + +```ts +import { ConnectionConfig, Sandbox } from "@alibaba-group/opensandbox"; +import { CodeInterpreter, SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +// 1. 配置连接信息 +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-api-key", +}); + +// 2. 创建 Sandbox(必须使用 code-interpreter 镜像),并指定语言版本 +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "opensandbox/code-interpreter:latest", + entrypoint: ["/opt/opensandbox/code-interpreter.sh"], + env: { + PYTHON_VERSION: "3.11", + JAVA_VERSION: "17", + NODE_VERSION: "20", + GO_VERSION: "1.24", + }, + timeoutSeconds: 15 * 60, +}); + +// 3. 创建 CodeInterpreter 包装器 +const ci = await CodeInterpreter.create(sandbox); + +// 4. 创建执行上下文(Python) +const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + +// 5. 运行代码 +const result = await ci.codes.run("import sys\nprint(sys.version)\nresult = 2 + 2\nresult", { + context: ctx, +}); + +// 6. 打印输出 +console.log(result.result[0]?.text); + +// 7. 清理远程实例(可选,但推荐) +await sandbox.kill(); +``` + +## 运行时配置 + +### Docker 镜像 + +Code Interpreter SDK 依赖于特定的运行环境。请确保你的沙箱服务提供商支持 `opensandbox/code-interpreter` 镜像。 + +### 语言版本选择 + +你可以在创建 `Sandbox` 时通过环境变量指定所需的编程语言版本。 + +| 语言 | 环境变量 | 示例值 | 默认值(若不设置) | +| --- | --- | --- | --- | +| Python | `PYTHON_VERSION` | `3.11` | 镜像默认值 | +| Java | `JAVA_VERSION` | `17` | 镜像默认值 | +| Node.js | `NODE_VERSION` | `20` | 镜像默认值 | +| Go | `GO_VERSION` | `1.24` | 镜像默认值 | + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "opensandbox/code-interpreter:latest", + entrypoint: ["/opt/opensandbox/code-interpreter.sh"], + env: { + JAVA_VERSION: "17", + GO_VERSION: "1.24", + }, +}); +``` + +## 核心功能示例 + +### 0. 直接传 `language`(使用该语言默认上下文) + +如果你不需要显式管理 context id,可以只传 `language` 来执行代码。 +当 `context.id` 省略时,execd 可以为该语言创建/复用默认 session,因此状态可以跨次执行保持: + +```ts +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +await ci.codes.run("x = 42", { language: SupportedLanguages.PYTHON }); +const execution = await ci.codes.run("result = x\nresult", { language: SupportedLanguages.PYTHON }); +console.log(execution.result[0]?.text); // "42" +``` + +### 0.1 Context 管理(list/get/delete) + +你也可以显式管理 context(与 Python/Kotlin SDK 对齐): + +```ts +const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + +const same = await ci.codes.getContext(ctx.id!); +console.log(same.id, same.language); + +const all = await ci.codes.listContexts(); +const pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON); + +await ci.codes.deleteContext(ctx.id!); +await ci.codes.deleteContexts(SupportedLanguages.PYTHON); // 批量清理 +``` + +### 1. Java 代码执行 + +```ts +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +const javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA); +const execution = await ci.codes.run( + [ + 'System.out.println("Calculating sum...");', + "int a = 10;", + "int b = 20;", + "int sum = a + b;", + 'System.out.println("Sum: " + sum);', + "sum", + ].join("\n"), + { context: javaCtx }, +); +console.log(execution.logs.stdout.map((m) => m.text)); +``` + +### 2. 流式输出处理 + +实时处理 stdout/stderr 等事件。 + +```ts +import type { ExecutionHandlers } from "@alibaba-group/opensandbox"; +import { SupportedLanguages } from "@alibaba-group/opensandbox-code-interpreter"; + +const handlers: ExecutionHandlers = { + onStdout: (m) => console.log("STDOUT:", m.text), + onStderr: (m) => console.error("STDERR:", m.text), + onResult: (r) => console.log("RESULT:", r.text), +}; + +const pyCtx = await ci.codes.createContext(SupportedLanguages.PYTHON); +await ci.codes.run("import time\nfor i in range(5):\n print(i)\n time.sleep(0.2)", { + context: pyCtx, + handlers, +}); +``` + +## 说明 + +- **生命周期**:`CodeInterpreter` 基于既有的 `Sandbox` 实例进行包装,并复用其连接配置。 +- **默认上下文**:`codes.run(..., { language })` 会使用语言默认 context(同语言的状态可跨次执行保持)。 + diff --git a/sdks/code-interpreter/javascript/eslint.config.mjs b/sdks/code-interpreter/javascript/eslint.config.mjs new file mode 100644 index 00000000..89d8f1b5 --- /dev/null +++ b/sdks/code-interpreter/javascript/eslint.config.mjs @@ -0,0 +1,12 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createBaseConfig } from "../../eslint.base.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default createBaseConfig({ + tsconfigRootDir: __dirname, + tsconfigPath: "./tsconfig.json", + extraIgnores: ["src/**/*.d.ts", "src/**/*.js"], +}); + diff --git a/sdks/code-interpreter/javascript/package.json b/sdks/code-interpreter/javascript/package.json new file mode 100644 index 00000000..3c0ab9ff --- /dev/null +++ b/sdks/code-interpreter/javascript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@alibaba-group/opensandbox-code-interpreter", + "version": "0.1.0-dev2", + "description": "OpenSandbox Code Interpreter TypeScript/JavaScript SDK", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "browser": "./dist/index.js", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/alibaba/OpenSandbox.git" + }, + "bugs": { + "url": "https://github.com/alibaba/OpenSandbox/issues" + }, + "homepage": "https://github.com/alibaba/OpenSandbox", + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@9.15.0", + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "eslint src --max-warnings 0", + "clean": "rm -rf dist" + }, + "dependencies": { + "@alibaba-group/opensandbox": "workspace:^" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "eslint": "^9.39.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.52.0" + } +} diff --git a/sdks/code-interpreter/javascript/src/adapters/codesAdapter.ts b/sdks/code-interpreter/javascript/src/adapters/codesAdapter.ts new file mode 100644 index 00000000..5c688a0a --- /dev/null +++ b/sdks/code-interpreter/javascript/src/adapters/codesAdapter.ts @@ -0,0 +1,188 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecdClient, ExecdPaths } from "@alibaba-group/opensandbox/internal"; +import type { ServerStreamEvent } from "@alibaba-group/opensandbox"; +import type { Execution, ExecutionHandlers } from "@alibaba-group/opensandbox"; +import { + ExecutionEventDispatcher, + InvalidArgumentException, +} from "@alibaba-group/opensandbox"; + +import type { Codes } from "../services/codes.js"; +import type { CodeContext, SupportedLanguage } from "../models.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import { parseJsonEventStream } from "./sse.js"; + +type ApiCreateContextRequest = + ExecdPaths["/code/context"]["post"]["requestBody"]["content"]["application/json"]; +type ApiCreateContextOk = + ExecdPaths["/code/context"]["post"]["responses"][200]["content"]["application/json"]; +type ApiGetContextOk = + ExecdPaths["/code/contexts/{context_id}"]["get"]["responses"][200]["content"]["application/json"]; +type ApiListContextsOk = + ExecdPaths["/code/contexts"]["get"]["responses"][200]["content"]["application/json"]; +type ApiRunCodeRequest = + ExecdPaths["/code"]["post"]["requestBody"]["content"]["application/json"]; + +/** + * Single-layer codes adapter for the Code Interpreter SDK. + * + * - Handles HTTP/SSE streaming via the underlying execd adapter + * - Builds the structured {@link Execution} result for `run(...)` + */ +function joinUrl(baseUrl: string, pathname: string): string { + const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const path = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${base}${path}`; +} + +export class CodesAdapter implements Codes { + private readonly fetch: typeof fetch; + + constructor( + private readonly client: ExecdClient, + private readonly opts: { baseUrl: string; fetch?: typeof fetch; headers?: Record }, + ) { + this.fetch = opts.fetch ?? fetch; + } + + async createContext(language: SupportedLanguage): Promise { + const body: ApiCreateContextRequest = { language }; + const { data, error, response } = await this.client.POST("/code/context", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Create code context failed"); + const ok = data as ApiCreateContextOk | undefined; + if (!ok || typeof ok !== "object") { + throw new Error("Create code context failed: unexpected response shape"); + } + if (typeof ok.language !== "string" || !ok.language) { + throw new Error("Create code context failed: missing language"); + } + return { id: ok.id, language: ok.language }; + } + + async getContext(contextId: string): Promise { + if (!contextId?.trim()) { + throw new InvalidArgumentException({ message: "contextId cannot be empty" }); + } + const { data, error, response } = await this.client.GET("/code/contexts/{context_id}", { + params: { path: { context_id: contextId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Get code context failed"); + const ok = data as ApiGetContextOk | undefined; + if (!ok || typeof ok !== "object") { + throw new Error("Get code context failed: unexpected response shape"); + } + if (typeof (ok as any).language !== "string" || !(ok as any).language) { + throw new Error("Get code context failed: missing language"); + } + return { id: (ok as any).id, language: (ok as any).language }; + } + + async listContexts(language?: SupportedLanguage): Promise { + const { data, error, response } = await this.client.GET("/code/contexts", { + params: language ? { query: { language } } : undefined, + } as any); + throwOnOpenApiFetchError({ error, response }, "List code contexts failed"); + const ok = data as ApiListContextsOk | undefined; + if (!Array.isArray(ok)) { + throw new Error("List code contexts failed: unexpected response shape"); + } + return ok + .filter((c) => c && typeof c === "object") + .map((c: any) => ({ id: c.id, language: c.language as any })); + } + + async deleteContext(contextId: string): Promise { + if (!contextId?.trim()) { + throw new InvalidArgumentException({ message: "contextId cannot be empty" }); + } + const { error, response } = await this.client.DELETE("/code/contexts/{context_id}", { + params: { path: { context_id: contextId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Delete code context failed"); + } + + async deleteContexts(language: SupportedLanguage): Promise { + const { error, response } = await this.client.DELETE("/code/contexts", { + params: { query: { language } }, + }); + throwOnOpenApiFetchError({ error, response }, "Delete code contexts failed"); + } + + async interrupt(contextId: string): Promise { + const { error, response } = await this.client.DELETE("/code", { + params: { query: { id: contextId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Interrupt code failed"); + } + + async *runStream(req: ApiRunCodeRequest, signal?: AbortSignal): AsyncIterable { + const url = joinUrl(this.opts.baseUrl, "/code"); + const body = JSON.stringify(req); + const res = await this.fetch(url, { + method: "POST", + headers: { + "accept": "text/event-stream", + "content-type": "application/json", + ...(this.opts.headers ?? {}), + }, + body, + signal, + }); + + for await (const ev of parseJsonEventStream(res, { fallbackErrorMessage: "Run code failed" })) { + yield ev; + } + } + + async run( + code: string, + opts: { context?: CodeContext; language?: SupportedLanguage; handlers?: ExecutionHandlers; signal?: AbortSignal } = {}, + ): Promise { + if (!code.trim()) { + throw new InvalidArgumentException({ message: "Code cannot be empty" }); + } + + if (opts.context && opts.language) { + throw new InvalidArgumentException({ message: "Provide either opts.context or opts.language, not both" }); + } + + const context: CodeContext = + opts.context ?? + (opts.language + ? { language: opts.language } + : { language: "python" }); + + // Make the OpenAPI contract explicit so backend schema changes surface quickly. + const req: ApiRunCodeRequest = { + code, + context: { id: context.id, language: context.language }, + }; + + const execution: Execution = { + logs: { stdout: [], stderr: [] }, + result: [], + }; + const dispatcher = new ExecutionEventDispatcher(execution, opts.handlers); + + for await (const ev of this.runStream(req, opts.signal)) { + await dispatcher.dispatch(ev as any); + } + + return execution; + } +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/adapters/openapiError.ts b/sdks/code-interpreter/javascript/src/adapters/openapiError.ts new file mode 100644 index 00000000..128a9074 --- /dev/null +++ b/sdks/code-interpreter/javascript/src/adapters/openapiError.ts @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SandboxApiException, SandboxError } from "@alibaba-group/opensandbox"; + +export function throwOnOpenApiFetchError( + result: { error?: unknown; response: Response }, + fallbackMessage: string, +): void { + if (!result.error) return; + + const requestId = result.response.headers.get("x-request-id") ?? undefined; + const status = (result.response as any).status ?? 0; + + const err = result.error as any; + const message = + err?.message ?? + err?.error?.message ?? + fallbackMessage; + + const code = err?.code ?? err?.error?.code; + const msg = err?.message ?? err?.error?.message ?? message; + + throw new SandboxApiException({ + message: msg, + statusCode: status, + requestId, + error: code + ? new SandboxError(String(code), String(msg ?? "")) + : new SandboxError(SandboxError.UNEXPECTED_RESPONSE, String(msg ?? "")), + rawBody: result.error, + }); +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/adapters/sse.ts b/sdks/code-interpreter/javascript/src/adapters/sse.ts new file mode 100644 index 00000000..dfc8bdea --- /dev/null +++ b/sdks/code-interpreter/javascript/src/adapters/sse.ts @@ -0,0 +1,90 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SandboxApiException, SandboxError } from "@alibaba-group/opensandbox"; + +function tryParseJson(line: string): unknown | undefined { + try { + return JSON.parse(line); + } catch { + return undefined; + } +} + +/** + * Parses an SSE-like stream that may be either: + * - standard SSE frames (`data: {...}\n\n`) + * - newline-delimited JSON (one JSON object per line) + */ +export async function* parseJsonEventStream( + res: Response, + opts?: { fallbackErrorMessage?: string }, +): AsyncIterable { + if (!res.ok) { + const text = await res.text().catch(() => ""); + const parsed = tryParseJson(text); + const err = parsed && typeof parsed === "object" ? (parsed as any) : undefined; + const requestId = res.headers.get("x-request-id") ?? undefined; + const message = err?.message ?? opts?.fallbackErrorMessage ?? `Stream request failed (status=${res.status})`; + const code = err?.code ? String(err.code) : SandboxError.UNEXPECTED_RESPONSE; + throw new SandboxApiException({ + message, + statusCode: res.status, + requestId, + error: new SandboxError(code, err?.message ? String(err.message) : message), + rawBody: parsed ?? text, + }); + } + + if (!res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buf = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buf += decoder.decode(value, { stream: true }); + let idx: number; + + while ((idx = buf.indexOf("\n")) >= 0) { + const rawLine = buf.slice(0, idx); + buf = buf.slice(idx + 1); + + const line = rawLine.trim(); + if (!line) continue; + + // Support standard SSE "data:" prefix + if (line.startsWith(":")) continue; + if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) continue; + + const jsonLine = line.startsWith("data:") ? line.slice("data:".length).trim() : line; + if (!jsonLine) continue; + + const parsed = tryParseJson(jsonLine); + if (!parsed) continue; + yield parsed as T; + } + } + + // flush last line if exists + const last = buf.trim(); + if (last) { + const jsonLine = last.startsWith("data:") ? last.slice("data:".length).trim() : last; + const parsed = tryParseJson(jsonLine); + if (parsed) yield parsed as T; + } +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts b/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts new file mode 100644 index 00000000..2363916c --- /dev/null +++ b/sdks/code-interpreter/javascript/src/factory/adapterFactory.ts @@ -0,0 +1,28 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Sandbox } from "@alibaba-group/opensandbox"; +import type { Codes } from "../services/codes.js"; + +export interface CreateCodesStackOptions { + sandbox: Sandbox; + execdBaseUrl: string; +} + +/** + * Factory abstraction for Code Interpreter SDK to decouple from concrete adapters/clients. + */ +export interface AdapterFactory { + createCodes(opts: CreateCodesStackOptions): Codes; +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts b/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts new file mode 100644 index 00000000..d61ab81e --- /dev/null +++ b/sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts @@ -0,0 +1,40 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createExecdClient } from "@alibaba-group/opensandbox/internal"; + +import type { AdapterFactory, CreateCodesStackOptions } from "./adapterFactory.js"; +import { CodesAdapter } from "../adapters/codesAdapter.js"; +import type { Codes } from "../services/codes.js"; + +export class DefaultAdapterFactory implements AdapterFactory { + createCodes(opts: CreateCodesStackOptions): Codes { + const client = createExecdClient({ + baseUrl: opts.execdBaseUrl, + headers: opts.sandbox.connectionConfig.headers, + fetch: opts.sandbox.connectionConfig.fetch, + }); + + return new CodesAdapter(client, { + baseUrl: opts.execdBaseUrl, + headers: opts.sandbox.connectionConfig.headers, + // Streaming calls (SSE) use a dedicated fetch, aligned with Kotlin/Python SDKs. + fetch: opts.sandbox.connectionConfig.sseFetch, + }); + } +} + +export function createDefaultAdapterFactory(): AdapterFactory { + return new DefaultAdapterFactory(); +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/index.ts b/sdks/code-interpreter/javascript/src/index.ts new file mode 100644 index 00000000..71265408 --- /dev/null +++ b/sdks/code-interpreter/javascript/src/index.ts @@ -0,0 +1,34 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { CodeInterpreter } from "./interpreter.js"; +export type { CodeInterpreterCreateOptions } from "./interpreter.js"; + +export type { AdapterFactory } from "./factory/adapterFactory.js"; +export { DefaultAdapterFactory, createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; + +export type { CodeContext, SupportedLanguage } from "./models.js"; +export { SupportedLanguage as SupportedLanguages } from "./models.js"; + +export type { Codes } from "./services/codes.js"; + +export type { + Execution, + ExecutionComplete, + ExecutionError, + ExecutionHandlers, + ExecutionInit, + ExecutionResult, + OutputMessage, +} from "@alibaba-group/opensandbox"; \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/interpreter.ts b/sdks/code-interpreter/javascript/src/interpreter.ts new file mode 100644 index 00000000..0034568f --- /dev/null +++ b/sdks/code-interpreter/javascript/src/interpreter.ts @@ -0,0 +1,64 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DEFAULT_EXECD_PORT } from "@alibaba-group/opensandbox"; +import type { Sandbox } from "@alibaba-group/opensandbox"; + +import { createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; +import type { AdapterFactory } from "./factory/adapterFactory.js"; +import type { Codes } from "./services/codes.js"; + +export interface CodeInterpreterCreateOptions { + adapterFactory?: AdapterFactory; +} + +/** + * Code interpreter facade (JS/TS). + * + * This class wraps an existing {@link Sandbox} and provides a high-level API for code execution. + * + * - Use {@link codes} to create contexts and run code. + * - {@link files}, {@link commands}, and {@link metrics} are exposed for convenience and are + * the same instances as on the underlying {@link Sandbox}. + */ +export class CodeInterpreter { + private constructor( + readonly sandbox: Sandbox, + readonly codes: Codes, + ) {} + + static async create(sandbox: Sandbox, opts: CodeInterpreterCreateOptions = {}): Promise { + const execdBaseUrl = await sandbox.getEndpointUrl(DEFAULT_EXECD_PORT); + const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); + const codes = adapterFactory.createCodes({ sandbox, execdBaseUrl }); + + return new CodeInterpreter(sandbox, codes); + } + + get id() { + return this.sandbox.id; + } + + get files() { + return this.sandbox.files; + } + + get commands() { + return this.sandbox.commands; + } + + get metrics() { + return this.sandbox.metrics; + } +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/models.ts b/sdks/code-interpreter/javascript/src/models.ts new file mode 100644 index 00000000..e487ce0d --- /dev/null +++ b/sdks/code-interpreter/javascript/src/models.ts @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const SupportedLanguage = { + PYTHON: "python", + JAVA: "java", + GO: "go", + TYPESCRIPT: "typescript", + JAVASCRIPT: "javascript", + BASH: "bash", +} as const; + +export type SupportedLanguage = + (typeof SupportedLanguage)[keyof typeof SupportedLanguage]; + +export interface CodeContext { + id?: string; + language: SupportedLanguage | (string & {}); +} + +export interface RunCodeRequest { + code: string; + context: CodeContext; +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/src/services/codes.ts b/sdks/code-interpreter/javascript/src/services/codes.ts new file mode 100644 index 00000000..397f1ece --- /dev/null +++ b/sdks/code-interpreter/javascript/src/services/codes.ts @@ -0,0 +1,49 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ServerStreamEvent } from "@alibaba-group/opensandbox"; +import type { Execution, ExecutionHandlers } from "@alibaba-group/opensandbox"; +import type { CodeContext, RunCodeRequest, SupportedLanguage } from "../models.js"; + +export interface Codes { + createContext(language: SupportedLanguage): Promise; + /** + * Get an existing context by id. + */ + getContext(contextId: string): Promise; + /** + * List active contexts. If language is provided, filters by language/runtime. + */ + listContexts(language?: SupportedLanguage): Promise; + /** + * Delete a context by id. + */ + deleteContext(contextId: string): Promise; + /** + * Delete all contexts under the specified language/runtime. + */ + deleteContexts(language: SupportedLanguage): Promise; + + run( + code: string, + opts?: { context?: CodeContext; language?: SupportedLanguage; handlers?: ExecutionHandlers; signal?: AbortSignal }, + ): Promise; + + runStream( + req: RunCodeRequest, + signal?: AbortSignal, + ): AsyncIterable; + + interrupt(contextId: string): Promise; +} \ No newline at end of file diff --git a/sdks/code-interpreter/javascript/tsconfig.json b/sdks/code-interpreter/javascript/tsconfig.json new file mode 100644 index 00000000..ac50168f --- /dev/null +++ b/sdks/code-interpreter/javascript/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/sdks/eslint.base.mjs b/sdks/eslint.base.mjs new file mode 100644 index 00000000..85faa24d --- /dev/null +++ b/sdks/eslint.base.mjs @@ -0,0 +1,62 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export function createBaseConfig({ + tsconfigRootDir, + tsconfigPath = "./tsconfig.json", + extraIgnores = [], + includeScripts = false, + scriptGlobs = ["scripts/**/*.{js,mjs,cjs}"], +} = {}) { + const ignores = ["dist/**", "node_modules/**", "coverage/**", ...extraIgnores]; + + const configs = [ + { ignores }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.{ts,mts,cts}"], + languageOptions: { + globals: { + ...globals.nodeBuiltin, + ...globals.node, + }, + parserOptions: { + project: [tsconfigPath], + tsconfigRootDir, + }, + }, + extends: [ + ...tseslint.configs.stylisticTypeChecked, + ], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "no-console": "warn", + "no-debugger": "error", + "no-constant-condition": "warn", + }, + }, + ]; + + if (includeScripts) { + configs.push({ + files: scriptGlobs, + languageOptions: { + globals: { + ...globals.nodeBuiltin, + ...globals.node, + }, + }, + rules: { + "no-console": "off", + }, + }); + } + + return tseslint.config(...configs); +} diff --git a/sdks/package.json b/sdks/package.json new file mode 100644 index 00000000..7fc3e91b --- /dev/null +++ b/sdks/package.json @@ -0,0 +1,18 @@ +{ + "name": "opensandbox-sdks", + "private": true, + "packageManager": "pnpm@9.15.0", + "scripts": { + "build:js": "pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... --sort run build", + "lint:js": "pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... run lint", + "clean:js": "pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... --sort run clean", + "publish:js": "pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... publish --access public --no-git-checks" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "eslint": "^9.39.2", + "globals": "^17.0.0", + "typescript": "^5.7.2", + "typescript-eslint": "^8.52.0" + } +} diff --git a/sdks/pnpm-lock.yaml b/sdks/pnpm-lock.yaml new file mode 100644 index 00000000..4d99a97c --- /dev/null +++ b/sdks/pnpm-lock.yaml @@ -0,0 +1,1171 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + eslint: + specifier: ^9.39.2 + version: 9.39.2 + globals: + specifier: ^17.0.0 + version: 17.0.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.52.0 + version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) + + code-interpreter/javascript: + dependencies: + '@alibaba-group/opensandbox': + specifier: workspace:^ + version: link:../../sandbox/javascript + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + eslint: + specifier: ^9.39.2 + version: 9.39.2 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.52.0 + version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) + + sandbox/javascript: + dependencies: + openapi-fetch: + specifier: ^0.13.8 + version: 0.13.8 + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + eslint: + specifier: ^9.39.2 + version: 9.39.2 + globals: + specifier: ^17.0.0 + version: 17.0.0 + openapi-typescript: + specifier: ^7.9.1 + version: 7.10.1(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.52.0 + version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + 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.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + 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.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + 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} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + 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'} + + '@redocly/ajv@8.17.1': + resolution: {integrity: sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==} + + '@redocly/config@0.22.2': + resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} + + '@redocly/openapi-core@1.34.6': + resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@typescript-eslint/eslint-plugin@8.53.0': + resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.53.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.53.0': + resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.53.0': + resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.53.0': + resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.53.0': + resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.53.0': + resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.53.0': + resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.53.0': + resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.53.0': + resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.53.0': + resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + 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==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + 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@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + 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'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + 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==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + 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.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.0.0: + resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==} + engines: {node: '>=18'} + + 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'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + 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'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + 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-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + 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.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + openapi-fetch@0.13.8: + resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.10.1: + resolution: {integrity: sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==} + hasBin: true + peerDependencies: + typescript: ^5.x + + 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'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + 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'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + 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'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript-eslint@8.53.0: + resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + dependencies: + eslint: 9.39.2 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 3.1.2 + 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.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3(supports-color@10.2.2) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@redocly/ajv@8.17.1': + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + '@redocly/config@0.22.2': {} + + '@redocly/openapi-core@1.34.6(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.17.1 + '@redocly/config': 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.6 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.0 + eslint: 9.39.2 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 + debug: 4.4.3(supports-color@10.2.2) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.53.0': + dependencies: + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/visitor-keys': 8.53.0 + + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.2 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.53.0': {} + + '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/visitor-keys': 8.53.0 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.53.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.53.0': + dependencies: + '@typescript-eslint/types': 8.53.0 + eslint-visitor-keys: 4.2.1 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + ajv@6.12.6: + 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-colors@4.1.3: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case@5.4.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@1.4.0: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + deep-is@0.1.4: {} + + 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@9.39.2: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + 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.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.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: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.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.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.0.0: {} + + has-flag@4.0.0: {} + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + index-to-position@1.2.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + 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.merge@4.6.2: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + openapi-fetch@0.13.8: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.10.1(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.6(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + 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 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.28.6 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pluralize@8.0.0: {} + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + typescript-eslint@8.53.0(eslint@9.39.2)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/sdks/pnpm-workspace.yaml b/sdks/pnpm-workspace.yaml new file mode 100644 index 00000000..67cb939c --- /dev/null +++ b/sdks/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "sandbox/javascript" + - "code-interpreter/javascript" diff --git a/sdks/sandbox/javascript/.nvmrc b/sdks/sandbox/javascript/.nvmrc new file mode 100644 index 00000000..4ca17c4e --- /dev/null +++ b/sdks/sandbox/javascript/.nvmrc @@ -0,0 +1,3 @@ +20 + + diff --git a/sdks/sandbox/javascript/README.md b/sdks/sandbox/javascript/README.md new file mode 100644 index 00000000..69bf0c85 --- /dev/null +++ b/sdks/sandbox/javascript/README.md @@ -0,0 +1,218 @@ +# Alibaba Sandbox SDK for JavaScript/TypeScript + +English | [中文](README_zh.md) + +A TypeScript/JavaScript SDK for low-level interaction with OpenSandbox. It provides the ability to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and reading resource metrics. + +## Installation + +### npm + +```bash +npm install @alibaba-group/opensandbox +``` + +### pnpm + +```bash +pnpm add @alibaba-group/opensandbox +``` + +### yarn + +```bash +yarn add @alibaba-group/opensandbox +``` + +## Quick Start + +The following example shows how to create a sandbox and execute a shell command. + +> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions. + +```ts +import { ConnectionConfig, Sandbox, SandboxException } from "@alibaba-group/opensandbox"; + +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-api-key", + // protocol: "https", + // requestTimeoutSeconds: 60, +}); + +try { + const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "ubuntu", + timeoutSeconds: 10 * 60, + }); + + const execution = await sandbox.commands.run("echo 'Hello Sandbox!'"); + console.log(execution.logs.stdout[0]?.text); + + // Optional but recommended: terminate the remote instance when you are done. + await sandbox.kill(); +} catch (err) { + if (err instanceof SandboxException) { + console.error(`Sandbox Error: [${err.error.code}] ${err.error.message ?? ""}`); + } else { + console.error(err); + } +} +``` + +## Usage Examples + +### 1. Lifecycle Management + +Manage the sandbox lifecycle, including renewal, pausing, and resuming. + +```ts +const info = await sandbox.getInfo(); +console.log("State:", info.status.state); +console.log("Created:", info.createdAt); +console.log("Expires:", info.expiresAt); + +await sandbox.pause(); + +// Resume returns a fresh, connected Sandbox instance. +const resumed = await sandbox.resume(); + +// Renew: expiresAt = now + timeoutSeconds +await resumed.renew(30 * 60); +``` + +### 2. Custom Health Check + +Define custom logic to determine whether the sandbox is ready/healthy. This overrides the default ping check used during readiness checks. + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "nginx:latest", + healthCheck: async (sbx) => { + // Example: consider the sandbox healthy when port 80 endpoint becomes available + const ep = await sbx.getEndpoint(80); + return !!ep.endpoint; + }, +}); +``` + +### 3. Command Execution & Streaming + +Execute commands and handle output streams in real-time. + +```ts +import type { ExecutionHandlers } from "@alibaba-group/opensandbox"; + +const handlers: ExecutionHandlers = { + onStdout: (m) => console.log("STDOUT:", m.text), + onStderr: (m) => console.error("STDERR:", m.text), + onExecutionComplete: (c) => console.log("Finished in", c.executionTimeMs, "ms"), +}; + +await sandbox.commands.run( + 'for i in 1 2 3; do echo "Count $i"; sleep 0.2; done', + undefined, + handlers, +); +``` + +### 4. Comprehensive File Operations + +Manage files and directories, including read, write, list/search, and delete. + +```ts +await sandbox.files.createDirectories([{ path: "/tmp/demo", mode: 0o755 }]); + +await sandbox.files.writeFiles([ + { path: "/tmp/demo/hello.txt", data: "Hello World", mode: 0o644 }, +]); + +const content = await sandbox.files.readFile("/tmp/demo/hello.txt"); +console.log("Content:", content); + +const files = await sandbox.files.search({ path: "/tmp/demo", pattern: "*.txt" }); +console.log(files.map((f) => f.path)); + +await sandbox.files.deleteDirectories(["/tmp/demo"]); +``` + +### 5. Endpoints + +`getEndpoint()` returns an endpoint **without a scheme** (for example `"localhost:44772"`). Use `getEndpointUrl()` if you want a ready-to-use absolute URL (for example `"http://localhost:44772"`). + +```ts +const { endpoint } = await sandbox.getEndpoint(44772); +const url = await sandbox.getEndpointUrl(44772); +``` + +### 6. Sandbox Management (Admin) + +Use `SandboxManager` for administrative tasks and finding existing sandboxes. + +```ts +import { SandboxManager } from "@alibaba-group/opensandbox"; + +const manager = SandboxManager.create({ connectionConfig: config }); +const list = await manager.listSandboxInfos({ states: ["Running"], pageSize: 10 }); +console.log(list.items.map((s) => s.id)); +``` + +## Configuration + +### 1. Connection Configuration + +The `ConnectionConfig` class manages API server connection settings. + +| Parameter | Description | Default | Environment Variable | +| --- | --- | --- | --- | +| `apiKey` | API key for authentication | Optional | `OPEN_SANDBOX_API_KEY` | +| `domain` | Sandbox service domain (`host[:port]`) | `localhost:8080` | `OPEN_SANDBOX_DOMAIN` | +| `protocol` | HTTP protocol (`http`/`https`) | `http` | - | +| `requestTimeoutSeconds` | Request timeout applied to SDK HTTP calls | `30` | - | +| `debug` | Enable basic HTTP debug logging | `false` | - | +| `headers` | Extra headers applied to every request | `{}` | - | + +```ts +import { ConnectionConfig } from "@alibaba-group/opensandbox"; + +// 1. Basic configuration +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-key", + requestTimeoutSeconds: 60, +}); + +// 2. Advanced: custom headers +const config2 = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-key", + headers: { "X-Custom-Header": "value" }, +}); +``` + +### 2. Sandbox Creation Configuration + +`Sandbox.create()` allows configuring the sandbox environment. + +| Parameter | Description | Default | +| --- | --- | --- | +| `image` | Docker image to use | Required | +| `timeoutSeconds` | Automatic termination timeout (server-side TTL) | 10 minutes | +| `entrypoint` | Container entrypoint command | `["tail","-f","/dev/null"]` | +| `resource` | CPU and memory limits (string map) | `{"cpu":"1","memory":"2Gi"}` | +| `env` | Environment variables | `{}` | +| `metadata` | Custom metadata tags | `{}` | +| `extensions` | Extra server-defined fields | `{}` | +| `skipHealthCheck` | Skip readiness checks (`Running` + health check) | `false` | +| `healthCheck` | Custom readiness check | - | +| `readyTimeoutSeconds` | Max time to wait for readiness | 30 seconds | +| `healthCheckPollingInterval` | Poll interval while waiting (milliseconds) | 200 ms | + +## Browser Notes + +- The SDK can run in browsers, but **streaming file uploads are Node-only**. +- If you pass `ReadableStream` or `AsyncIterable` for `writeFiles`, the browser will fall back to **buffering in memory** before upload. +- Reason: browsers do not support streaming `multipart/form-data` bodies with custom boundaries (required by the execd upload API). + diff --git a/sdks/sandbox/javascript/README_zh.md b/sdks/sandbox/javascript/README_zh.md new file mode 100644 index 00000000..83edb550 --- /dev/null +++ b/sdks/sandbox/javascript/README_zh.md @@ -0,0 +1,218 @@ +# Alibaba Sandbox JavaScript/TypeScript SDK + +中文 | [English](README.md) + +用于与 OpenSandbox 进行底层交互的 TypeScript/JavaScript SDK。它提供了创建、管理和与安全沙箱环境交互的能力,包括执行 Shell 命令、管理文件以及读取资源指标等。 + +## 安装指南 + +### npm + +```bash +npm install @alibaba-group/opensandbox +``` + +### pnpm + +```bash +pnpm add @alibaba-group/opensandbox +``` + +### yarn + +```bash +yarn add @alibaba-group/opensandbox +``` + +## 快速开始 + +以下示例展示了如何创建一个沙箱并执行 Shell 命令。 + +> **注意**: 在运行此示例之前,请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。 + +```ts +import { ConnectionConfig, Sandbox, SandboxException } from "@alibaba-group/opensandbox"; + +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-api-key", + // protocol: "https", + // requestTimeoutSeconds: 60, +}); + +try { + const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "ubuntu", + timeoutSeconds: 10 * 60, + }); + + const execution = await sandbox.commands.run("echo 'Hello Sandbox!'"); + console.log(execution.logs.stdout[0]?.text); + + // 可选但推荐:使用完成后终止远程实例 + await sandbox.kill(); +} catch (err) { + if (err instanceof SandboxException) { + console.error(`沙箱错误: [${err.error.code}] ${err.error.message ?? ""}`); + } else { + console.error(err); + } +} +``` + +## 核心功能示例 + +### 1. 生命周期管理 + +管理沙箱的生命周期,包括续期、暂停、恢复和状态查询。 + +```ts +const info = await sandbox.getInfo(); +console.log("状态:", info.status.state); +console.log("创建时间:", info.createdAt); +console.log("过期时间:", info.expiresAt); + +await sandbox.pause(); + +// resume 会返回新的、已连接的 Sandbox 实例 +const resumed = await sandbox.resume(); + +// renew:expiresAt = now + timeoutSeconds +await resumed.renew(30 * 60); +``` + +### 2. 自定义健康检查 + +定义自定义逻辑来判断沙箱是否就绪/健康。这会覆盖“就绪检测”默认使用的 ping 检查逻辑。 + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "nginx:latest", + healthCheck: async (sbx) => { + // 示例:当 80 端口 endpoint 可获取时认为沙箱可用 + const ep = await sbx.getEndpoint(80); + return !!ep.endpoint; + }, +}); +``` + +### 3. 命令执行与流式响应 + +执行命令并实时处理输出流。 + +```ts +import type { ExecutionHandlers } from "@alibaba-group/opensandbox"; + +const handlers: ExecutionHandlers = { + onStdout: (m) => console.log("STDOUT:", m.text), + onStderr: (m) => console.error("STDERR:", m.text), + onExecutionComplete: (c) => console.log("耗时(ms):", c.executionTimeMs), +}; + +await sandbox.commands.run( + 'for i in 1 2 3; do echo "Count $i"; sleep 0.2; done', + undefined, + handlers, +); +``` + +### 4. 全面的文件操作 + +管理文件和目录,包括读写、列表/搜索与删除。 + +```ts +await sandbox.files.createDirectories([{ path: "/tmp/demo", mode: 0o755 }]); + +await sandbox.files.writeFiles([ + { path: "/tmp/demo/hello.txt", data: "Hello World", mode: 0o644 }, +]); + +const content = await sandbox.files.readFile("/tmp/demo/hello.txt"); +console.log("文件内容:", content); + +const files = await sandbox.files.search({ path: "/tmp/demo", pattern: "*.txt" }); +console.log(files.map((f) => f.path)); + +await sandbox.files.deleteDirectories(["/tmp/demo"]); +``` + +### 5. Endpoint + +`getEndpoint()` 返回 **不带 scheme** 的 endpoint(例如 `"localhost:44772"`)。如果你希望直接得到可用的绝对 URL(例如 `"http://localhost:44772"`),请使用 `getEndpointUrl()`。 + +```ts +const { endpoint } = await sandbox.getEndpoint(44772); +const url = await sandbox.getEndpointUrl(44772); +``` + +### 6. 沙箱管理(Admin) + +使用 `SandboxManager` 进行管理操作,如查询现有沙箱列表。 + +```ts +import { SandboxManager } from "@alibaba-group/opensandbox"; + +const manager = SandboxManager.create({ connectionConfig: config }); +const list = await manager.listSandboxInfos({ states: ["Running"], pageSize: 10 }); +console.log(list.items.map((s) => s.id)); +``` + +## 配置说明 + +### 1. 连接配置 (Connection Configuration) + +`ConnectionConfig` 类管理与 API 服务器的连接设置。 + +| 参数 | 描述 | 默认值 | 环境变量 | +| --- | --- | --- | --- | +| `apiKey` | 用于认证的 API Key | 可选 | `OPEN_SANDBOX_API_KEY` | +| `domain` | 沙箱服务域名(`host[:port]`) | `localhost:8080` | `OPEN_SANDBOX_DOMAIN` | +| `protocol` | HTTP 协议(`http`/`https`) | `http` | - | +| `requestTimeoutSeconds` | SDK HTTP 请求超时(秒) | `30` | - | +| `debug` | 是否开启基础 HTTP 调试日志 | `false` | - | +| `headers` | 每次请求附加的 Header | `{}` | - | + +```ts +import { ConnectionConfig } from "@alibaba-group/opensandbox"; + +// 1. 基础配置 +const config = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-key", + requestTimeoutSeconds: 60, +}); + +// 2. 进阶配置:自定义 headers +const config2 = new ConnectionConfig({ + domain: "api.opensandbox.io", + apiKey: "your-key", + headers: { "X-Custom-Header": "value" }, +}); +``` + +### 2. 沙箱创建配置 (Sandbox Creation Configuration) + +`Sandbox.create()` 用于配置沙箱环境。 + +| 参数 | 描述 | 默认值 | +| --- | --- | --- | +| `image` | 使用的 Docker 镜像 | 必填 | +| `timeoutSeconds` | 自动终止超时时间(服务端 TTL) | 10 分钟 | +| `entrypoint` | 容器启动入口命令 | `["tail","-f","/dev/null"]` | +| `resource` | CPU/内存限制(字符串 map) | `{"cpu":"1","memory":"2Gi"}` | +| `env` | 环境变量 | `{}` | +| `metadata` | 自定义元数据标签 | `{}` | +| `extensions` | 额外的服务端扩展字段 | `{}` | +| `skipHealthCheck` | 跳过就绪检测(`Running` + 健康检查) | `false` | +| `healthCheck` | 自定义就绪检查 | - | +| `readyTimeoutSeconds` | 等待就绪最大时间 | 30 秒 | +| `healthCheckPollingInterval` | 就绪轮询间隔(毫秒) | 200 ms | + +## 浏览器注意事项 + +- SDK 可在浏览器运行,但**流式文件上传仅支持 Node**。 +- 如果 `writeFiles` 传入 `ReadableStream` 或 `AsyncIterable`,浏览器会回退为**先缓存在内存,再上传**。 +- 原因:浏览器不支持以自定义 boundary 的 `multipart/form-data` 流式请求体(execd 上传接口需要此能力)。 + diff --git a/sdks/sandbox/javascript/eslint.config.mjs b/sdks/sandbox/javascript/eslint.config.mjs new file mode 100644 index 00000000..2e9ce18d --- /dev/null +++ b/sdks/sandbox/javascript/eslint.config.mjs @@ -0,0 +1,12 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createBaseConfig } from "../../eslint.base.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default createBaseConfig({ + tsconfigRootDir: __dirname, + tsconfigPath: "./tsconfig.json", + extraIgnores: ["src/api/**", "src/**/*.d.ts", "src/**/*.js"], + includeScripts: true, +}); \ No newline at end of file diff --git a/sdks/sandbox/javascript/package.json b/sdks/sandbox/javascript/package.json new file mode 100644 index 00000000..d17e6cad --- /dev/null +++ b/sdks/sandbox/javascript/package.json @@ -0,0 +1,53 @@ +{ + "name": "@alibaba-group/opensandbox", + "version": "0.1.0-dev2", + "description": "OpenSandbox TypeScript/JavaScript SDK (sandbox lifecycle + execd APIs)", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./internal": { + "types": "./dist/internal.d.ts", + "default": "./dist/internal.js" + } + }, + "browser": "./dist/index.js", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/alibaba/OpenSandbox.git" + }, + "bugs": { + "url": "https://github.com/alibaba/OpenSandbox/issues" + }, + "homepage": "https://github.com/alibaba/OpenSandbox", + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@9.15.0", + "scripts": { + "gen:api": "node ./scripts/generate-api.mjs", + "build": "pnpm run gen:api && tsc -p tsconfig.json", + "lint": "eslint src scripts --max-warnings 0", + "clean": "rm -rf dist" + }, + "dependencies": { + "openapi-fetch": "^0.13.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "eslint": "^9.39.2", + "globals": "^17.0.0", + "openapi-typescript": "^7.9.1", + "typescript": "^5.7.2", + "typescript-eslint": "^8.52.0" + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/scripts/generate-api.mjs b/sdks/sandbox/javascript/scripts/generate-api.mjs new file mode 100644 index 00000000..1e6886d1 --- /dev/null +++ b/sdks/sandbox/javascript/scripts/generate-api.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const LICENSE_OWNER = "Alibaba Group Holding Ltd."; +const LICENSE_MARKER_REGEX = new RegExp(`Copyright [0-9]{4} ${LICENSE_OWNER}`); + +function buildLicenseText() { + const year = new Date().getFullYear(); + return `Copyright ${year} ${LICENSE_OWNER}. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.`; +} + +function asLineCommentHeader(text) { + return text + .split("\n") + .map((line) => `// ${line}`) + .join("\n"); +} + +function ensureLicenseHeader(filePath) { + const body = readFileSync(filePath, "utf8"); + const head = body.split("\n").slice(0, 40).join("\n"); + if (LICENSE_MARKER_REGEX.test(head)) { + return; + } + const header = asLineCommentHeader(buildLicenseText()); + writeFileSync(filePath, `${header}\n\n${body}`, "utf8"); +} + +function fail(message) { + console.error(`❌ ${message}`); + process.exit(1); +} + +function run(cmd, args, cwd) { + const pretty = [cmd, ...args].join(" "); + console.log(`\n▶ ${pretty}`); + const res = spawnSync(cmd, args, { cwd, stdio: "inherit" }); + if (res.status !== 0) { + fail(`Command failed (exit=${res.status}): ${pretty}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// scripts/ -> package root +const packageRoot = path.resolve(__dirname, ".."); +// scripts/ -> repo root (OpenSandbox/) +const repoRoot = path.resolve(__dirname, "../../../../"); + +const specs = { + execd: path.join(repoRoot, "specs", "execd-api.yaml"), + lifecycle: path.join(repoRoot, "specs", "sandbox-lifecycle.yml"), +}; + +for (const [name, p] of Object.entries(specs)) { + if (!existsSync(p)) { + fail(`OpenAPI spec not found for '${name}': ${p}`); + } +} + +const outDir = path.join(packageRoot, "src", "api"); +mkdirSync(outDir, { recursive: true }); + +const outFiles = { + execd: path.join(outDir, "execd.ts"), + lifecycle: path.join(outDir, "lifecycle.ts"), +}; + +console.log("🚀 OpenSandbox TypeScript SDK API Generator"); +console.log(`- repoRoot: ${repoRoot}`); +console.log(`- outDir: ${outDir}`); + +// Use pnpm as requested by the project rules. +run("pnpm", ["exec", "openapi-typescript", specs.execd, "-o", outFiles.execd], packageRoot); +run( + "pnpm", + ["exec", "openapi-typescript", specs.lifecycle, "-o", outFiles.lifecycle], + packageRoot, +); + +// The generator may overwrite outputs; re-apply unified license headers after generation. +ensureLicenseHeader(outFiles.execd); +ensureLicenseHeader(outFiles.lifecycle); + +console.log("\n✅ API type generation completed:"); +console.log(`- ${path.relative(packageRoot, outFiles.execd)}`); +console.log(`- ${path.relative(packageRoot, outFiles.lifecycle)}`); + + diff --git a/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts new file mode 100644 index 00000000..c2205808 --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/commandsAdapter.ts @@ -0,0 +1,112 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecdClient } from "../openapi/execdClient.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import { parseJsonEventStream } from "./sse.js"; +import type { paths as ExecdPaths } from "../api/execd.js"; +import type { CommandExecution, RunCommandOpts, ServerStreamEvent } from "../models/execd.js"; +import type { ExecdCommands } from "../services/execdCommands.js"; +import type { ExecutionHandlers } from "../models/execution.js"; +import { ExecutionEventDispatcher } from "../models/executionEventDispatcher.js"; + +function joinUrl(baseUrl: string, pathname: string): string { + const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const path = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${base}${path}`; +} + +type ApiRunCommandRequest = + ExecdPaths["/command"]["post"]["requestBody"]["content"]["application/json"]; + +function toRunCommandRequest(command: string, opts?: RunCommandOpts): ApiRunCommandRequest { + return { + command, + cwd: opts?.workingDirectory, + background: !!opts?.background, + }; +} + +export interface CommandsAdapterOptions { + /** + * Must match the baseUrl used by the ExecdClient. + */ + baseUrl: string; + fetch?: typeof fetch; + headers?: Record; +} + +export class CommandsAdapter implements ExecdCommands { + private readonly fetch: typeof fetch; + + constructor( + private readonly client: ExecdClient, + private readonly opts: CommandsAdapterOptions, + ) { + this.fetch = opts.fetch ?? fetch; + } + + async interrupt(sessionId: string): Promise { + const { error, response } = await this.client.DELETE("/command", { + params: { query: { id: sessionId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Interrupt command failed"); + } + + async *runStream( + command: string, + opts?: RunCommandOpts, + signal?: AbortSignal, + ): AsyncIterable { + const url = joinUrl(this.opts.baseUrl, "/command"); + const body = JSON.stringify(toRunCommandRequest(command, opts)); + + const res = await this.fetch(url, { + method: "POST", + headers: { + "accept": "text/event-stream", + "content-type": "application/json", + ...(this.opts.headers ?? {}), + }, + body, + signal, + }); + + for await (const ev of parseJsonEventStream(res, { fallbackErrorMessage: "Run command failed" })) { + yield ev; + } + } + + async run( + command: string, + opts?: RunCommandOpts, + handlers?: ExecutionHandlers, + signal?: AbortSignal, + ): Promise { + const execution: CommandExecution = { + logs: { stdout: [], stderr: [] }, + result: [], + }; + const dispatcher = new ExecutionEventDispatcher(execution, handlers); + for await (const ev of this.runStream(command, opts, signal)) { + // Keep legacy behavior: if server sends "init" with empty id, preserve previous id. + if (ev.type === "init" && (ev.text ?? "") === "" && execution.id) { + (ev as any).text = execution.id; + } + await dispatcher.dispatch(ev as any); + } + + return execution; + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts b/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts new file mode 100644 index 00000000..66f7880b --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts @@ -0,0 +1,575 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecdClient } from "../openapi/execdClient.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import type { SandboxFiles } from "../services/filesystem.js"; +import type { paths as ExecdPaths } from "../api/execd.js"; +import type { + ContentReplaceEntry, + FileInfo, + FileMetadata, + FilesInfoResponse, + MoveEntry, + Permission, + RenameFileItem, + ReplaceFileContentItem, + SearchEntry, + SearchFilesResponse, + SetPermissionEntry, + WriteEntry, +} from "../models/filesystem.js"; +import { SandboxApiException, SandboxError } from "../core/exceptions.js"; + +function joinUrl(baseUrl: string, pathname: string): string { + const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const path = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${base}${path}`; +} + +function toUploadBlob(data: Blob | Uint8Array | ArrayBuffer | string): Blob { + if (typeof data === "string") return new Blob([data]); + if (data instanceof Blob) return data; + if (data instanceof ArrayBuffer) return new Blob([data]); + // Copy into a new Uint8Array backed by ArrayBuffer (not SharedArrayBuffer) + const copied = Uint8Array.from(data); + return new Blob([copied.buffer]); +} + +function isReadableStream(v: unknown): v is ReadableStream { + return !!v && typeof (v as any).getReader === "function"; +} + +function isAsyncIterable(v: unknown): v is AsyncIterable { + return !!v && typeof (v as any)[Symbol.asyncIterator] === "function"; +} + +function isNodeRuntime(): boolean { + const p = (globalThis as any)?.process; + return !!(p?.versions?.node); +} + +async function collectBytes( + source: ReadableStream | AsyncIterable +): Promise { + const chunks: Uint8Array[] = []; + let total = 0; + + if (isReadableStream(source)) { + const reader = source.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + total += value.length; + } + } + } finally { + reader.releaseLock(); + } + } else { + for await (const chunk of source) { + chunks.push(chunk); + total += chunk.length; + } + } + + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function toReadableStream( + it: AsyncIterable +): ReadableStream { + const RS: any = ReadableStream as any; + if (typeof RS?.from === "function") return RS.from(it); + const iterator = it[Symbol.asyncIterator](); + return new ReadableStream({ + async pull(controller) { + const r = await iterator.next(); + if (r.done) { + controller.close(); + return; + } + controller.enqueue(r.value); + }, + async cancel() { + await iterator.return?.(); + }, + }); +} + +function basename(p: string): string { + const parts = p.split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : "file"; +} + +function encodeUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +async function* multipartUploadBody(opts: { + boundary: string; + metadataJson: string; + fileName: string; + fileContentType: string; + file: ReadableStream | AsyncIterable; +}): AsyncIterable { + const b = opts.boundary; + + // Part 1: metadata (application/json) + yield encodeUtf8(`--${b}\r\n`); + yield encodeUtf8( + `Content-Disposition: form-data; name="metadata"; filename="metadata"\r\n` + ); + yield encodeUtf8(`Content-Type: application/json\r\n\r\n`); + yield encodeUtf8(opts.metadataJson); + yield encodeUtf8(`\r\n`); + + // Part 2: file + yield encodeUtf8(`--${b}\r\n`); + yield encodeUtf8( + `Content-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\n` + ); + yield encodeUtf8(`Content-Type: ${opts.fileContentType}\r\n\r\n`); + + if (isReadableStream(opts.file)) { + const reader = opts.file.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) yield value; + } + } finally { + reader.releaseLock(); + } + } else { + for await (const chunk of opts.file) { + yield chunk; + } + } + + yield encodeUtf8(`\r\n--${b}--\r\n`); +} + +export interface FilesystemAdapterOptions { + /** + * Must match the baseUrl used by the ExecdClient, used for binary endpoints + * like download/upload where we bypass JSON parsing. + */ + baseUrl: string; + fetch?: typeof fetch; + headers?: Record; +} + +function toPermission(e: { + mode?: number; + owner?: string; + group?: string; +}): Permission { + return { + mode: e.mode ?? 755, + owner: e.owner, + group: e.group, + } as Permission; +} + +/** + * Filesystem adapter that exposes user-facing file APIs (`sandbox.files`). + * + * This adapter owns all request/response conversions: + * - Maps friendly method shapes to API payloads + * - Parses timestamps into `Date` + * - Implements streaming upload/download helpers + */ +export class FilesystemAdapter implements SandboxFiles { + private readonly fetch: typeof fetch; + + private static readonly Api = { + // This is intentionally derived from OpenAPI schema types so API changes surface quickly. + SearchFilesOk: + null as unknown as ExecdPaths["/files/search"]["get"]["responses"][200]["content"]["application/json"], + FilesInfoOk: + null as unknown as ExecdPaths["/files/info"]["get"]["responses"][200]["content"]["application/json"], + MakeDirsRequest: + null as unknown as ExecdPaths["/directories"]["post"]["requestBody"]["content"]["application/json"], + SetPermissionsRequest: + null as unknown as ExecdPaths["/files/permissions"]["post"]["requestBody"]["content"]["application/json"], + MoveFilesRequest: + null as unknown as ExecdPaths["/files/mv"]["post"]["requestBody"]["content"]["application/json"], + ReplaceContentsRequest: + null as unknown as ExecdPaths["/files/replace"]["post"]["requestBody"]["content"]["application/json"], + }; + + constructor( + private readonly client: ExecdClient, + private readonly opts: FilesystemAdapterOptions + ) { + this.fetch = opts.fetch ?? fetch; + } + + private parseIsoDate(field: string, v: unknown): Date { + if (typeof v !== "string" || !v) { + throw new Error(`Invalid ${field}: expected ISO string, got ${typeof v}`); + } + const d = new Date(v); + if (Number.isNaN(d.getTime())) { + throw new Error(`Invalid ${field}: ${v}`); + } + return d; + } + + private static readonly _ApiFileInfo = + null as unknown as (typeof FilesystemAdapter.Api.SearchFilesOk)[number]; + + private mapApiFileInfo(raw: typeof FilesystemAdapter._ApiFileInfo): FileInfo { + const { path, size, created_at, modified_at, mode, owner, group, ...rest } = + raw; + + return { + ...rest, + path, + size, + mode, + owner, + group, + createdAt: created_at + ? this.parseIsoDate("createdAt", created_at) + : undefined, + modifiedAt: modified_at + ? this.parseIsoDate("modifiedAt", modified_at) + : undefined, + }; + } + + async getFileInfo(paths: string[]): Promise> { + const { data, error, response } = await this.client.GET("/files/info", { + params: { query: { path: paths } }, + }); + throwOnOpenApiFetchError({ error, response }, "Get file info failed"); + const raw = data as typeof FilesystemAdapter.Api.FilesInfoOk | undefined; + if (!raw) return {} as FilesInfoResponse; + if (typeof raw !== "object") { + throw new Error( + `Get file info failed: unexpected response shape (got ${typeof raw})` + ); + } + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (!v || typeof v !== "object") { + throw new Error( + `Get file info failed: invalid file info for path=${k}` + ); + } + out[k] = this.mapApiFileInfo(v as typeof FilesystemAdapter._ApiFileInfo); + } + return out as FilesInfoResponse; + } + + async deleteFiles(paths: string[]): Promise { + const { error, response } = await this.client.DELETE("/files", { + params: { query: { path: paths } }, + }); + throwOnOpenApiFetchError({ error, response }, "Delete files failed"); + } + + async createDirectories( + entries: Pick[] + ): Promise { + const map: Record = {}; + for (const e of entries) { + map[e.path] = toPermission(e); + } + const body = map as unknown as typeof FilesystemAdapter.Api.MakeDirsRequest; + const { error, response } = await this.client.POST("/directories", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Create directories failed"); + } + + async deleteDirectories(paths: string[]): Promise { + const { error, response } = await this.client.DELETE("/directories", { + params: { query: { path: paths } }, + }); + throwOnOpenApiFetchError({ error, response }, "Delete directories failed"); + } + + async setPermissions(entries: SetPermissionEntry[]): Promise { + const req: Record = {}; + for (const e of entries) { + req[e.path] = toPermission(e); + } + const body = + req as unknown as typeof FilesystemAdapter.Api.SetPermissionsRequest; + const { error, response } = await this.client.POST("/files/permissions", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Set permissions failed"); + } + + async moveFiles(entries: MoveEntry[]): Promise { + const req: RenameFileItem[] = entries.map((e) => ({ + src: e.src, + dest: e.dest, + })); + const body = + req as unknown as typeof FilesystemAdapter.Api.MoveFilesRequest; + const { error, response } = await this.client.POST("/files/mv", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Move files failed"); + } + + async replaceContents(entries: ContentReplaceEntry[]): Promise { + const req: Record = {}; + for (const e of entries) { + req[e.path] = { old: e.oldContent, new: e.newContent }; + } + const body = + req as unknown as typeof FilesystemAdapter.Api.ReplaceContentsRequest; + const { error, response } = await this.client.POST("/files/replace", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Replace contents failed"); + } + + async search(entry: SearchEntry): Promise { + const { data, error, response } = await this.client.GET("/files/search", { + params: { query: { path: entry.path, pattern: entry.pattern } }, + }); + throwOnOpenApiFetchError({ error, response }, "Search files failed"); + + // Make the OpenAPI contract explicit (and fail loudly on unexpected shapes). + const ok = data as typeof FilesystemAdapter.Api.SearchFilesOk | undefined; + if (!ok) return []; + if (!Array.isArray(ok)) { + throw new Error( + `Search files failed: unexpected response shape (expected array, got ${typeof ok})` + ); + } + return ok.map((x) => this.mapApiFileInfo(x)); + } + + private async uploadFile( + meta: FileMetadata, + data: + | Blob + | Uint8Array + | ArrayBuffer + | string + | AsyncIterable + | ReadableStream + ): Promise { + const url = joinUrl(this.opts.baseUrl, "/files/upload"); + const fileName = basename(meta.path); + const metadataJson = JSON.stringify(meta); + + // Streaming path (large files): build multipart body manually to avoid buffering. + if (isReadableStream(data) || isAsyncIterable(data)) { + // Browsers do not allow streaming multipart requests with custom boundaries. + // Fall back to in-memory uploads when streaming is unavailable. + if (!isNodeRuntime()) { + const bytes = await collectBytes(data); + return await this.uploadFile(meta, bytes); + } + const boundary = `opensandbox_${Math.random() + .toString(16) + .slice(2)}_${Date.now()}`; + const bodyIt = multipartUploadBody({ + boundary, + metadataJson, + fileName, + fileContentType: "application/octet-stream", + file: data, + }); + const stream = toReadableStream(bodyIt); + + const res = await this.fetch(url, { + method: "POST", + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + ...(this.opts.headers ?? {}), + }, + body: stream as any, + // Node fetch (undici) requires duplex for streaming request bodies. + duplex: "half" as any, + } as any); + + if (!res.ok) { + const requestId = res.headers.get("x-request-id") ?? undefined; + const rawBody = await res.text().catch(() => undefined); + throw new SandboxApiException({ + message: `Upload failed (status=${res.status})`, + statusCode: res.status, + requestId, + error: new SandboxError( + SandboxError.UNEXPECTED_RESPONSE, + "Upload failed" + ), + rawBody, + }); + } + return; + } + + // In-memory path (small files): use FormData. + const form = new FormData(); + form.append( + "metadata", + new Blob([metadataJson], { type: "application/json" }), + "metadata" + ); + + if (typeof data === "string") { + const textBlob = new Blob([data], { type: "text/plain; charset=utf-8" }); + form.append("file", textBlob, fileName); + } else { + const blob = toUploadBlob(data); + const fileBlob = blob.type + ? blob + : new Blob([blob], { type: "application/octet-stream" }); + form.append("file", fileBlob, fileName); + } + + const res = await this.fetch(url, { + method: "POST", + headers: { + ...(this.opts.headers ?? {}), + }, + body: form, + }); + + if (!res.ok) { + const requestId = res.headers.get("x-request-id") ?? undefined; + const rawBody = await res.text().catch(() => undefined); + throw new SandboxApiException({ + message: `Upload failed (status=${res.status})`, + statusCode: res.status, + requestId, + error: new SandboxError( + SandboxError.UNEXPECTED_RESPONSE, + "Upload failed" + ), + rawBody, + }); + } + } + + async readBytes( + path: string, + opts?: { range?: string } + ): Promise { + const url = + joinUrl(this.opts.baseUrl, "/files/download") + + `?path=${encodeURIComponent(path)}`; + const res = await this.fetch(url, { + method: "GET", + headers: { + ...(this.opts.headers ?? {}), + ...(opts?.range ? { Range: opts.range } : {}), + }, + }); + if (!res.ok) { + const requestId = res.headers.get("x-request-id") ?? undefined; + const rawBody = await res.text().catch(() => undefined); + throw new SandboxApiException({ + message: "Download failed", + statusCode: res.status, + requestId, + error: new SandboxError( + SandboxError.UNEXPECTED_RESPONSE, + "Download failed" + ), + rawBody, + }); + } + const ab = await res.arrayBuffer(); + return new Uint8Array(ab); + } + + readBytesStream( + path: string, + opts?: { range?: string } + ): AsyncIterable { + return this.downloadStream(path, opts); + } + + private async *downloadStream( + path: string, + opts?: { range?: string } + ): AsyncIterable { + const url = + joinUrl(this.opts.baseUrl, "/files/download") + + `?path=${encodeURIComponent(path)}`; + const res = await this.fetch(url, { + method: "GET", + headers: { + ...(this.opts.headers ?? {}), + ...(opts?.range ? { Range: opts.range } : {}), + }, + }); + if (!res.ok) { + const requestId = res.headers.get("x-request-id") ?? undefined; + const rawBody = await res.text().catch(() => undefined); + throw new SandboxApiException({ + message: "Download stream failed", + statusCode: res.status, + requestId, + error: new SandboxError( + SandboxError.UNEXPECTED_RESPONSE, + "Download stream failed" + ), + rawBody, + }); + } + + const body = res.body as ReadableStream | null; + if (!body) return; + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) yield value; + } + } + + async readFile( + path: string, + opts?: { encoding?: string; range?: string } + ): Promise { + const bytes = await this.readBytes(path, { range: opts?.range }); + const encoding = opts?.encoding ?? "utf-8"; + return new TextDecoder(encoding).decode(bytes); + } + + async writeFiles(entries: WriteEntry[]): Promise { + for (const e of entries) { + const meta: FileMetadata = { + path: e.path, + owner: e.owner, + group: e.group, + mode: e.mode, + }; + await this.uploadFile(meta, e.data ?? ""); + } + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/healthAdapter.ts b/sdks/sandbox/javascript/src/adapters/healthAdapter.ts new file mode 100644 index 00000000..a2ffce22 --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/healthAdapter.ts @@ -0,0 +1,27 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecdClient } from "../openapi/execdClient.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import type { ExecdHealth } from "../services/execdHealth.js"; + +export class HealthAdapter implements ExecdHealth { + constructor(private readonly client: ExecdClient) {} + + async ping(): Promise { + const { error, response } = await this.client.GET("/ping"); + throwOnOpenApiFetchError({ error, response }, "Execd ping failed"); + return true; + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/metricsAdapter.ts b/sdks/sandbox/javascript/src/adapters/metricsAdapter.ts new file mode 100644 index 00000000..7ec90e8c --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/metricsAdapter.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecdClient } from "../openapi/execdClient.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import type { paths as ExecdPaths } from "../api/execd.js"; +import type { SandboxMetrics } from "../models/execd.js"; +import type { ExecdMetrics } from "../services/execdMetrics.js"; + +type ApiMetricsOk = + ExecdPaths["/metrics"]["get"]["responses"][200]["content"]["application/json"]; + +function normalizeMetrics(m: ApiMetricsOk): SandboxMetrics { + const cpuCount = m.cpu_count ?? 0; + const cpuUsedPercentage = m.cpu_used_pct ?? 0; + const memoryTotalMiB = m.mem_total_mib ?? 0; + const memoryUsedMiB = m.mem_used_mib ?? 0; + const timestamp = m.timestamp ?? 0; + return { + cpuCount: Number(cpuCount), + cpuUsedPercentage: Number(cpuUsedPercentage), + memoryTotalMiB: Number(memoryTotalMiB), + memoryUsedMiB: Number(memoryUsedMiB), + timestamp: Number(timestamp), + }; +} + +export class MetricsAdapter implements ExecdMetrics { + constructor(private readonly client: ExecdClient) {} + + async getMetrics(): Promise { + const { data, error, response } = await this.client.GET("/metrics"); + throwOnOpenApiFetchError({ error, response }, "Get execd metrics failed"); + const ok = data as ApiMetricsOk | undefined; + if (!ok || typeof ok !== "object") { + throw new Error("Get execd metrics failed: unexpected response shape"); + } + return normalizeMetrics(ok); + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/openapiError.ts b/sdks/sandbox/javascript/src/adapters/openapiError.ts new file mode 100644 index 00000000..c476f42b --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/openapiError.ts @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SandboxApiException, SandboxError } from "../core/exceptions.js"; + +export function throwOnOpenApiFetchError( + result: { error?: unknown; response: Response }, + fallbackMessage: string, +): void { + if (!result.error) return; + + const requestId = result.response.headers.get("x-request-id") ?? undefined; + const status = (result.response as any).status ?? 0; + + const err = result.error as any; + const message = + err?.message ?? + err?.error?.message ?? + fallbackMessage; + + const code = err?.code ?? err?.error?.code; + const msg = err?.message ?? err?.error?.message ?? message; + + throw new SandboxApiException({ + message: msg, + statusCode: status, + requestId, + error: code ? new SandboxError(String(code), String(msg ?? "")) : new SandboxError(SandboxError.UNEXPECTED_RESPONSE, String(msg ?? "")), + rawBody: result.error, + }); +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts b/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts new file mode 100644 index 00000000..7ca658ae --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts @@ -0,0 +1,187 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { LifecycleClient } from "../openapi/lifecycleClient.js"; +import { throwOnOpenApiFetchError } from "./openapiError.js"; +import type { paths as LifecyclePaths } from "../api/lifecycle.js"; +import type { + Sandboxes, +} from "../services/sandboxes.js"; +import type { + CreateSandboxRequest, + CreateSandboxResponse, + Endpoint, + ListSandboxesParams, + ListSandboxesResponse, + RenewSandboxExpirationRequest, + RenewSandboxExpirationResponse, + SandboxId, + SandboxInfo, +} from "../models/sandboxes.js"; + +type ApiCreateSandboxRequest = + LifecyclePaths["/sandboxes"]["post"]["requestBody"]["content"]["application/json"]; +type ApiCreateSandboxOk = + LifecyclePaths["/sandboxes"]["post"]["responses"][202]["content"]["application/json"]; +type ApiGetSandboxOk = + LifecyclePaths["/sandboxes/{sandboxId}"]["get"]["responses"][200]["content"]["application/json"]; +type ApiListSandboxesOk = + LifecyclePaths["/sandboxes"]["get"]["responses"][200]["content"]["application/json"]; +type ApiRenewSandboxExpirationRequest = + LifecyclePaths["/sandboxes/{sandboxId}/renew-expiration"]["post"]["requestBody"]["content"]["application/json"]; +type ApiRenewSandboxExpirationOk = + LifecyclePaths["/sandboxes/{sandboxId}/renew-expiration"]["post"]["responses"][200]["content"]["application/json"]; +type ApiEndpointOk = + LifecyclePaths["/sandboxes/{sandboxId}/endpoints/{port}"]["get"]["responses"][200]["content"]["application/json"]; + +function encodeMetadataFilter(metadata: Record): string { + // The Lifecycle API expects a single `metadata` query parameter whose value is `k=v&k2=v2`. + // The query serializer will URL-encode the value (e.g. `=` -> %3D and `&` -> %26). + const parts: string[] = []; + for (const [k, v] of Object.entries(metadata)) { + parts.push(`${k}=${v}`); + } + return parts.join("&"); +} + +export class SandboxesAdapter implements Sandboxes { + constructor(private readonly client: LifecycleClient) {} + + private parseIsoDate(field: string, v: unknown): Date { + if (typeof v !== "string" || !v) { + throw new Error(`Invalid ${field}: expected ISO string, got ${typeof v}`); + } + const d = new Date(v); + if (Number.isNaN(d.getTime())) { + throw new Error(`Invalid ${field}: ${v}`); + } + return d; + } + + private mapSandboxInfo(raw: ApiGetSandboxOk): SandboxInfo { + return { + ...(raw ?? {}), + createdAt: this.parseIsoDate("createdAt", raw?.createdAt), + expiresAt: this.parseIsoDate("expiresAt", raw?.expiresAt), + } as SandboxInfo; + } + + async createSandbox(req: CreateSandboxRequest): Promise { + // Make the OpenAPI contract explicit so backend schema changes surface quickly. + const body: ApiCreateSandboxRequest = req as unknown as ApiCreateSandboxRequest; + const { data, error, response } = await this.client.POST("/sandboxes", { + body, + }); + throwOnOpenApiFetchError({ error, response }, "Create sandbox failed"); + const raw = data as ApiCreateSandboxOk | undefined; + if (!raw || typeof raw !== "object") { + throw new Error("Create sandbox failed: unexpected response shape"); + } + return { + ...(raw ?? {}), + createdAt: this.parseIsoDate("createdAt", raw?.createdAt), + expiresAt: this.parseIsoDate("expiresAt", raw?.expiresAt), + } as CreateSandboxResponse; + } + + async getSandbox(sandboxId: SandboxId): Promise { + const { data, error, response } = await this.client.GET("/sandboxes/{sandboxId}", { + params: { path: { sandboxId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Get sandbox failed"); + const ok = data as ApiGetSandboxOk | undefined; + if (!ok || typeof ok !== "object") { + throw new Error("Get sandbox failed: unexpected response shape"); + } + return this.mapSandboxInfo(ok); + } + + async listSandboxes(params: ListSandboxesParams = {}): Promise { + const query: Record = {}; + if (params.states?.length) query.state = params.states; + if (params.metadata && Object.keys(params.metadata).length) { + query.metadata = encodeMetadataFilter(params.metadata); + } + if (params.page != null) query.page = params.page; + if (params.pageSize != null) query.pageSize = params.pageSize; + + const { data, error, response } = await this.client.GET("/sandboxes", { + params: { query }, + }); + throwOnOpenApiFetchError({ error, response }, "List sandboxes failed"); + const raw = data as ApiListSandboxesOk | undefined; + if (!raw || typeof raw !== "object") { + throw new Error("List sandboxes failed: unexpected response shape"); + } + const itemsRaw = raw.items; + if (!Array.isArray(itemsRaw)) throw new Error("List sandboxes failed: unexpected items shape"); + return { + ...(raw ?? {}), + items: itemsRaw.map((x) => this.mapSandboxInfo(x)), + } as ListSandboxesResponse; + } + + async deleteSandbox(sandboxId: SandboxId): Promise { + const { error, response } = await this.client.DELETE("/sandboxes/{sandboxId}", { + params: { path: { sandboxId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Delete sandbox failed"); + } + + async pauseSandbox(sandboxId: SandboxId): Promise { + const { error, response } = await this.client.POST("/sandboxes/{sandboxId}/pause", { + params: { path: { sandboxId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Pause sandbox failed"); + } + + async resumeSandbox(sandboxId: SandboxId): Promise { + const { error, response } = await this.client.POST("/sandboxes/{sandboxId}/resume", { + params: { path: { sandboxId } }, + }); + throwOnOpenApiFetchError({ error, response }, "Resume sandbox failed"); + } + + async renewSandboxExpiration( + sandboxId: SandboxId, + req: RenewSandboxExpirationRequest, + ): Promise { + const body: ApiRenewSandboxExpirationRequest = req as unknown as ApiRenewSandboxExpirationRequest; + const { data, error, response } = await this.client.POST("/sandboxes/{sandboxId}/renew-expiration", { + params: { path: { sandboxId } }, + body, + }); + throwOnOpenApiFetchError({ error, response }, "Renew sandbox expiration failed"); + const raw = data as ApiRenewSandboxExpirationOk | undefined; + if (!raw || typeof raw !== "object") { + throw new Error("Renew sandbox expiration failed: unexpected response shape"); + } + return { + ...(raw ?? {}), + expiresAt: raw?.expiresAt ? this.parseIsoDate("expiresAt", raw.expiresAt) : undefined, + } as RenewSandboxExpirationResponse; + } + + async getSandboxEndpoint(sandboxId: SandboxId, port: number): Promise { + const { data, error, response } = await this.client.GET("/sandboxes/{sandboxId}/endpoints/{port}", { + params: { path: { sandboxId, port } }, + }); + throwOnOpenApiFetchError({ error, response }, "Get sandbox endpoint failed"); + const ok = data as ApiEndpointOk | undefined; + if (!ok || typeof ok !== "object") { + throw new Error("Get sandbox endpoint failed: unexpected response shape"); + } + return ok as unknown as Endpoint; + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/adapters/sse.ts b/sdks/sandbox/javascript/src/adapters/sse.ts new file mode 100644 index 00000000..a4fb380d --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/sse.ts @@ -0,0 +1,95 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SandboxApiException, SandboxError } from "../core/exceptions.js"; + +function tryParseJson(line: string): unknown | undefined { + try { + return JSON.parse(line); + } catch { + return undefined; + } +} + +/** + * Parses an SSE-like stream that may be either: + * - standard SSE frames (`data: {...}\n\n`) + * - newline-delimited JSON (one JSON object per line) + */ +export async function* parseJsonEventStream( + res: Response, + opts?: { fallbackErrorMessage?: string }, +): AsyncIterable { + if (!res.ok) { + const text = await res.text().catch(() => ""); + const parsed = tryParseJson(text); + const err = parsed && typeof parsed === "object" ? (parsed as any) : undefined; + const requestId = res.headers.get("x-request-id") ?? undefined; + const message = err?.message ?? opts?.fallbackErrorMessage ?? `Stream request failed (status=${res.status})`; + const code = err?.code ? String(err.code) : SandboxError.UNEXPECTED_RESPONSE; + throw new SandboxApiException({ + message, + statusCode: res.status, + requestId, + error: new SandboxError(code, err?.message ? String(err.message) : message), + rawBody: parsed ?? text, + }); + } + + if (!res.body) { + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buf = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buf += decoder.decode(value, { stream: true }); + let idx: number; + + while ((idx = buf.indexOf("\n")) >= 0) { + const rawLine = buf.slice(0, idx); + buf = buf.slice(idx + 1); + + const line = rawLine.trim(); + if (!line) continue; + + // Support standard SSE "data:" prefix + if (line.startsWith(":")) continue; + if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) continue; + + const jsonLine = line.startsWith("data:") ? line.slice("data:".length).trim() : line; + if (!jsonLine) continue; + + const parsed = tryParseJson(jsonLine); + if (!parsed) continue; + yield parsed as T; + } + } + + // Flush any buffered UTF-8 bytes from the decoder. + buf += decoder.decode(); + + // flush last line if exists + const last = buf.trim(); + if (last) { + const jsonLine = last.startsWith("data:") ? last.slice("data:".length).trim() : last; + const parsed = tryParseJson(jsonLine); + if (parsed) yield parsed as T; + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/api/execd.ts b/sdks/sandbox/javascript/src/api/execd.ts new file mode 100644 index 00000000..f2ab1ca8 --- /dev/null +++ b/sdks/sandbox/javascript/src/api/execd.ts @@ -0,0 +1,1569 @@ +// Copyright 2026 Alibaba Group Holding Ltd.. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/ping": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health check endpoint + * @description Performs a simple health check to verify that the server is running and responsive. + * Returns HTTP 200 OK status if the server is healthy. This endpoint is typically used + * by load balancers, monitoring systems, and orchestration platforms (like Kubernetes) + * to check service availability. + */ + get: operations["ping"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/code/contexts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List active code execution contexts + * @description Lists all active/available code execution contexts. + * If `language` is provided, only contexts under that language/runtime are returned. + */ + get: operations["listContexts"]; + put?: never; + post?: never; + /** + * Delete all contexts under a language + * @description Deletes all existing code execution contexts under the specified `language`/runtime. + * This is a bulk operation intended for code-interpreter context cleanup. + */ + delete: operations["deleteContextsByLanguage"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/code/contexts/{context_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a code execution context by id + * @description Retrieves the details of an existing code execution context (session) by id. + * Returns the context ID, language, and any associated metadata. + */ + get: operations["getContext"]; + put?: never; + post?: never; + /** + * Delete a code execution context by id + * @description Deletes an existing code execution context (session) by id. + * This should terminate the underlying context thread/process and release resources. + */ + delete: operations["deleteContext"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/code/context": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create code execution context + * @description Creates a new code execution environment and returns a session ID that can be used + * for subsequent code execution requests. The context maintains state across multiple + * code executions within the same session. + */ + post: operations["createCodeContext"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Execute code in context + * @description Executes code using Jupyter kernel in a specified execution context and streams + * the output in real-time using SSE (Server-Sent Events). Supports multiple programming + * languages (Python, JavaScript, etc.) and maintains execution state within the session. + * Returns execution results, output streams, execution count, and any errors. + */ + post: operations["runCode"]; + /** + * Interrupt code execution + * @description Interrupts the currently running code execution in the specified context. + * This sends a signal to terminate the execution process and releases associated resources. + */ + delete: operations["interruptCode"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/command": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Execute shell command + * @description Executes a shell command and streams the output in real-time using SSE (Server-Sent Events). + * The command can run in foreground or background mode. The response includes stdout, stderr, + * execution status, and completion events. + */ + post: operations["runCommand"]; + /** + * Interrupt command execution + * @description Interrupts the currently running command execution in the specified context. + * This sends a signal to terminate the execution process and releases associated resources. + */ + delete: operations["interruptCommand"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/command/status/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get command running status + * @description Returns the current status of a command (foreground or background) by command ID. + * Includes running flag, exit code, error (if any), and start/finish timestamps. + */ + get: operations["getCommandStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/command/{id}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get background command stdout/stderr (non-streamed) + * @description Returns stdout and stderr for a background (detached) command by command ID. + * Foreground commands should be consumed via SSE; this endpoint is intended for + * polling logs of background commands. Supports incremental reads similar to a file seek: + * pass a starting line via query to fetch output after that line and receive the latest + * tail cursor for the next poll. When no starting line is provided, the full logs are returned. + * Response body is plain text so it can be rendered directly in browsers; the latest line index + * is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests. + */ + get: operations["getBackgroundCommandLogs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get file metadata + * @description Retrieves detailed metadata for one or multiple files including permissions, owner, + * group, size, and modification time. Returns a map of file paths to their corresponding + * FileInfo objects. + */ + get: operations["getFilesInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete files + * @description Deletes one or multiple files from the sandbox. Only removes files, not directories. + * Use RemoveDirs for directory removal. + */ + delete: operations["removeFiles"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change file permissions + * @description Changes permissions (mode), owner, and group for one or multiple files. + * Accepts a map of file paths to permission settings including octal mode, + * owner username, and group name. + */ + post: operations["chmodFiles"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/mv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rename or move files + * @description Renames or moves one or multiple files to new paths. Can be used for both + * renaming within the same directory and moving to different directories. + * Target directory must exist. + */ + post: operations["renameFiles"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search for files + * @description Searches for files matching a glob pattern within a specified directory and + * its subdirectories. Returns file metadata including path, permissions, owner, + * and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files). + */ + get: operations["searchFiles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/replace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Replace file content + * @description Performs text replacement in one or multiple files. Replaces all occurrences + * of the old string with the new string (similar to strings.ReplaceAll). + * Preserves file permissions. Useful for batch text substitution across files. + */ + post: operations["replaceContent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload files to sandbox + * @description Uploads one or multiple files to specified paths within the sandbox. + * Reads metadata and file content from multipart form parts in sequence. + * Each file upload consists of two parts: a metadata part (JSON) followed + * by the actual file part. + */ + post: operations["uploadFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/files/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download file from sandbox + * @description Downloads a file from the specified path within the sandbox. Supports HTTP + * range requests for resumable downloads and partial content retrieval. + * Returns file as octet-stream with appropriate headers. + */ + get: operations["downloadFile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/directories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create directories + * @description Creates one or multiple directories with specified permissions. Creates parent + * directories as needed (similar to mkdir -p). Accepts a map of directory paths + * to permission objects. + */ + post: operations["makeDirs"]; + /** + * Delete directories + * @description Recursively deletes one or multiple directories and all their contents. + * Similar to rm -rf. Use with caution as this operation cannot be undone. + */ + delete: operations["removeDirs"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get system metrics + * @description Retrieves current system resource metrics including CPU usage percentage, + * CPU core count, total memory, used memory, and timestamp. Provides a snapshot + * of system resource utilization at the time of request. + */ + get: operations["getMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics/watch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Watch system metrics in real-time + * @description Streams system resource metrics in real-time using Server-Sent Events (SSE). + * Updates are sent every second, providing continuous monitoring of CPU usage, + * memory usage, and other system metrics. The connection remains open until + * the client disconnects. + */ + get: operations["watchMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description Request to create a code execution context */ + CodeContextRequest: { + /** + * @description Execution runtime (python, bash, java, etc.) + * @example python + */ + language?: string; + }; + /** @description Code execution context with session identifier */ + CodeContext: { + /** + * @description Unique session identifier returned by CreateContext + * @example session-abc123 + */ + id?: string; + /** + * @description Execution runtime + * @example python + */ + language: string; + }; + /** @description Request to execute code in a context */ + RunCodeRequest: { + context?: components["schemas"]["CodeContext"]; + /** + * @description Source code to execute + * @example import numpy as np + * result = np.array([1, 2, 3]) + * print(result) + */ + code: string; + }; + /** @description Request to execute a shell command */ + RunCommandRequest: { + /** + * @description Shell command to execute + * @example ls -la /workspace + */ + command: string; + /** + * @description Working directory for command execution + * @example /workspace + */ + cwd?: string; + /** + * @description Whether to run command in detached mode + * @default false + * @example false + */ + background: boolean; + }; + /** @description Command execution status (foreground or background) */ + CommandStatusResponse: { + /** + * @description Command ID returned by RunCommand + * @example cmd-abc123 + */ + id?: string; + /** + * @description Original command content + * @example ls -la + */ + content?: string; + /** + * @description Whether the command is still running + * @example false + */ + running?: boolean; + /** + * Format: int32 + * @description Exit code if the command has finished + * @example 0 + */ + exit_code?: number | null; + /** + * @description Error message if the command failed + * @example permission denied + */ + error?: string; + /** + * Format: date-time + * @description Start time in RFC3339 format + * @example 2025-12-22T09:08:05Z + */ + started_at?: string; + /** + * Format: date-time + * @description Finish time in RFC3339 format (null if still running) + * @example 2025-12-22T09:08:09Z + */ + finished_at?: string | null; + }; + /** @description Server-sent event for streaming execution output */ + ServerStreamEvent: { + /** + * @description Event type for client-side handling + * @example stdout + * @enum {string} + */ + type?: "init" | "status" | "error" | "stdout" | "stderr" | "result" | "execution_complete" | "execution_count" | "ping"; + /** + * @description Textual data for status, init, and stream events + * @example Hello, World! + */ + text?: string; + /** + * @description Cell execution number in the session + * @example 1 + */ + execution_count?: number; + /** + * Format: int64 + * @description Execution duration in milliseconds + * @example 150 + */ + execution_time?: number; + /** + * Format: int64 + * @description When the event was generated (Unix milliseconds) + * @example 1700000000000 + */ + timestamp?: number; + /** + * @description Execution output in various MIME types (e.g., "text/plain", "text/html") + * @example { + * "text/plain": "4" + * } + */ + results?: { + [key: string]: unknown; + }; + /** @description Execution error details if an error occurred */ + error?: { + /** + * @description Error name/type + * @example NameError + */ + ename?: string; + /** + * @description Error value/message + * @example name 'undefined_var' is not defined + */ + evalue?: string; + /** + * @description Stack trace lines + * @example [ + * "Traceback (most recent call last):", + * " File \"\", line 1, in ", + * "NameError: name 'undefined_var' is not defined" + * ] + */ + traceback?: string[]; + }; + }; + /** @description File metadata including path and permissions */ + FileInfo: { + /** + * @description Absolute file path + * @example /workspace/file.txt + */ + path: string; + /** + * Format: int64 + * @description File size in bytes + * @example 2048 + */ + size: number; + /** + * Format: date-time + * @description Last modification time + * @example 2025-11-16T14:30:45Z + */ + modified_at: string; + /** + * Format: date-time + * @description File creation time + * @example 2025-11-16T14:30:45Z + */ + created_at: string; + /** + * @description File owner username + * @example admin + */ + owner: string; + /** + * @description File group name + * @example admin + */ + group: string; + /** + * @description File permissions in octal format + * @example 755 + */ + mode: number; + }; + /** @description File ownership and mode settings */ + Permission: { + /** + * @description Owner username + * @example root + */ + owner?: string; + /** + * @description Group name + * @example root + */ + group?: string; + /** + * @description Permission mode in octal format (e.g., 644, 755) + * @default 755 + * @example 755 + */ + mode: number; + }; + /** @description File metadata for upload operations */ + FileMetadata: { + /** + * @description Target file path + * @example /workspace/upload.txt + */ + path?: string; + /** + * @description File owner + * @example admin + */ + owner?: string; + /** + * @description File group + * @example admin + */ + group?: string; + /** + * @description File permissions in octal + * @example 755 + */ + mode?: number; + }; + /** @description File rename/move operation */ + RenameFileItem: { + /** + * @description Source file path + * @example /workspace/old.txt + */ + src: string; + /** + * @description Destination file path + * @example /workspace/new.txt + */ + dest: string; + }; + /** @description Content replacement operation */ + ReplaceFileContentItem: { + /** + * @description String to be replaced + * @example localhost + */ + old: string; + /** + * @description Replacement string + * @example 0.0.0.0 + */ + new: string; + }; + /** @description System resource usage metrics */ + Metrics: { + /** + * Format: float + * @description Number of CPU cores + * @example 4 + */ + cpu_count: number; + /** + * Format: float + * @description CPU usage percentage + * @example 45.5 + */ + cpu_used_pct: number; + /** + * Format: float + * @description Total memory in MiB + * @example 8192 + */ + mem_total_mib: number; + /** + * Format: float + * @description Used memory in MiB + * @example 4096 + */ + mem_used_mib: number; + /** + * Format: int64 + * @description Timestamp when metrics were collected (Unix milliseconds) + * @example 1700000000000 + */ + timestamp: number; + }; + /** @description Standard error response format */ + ErrorResponse: { + /** + * @description Error code for programmatic handling + * @example INVALID_REQUEST_BODY + */ + code: string; + /** + * @description Human-readable error message + * @example error parsing request, MAYBE invalid body format + */ + message: string; + }; + }; + responses: { + /** @description Invalid request body format or missing required fields */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "INVALID_REQUEST_BODY", + * "message": "error parsing request, MAYBE invalid body format" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description File or resource not found */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "FILE_NOT_FOUND", + * "message": "file not found" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Runtime server error during operation */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "RUNTIME_ERROR", + * "message": "error running code execution" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + ping: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Server is alive and healthy */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listContexts: { + parameters: { + query: { + /** + * @description Filter contexts by execution runtime (python, bash, java, etc.) + * @example python + */ + language: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of active contexts */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeContext"][]; + }; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + deleteContextsByLanguage: { + parameters: { + query: { + /** + * @description Target execution runtime whose contexts should be deleted + * @example python + */ + language: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contexts deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getContext: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description Session/context id to get + * @example session-abc123 + */ + context_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Context details retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeContext"]; + }; + }; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + deleteContext: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description Session/context id to delete + * @example session-abc123 + */ + context_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Context deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + createCodeContext: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CodeContextRequest"]; + }; + }; + responses: { + /** @description Successfully created context with session ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CodeContext"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + runCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RunCodeRequest"]; + }; + }; + responses: { + /** @description Stream of code execution events */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["ServerStreamEvent"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + interruptCode: { + parameters: { + query: { + /** + * @description Session ID of the execution context to interrupt + * @example session-123 + */ + id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code execution successfully interrupted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + runCommand: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RunCommandRequest"]; + }; + }; + responses: { + /** @description Stream of command execution events */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["ServerStreamEvent"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + interruptCommand: { + parameters: { + query: { + /** + * @description Session ID of the execution context to interrupt + * @example session-456 + */ + id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Command execution successfully interrupted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getCommandStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description Command ID returned by RunCommand + * @example cmd-abc123 + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Command status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandStatusResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getBackgroundCommandLogs: { + parameters: { + query?: { + /** + * @description Optional 0-based line cursor (behaves like a file seek). When provided, only + * stdout/stderr lines after this line are returned. The response includes the + * latest line index (`cursor`) so the client can request incremental output + * on subsequent calls. If omitted, the full log is returned. + * @example 120 + */ + cursor?: number; + }; + header?: never; + path: { + /** + * @description Command ID returned by RunCommand + * @example cmd-abc123 + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Command output (plain text) and status metadata via headers */ + 200: { + headers: { + /** @description Highest available 0-based line index after applying the request cursor (use as the next cursor for incremental reads) */ + "EXECD-COMMANDS-TAIL-CURSOR"?: number; + [name: string]: unknown; + }; + content: { + /** + * @example line1 + * line2 + * warn: something on stderr + */ + "text/plain": string; + }; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getFilesInfo: { + parameters: { + query: { + /** @description File path(s) to get info for (can be specified multiple times) */ + path: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Map of file paths to FileInfo objects */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: components["schemas"]["FileInfo"]; + }; + }; + }; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + removeFiles: { + parameters: { + query: { + /** + * @description File path(s) to delete (can be specified multiple times) + * @example [ + * "/workspace/temp.txt" + * ] + */ + path: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Files deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + chmodFiles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "/workspace/script.sh": { + * "owner": "admin", + * "group": "admin", + * "mode": 755 + * }, + * "/workspace/config.json": { + * "owner": "admin", + * "group": "admin", + * "mode": 755 + * } + * } + */ + "application/json": { + [key: string]: components["schemas"]["Permission"]; + }; + }; + }; + responses: { + /** @description Permissions changed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + renameFiles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example [ + * { + * "src": "/workspace/old_name.txt", + * "dest": "/workspace/new_name.txt" + * }, + * { + * "src": "/workspace/file.py", + * "dest": "/archive/file.py" + * } + * ] + */ + "application/json": components["schemas"]["RenameFileItem"][]; + }; + }; + responses: { + /** @description Files renamed/moved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + searchFiles: { + parameters: { + query: { + /** @description Root directory path to search in */ + path: string; + /** @description Glob pattern to match files (default is **) */ + pattern?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Array of matching files with metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FileInfo"][]; + }; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + replaceContent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "/workspace/config.yaml": { + * "old": "localhost:8080", + * "new": "0.0.0.0:9090" + * }, + * "/workspace/app.py": { + * "old": "DEBUG = True", + * "new": "DEBUG = False" + * } + * } + */ + "application/json": { + [key: string]: components["schemas"]["ReplaceFileContentItem"]; + }; + }; + }; + responses: { + /** @description Content replaced successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + uploadFile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** + * @description JSON-encoded file metadata (FileMetadata object) + * @example {"path":"/workspace/file.txt","owner":"admin","group":"admin","mode":755} + */ + metadata?: string; + /** + * Format: binary + * @description File to upload + */ + file?: string; + }; + }; + }; + responses: { + /** @description Files uploaded successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + downloadFile: { + parameters: { + query: { + /** + * @description Absolute or relative path of the file to download + * @example /workspace/data.csv + */ + path: string; + }; + header?: { + /** + * @description HTTP Range header for partial content requests + * @example bytes=0-1023 + */ + Range?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description File content */ + 200: { + headers: { + /** @description Attachment header with filename */ + "Content-Disposition"?: string; + /** @description File size in bytes */ + "Content-Length"?: number; + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Partial file content (when Range header is provided) */ + 206: { + headers: { + /** @description Range of bytes being returned */ + "Content-Range"?: string; + /** @description Length of the returned range */ + "Content-Length"?: number; + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + /** @description Requested range not satisfiable */ + 416: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + makeDirs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "/workspace/project": { + * "owner": "admin", + * "group": "admin", + * "mode": 755 + * }, + * "/workspace/logs": { + * "owner": "admin", + * "group": "admin", + * "mode": 755 + * } + * } + */ + "application/json": { + [key: string]: components["schemas"]["Permission"]; + }; + }; + }; + responses: { + /** @description Directories created successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + removeDirs: { + parameters: { + query: { + /** + * @description Directory path(s) to delete (can be specified multiple times) + * @example [ + * "/workspace/temp" + * ] + */ + path: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Directories deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + getMetrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current system metrics including CPU and memory usage */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Metrics"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; + watchMetrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stream of system metrics updated every second */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["Metrics"]; + }; + }; + 500: components["responses"]["InternalServerError"]; + }; + }; +} diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts new file mode 100644 index 00000000..2719d2f7 --- /dev/null +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -0,0 +1,801 @@ +// Copyright 2026 Alibaba Group Holding Ltd.. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/sandboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List sandboxes + * @description List all sandboxes with optional filtering and pagination using query parameters. + * All filter conditions use AND logic. Multiple `state` parameters use OR logic within states. + */ + get: { + parameters: { + query?: { + /** + * @description Filter by lifecycle state. Pass multiple times for OR logic. + * Example: `?state=Running&state=Paused` + */ + state?: string[]; + /** + * @description Arbitrary metadata key-value pairs for filtering,keys and values must be url encoded + * Example: To filter by `project=Apollo` and `note=Demo Test`: `?metadata=project%3DApollo%26note%3DDemo%252520Test` + */ + metadata?: string; + /** @description Page number for pagination */ + page?: number; + /** @description Number of items per page */ + pageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paginated collection of sandboxes */ + 200: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSandboxesResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + put?: never; + /** + * Create a sandbox from a container image + * @description Creates a new sandbox from a container image with optional resource limits, + * environment variables, and metadata. Sandboxes are provisioned directly from + * the specified image without requiring a pre-created template. + * + * ## Authentication + * + * API Key authentication is required via: + * - `OPEN-SANDBOX-API-KEY: ` header + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSandboxRequest"]; + }; + }; + responses: { + /** + * @description Sandbox created and accepted for provisioning. + * + * The returned sandbox includes: + * - `id`: Unique sandbox identifier + * - `status.state: "Pending"` (auto-starting provisioning) + * - `status.reason` and `status.message` indicating initialization stage + * - `metadata`, `expiresAt`, `createdAt`: Core sandbox information + * + * Note: `image` and `updatedAt` are not included in the create response. + * Use GET /sandboxes/{sandboxId} to retrieve the complete sandbox information including image spec. + * + * To track provisioning progress, poll GET /sandboxes/{sandboxId}. + * The sandbox will automatically transition to `Running` state once provisioning completes. + */ + 202: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateSandboxResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + /** + * Fetch a sandbox by id + * @description Returns the complete sandbox information including: + * - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information + * - `image`: Container image specification (not included in create response) + * - `entrypoint`: Entry process specification + * + * This is the complete representation of the sandbox resource. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sandbox current state and metadata */ + 200: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + put?: never; + post?: never; + /** + * Delete a sandbox + * @description Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to Terminated. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** + * @description Sandbox successfully deleted. + * + * Sandbox has been scheduled for termination and will transition to Stopping state, then Terminated. + */ + 204: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}/pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pause execution while retaining state + * @description Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state transition to Paused. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** + * @description Pause operation accepted. + * + * Sandbox will transition to Pausing state. + * Poll GET /sandboxes/{sandboxId} to track progress. + */ + 202: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resume a paused sandbox + * @description Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to Running. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** + * @description Resume operation accepted. + * + * Sandbox will transition from Paused → Running. + * Poll GET /sandboxes/{sandboxId} to track progress. + */ + 202: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}/renew-expiration": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Renew sandbox expiration + * @description Renew the absolute expiration time of a sandbox. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RenewSandboxExpirationRequest"]; + }; + }; + responses: { + /** + * @description Sandbox expiration updated successfully. + * + * Returns only the updated expiresAt field. + */ + 200: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RenewSandboxExpirationResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxId}/endpoints/{port}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get sandbox access endpoint + * @description Get the public access endpoint URL for accessing a service running on a specific port + * within the sandbox. The service must be listening on the specified port inside + * the sandbox for the endpoint to be available. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique sandbox identifier */ + sandboxId: components["parameters"]["SandboxId"]; + /** @description Port number where the service is listening inside the sandbox */ + port: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** + * @description Endpoint retrieved successfully. + * + * Returns the public URL for accessing the service on the specified port. + */ + 200: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Endpoint"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ListSandboxesResponse: { + items: components["schemas"]["Sandbox"][]; + pagination: components["schemas"]["PaginationInfo"]; + }; + /** @description Pagination metadata for list responses */ + PaginationInfo: { + /** @description Current page number */ + page: number; + /** @description Number of items per page */ + pageSize: number; + /** @description Total number of items matching the filter */ + totalItems: number; + /** @description Total number of pages */ + totalPages: number; + /** @description Whether there are more pages after the current one */ + hasNextPage: boolean; + }; + /** @description Response from creating a new sandbox. Contains essential information without image and updatedAt. */ + CreateSandboxResponse: { + /** @description Unique sandbox identifier */ + id: string; + /** @description Current lifecycle status and detailed state information */ + status: components["schemas"]["SandboxStatus"]; + /** @description Custom metadata from creation request */ + metadata?: { + [key: string]: string; + }; + /** + * Format: date-time + * @description Timestamp when sandbox will auto-terminate + */ + expiresAt: string; + /** + * Format: date-time + * @description Sandbox creation timestamp + */ + createdAt: string; + /** @description Entry process specification from creation request */ + entrypoint: string[]; + }; + /** @description Runtime execution environment provisioned from a container image */ + Sandbox: { + /** @description Unique sandbox identifier */ + id: string; + /** + * @description Container image specification used to provision this sandbox. + * Only present in responses for GET/LIST operations. Not returned in createSandbox response. + */ + image: components["schemas"]["ImageSpec"]; + /** @description Current lifecycle status and detailed state information */ + status: components["schemas"]["SandboxStatus"]; + /** @description Custom metadata from creation request */ + metadata?: { + [key: string]: string; + }; + /** + * @description The command to execute as the sandbox's entry process. + * Always present in responses since entrypoint is required in creation requests. + */ + entrypoint: string[]; + /** + * Format: date-time + * @description Timestamp when sandbox will auto-terminate + */ + expiresAt: string; + /** + * Format: date-time + * @description Sandbox creation timestamp + */ + createdAt: string; + }; + /** + * @description High-level lifecycle state of the sandbox. + * + * Common state values: + * - Pending: Sandbox is being provisioned + * - Running: Sandbox is running and ready to accept requests + * - Pausing: Sandbox is in the process of pausing + * - Paused: Sandbox has been paused while retaining its state + * - Stopping: Sandbox is being terminated + * - Terminated: Sandbox has been successfully terminated + * - Failed: Sandbox encountered a critical error + * + * State transitions: + * - Pending → Running (after creation completes) + * - Running → Pausing (when pause is requested) + * - Pausing → Paused (pause operation completes) + * - Paused → Running (when resume is requested) + * - Running/Paused → Stopping (when kill is requested or TTL expires) + * - Stopping → Terminated (kill/timeout operation completes) + * - Pending/Running/Paused → Failed (on error) + * + * Note: New state values may be added in future versions. + * Clients should handle unknown state values gracefully. + */ + SandboxState: string; + /** @description Detailed status information with lifecycle state and transition details */ + SandboxStatus: { + /** @description Current lifecycle state of the sandbox */ + state: components["schemas"]["SandboxState"]; + /** + * @description Short machine-readable reason code for the current state. + * Examples: "user_delete", "ttl_expiry", "provision_timeout", "runtime_error" + */ + reason?: string; + /** @description Human-readable message describing the current state or reason for state transition */ + message?: string; + /** + * Format: date-time + * @description Timestamp of the last state transition + */ + lastTransitionAt?: string; + }; + /** + * @description Container image specification for sandbox provisioning. + * + * Supports public registry images and private registry images with authentication. + */ + ImageSpec: { + /** + * @description Container image URI in standard format. + * + * Examples: + * - "python:3.11" (Docker Hub) + * - "ubuntu:22.04" + * - "gcr.io/my-project/model-server:v1.0" + * - "private-registry.company.com:5000/app:latest" + */ + uri: string; + /** @description Registry authentication credentials (required for private registries) */ + auth?: { + /** @description Registry username or service account */ + username?: string; + /** @description Registry password or authentication token */ + password?: string; + }; + }; + /** + * @description Request to create a new sandbox from a container image. + * + * **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header. + */ + CreateSandboxRequest: { + /** @description Container image specification for the sandbox */ + image: components["schemas"]["ImageSpec"]; + /** + * @description Sandbox timeout in seconds. The sandbox will automatically terminate after this duration. + * SDK clients should provide a default value (e.g., 3600 seconds / 1 hour). + */ + timeout: number; + /** + * @description Runtime resource constraints for the sandbox instance. + * SDK clients should provide sensible defaults (e.g., cpu: "500m", memory: "512Mi"). + */ + resourceLimits: components["schemas"]["ResourceLimits"]; + /** + * @description Environment variables to inject into the sandbox runtime. + * @example { + * "API_KEY": "secret-key", + * "DEBUG": "true", + * "LOG_LEVEL": "info" + * } + */ + env?: { + [key: string]: string; + }; + /** + * @description Custom key-value metadata for management, filtering, and tagging. + * Use "name" key for a human-readable identifier. + * @example { + * "name": "Data Processing Sandbox", + * "project": "data-processing", + * "team": "ml", + * "environment": "staging" + * } + */ + metadata?: { + [key: string]: string; + }; + /** + * @description The command to execute as the sandbox's entry process (required). + * + * Explicitly specifies the user's expected main process, allowing the sandbox management + * service to reliably inject control processes before executing this command. + * + * Format: [executable, arg1, arg2, ...] + * + * Examples: + * - ["python", "/app/main.py"] + * - ["/bin/bash"] + * - ["java", "-jar", "/app/app.jar"] + * - ["node", "server.js"] + * @example [ + * "python", + * "/app/main.py" + * ] + */ + entrypoint: string[]; + /** + * @description Opaque container for provider-specific or transient parameters not supported by the core API. + * + * **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard parameters should be proposed as core API fields. + * + * **Best Practices**: + * - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions. + * - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently. + */ + extensions?: { + [key: string]: string; + }; + }; + /** + * @description Runtime resource constraints as key-value pairs. Similar to Kubernetes resource specifications, + * allows flexible definition of resource limits. Common resource types include: + * - `cpu`: CPU allocation in millicores (e.g., "250m" for 0.25 CPU cores) + * - `memory`: Memory allocation in bytes or human-readable format (e.g., "512Mi", "1Gi") + * - `gpu`: Number of GPU devices (e.g., "1") + * + * New resource types can be added without API changes. + * @example { + * "cpu": "500m", + * "memory": "512Mi", + * "gpu": "1" + * } + */ + ResourceLimits: { + [key: string]: string; + }; + RenewSandboxExpirationRequest: { + /** + * Format: date-time + * @description New absolute expiration time in UTC (RFC 3339 format). + * Must be in the future and after the current expiresAt time. + * + * Example: "2025-11-16T14:30:45Z" + */ + expiresAt: string; + }; + RenewSandboxExpirationResponse: { + /** + * Format: date-time + * @description The new absolute expiration time in UTC (RFC 3339 format). + * + * Example: "2025-11-16T14:30:45Z" + */ + expiresAt: string; + }; + /** + * @description Standard error response for all non-2xx HTTP responses. + * HTTP status code indicates the error category; code and message provide details. + */ + ErrorResponse: { + /** + * @description Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR). + * Use this for programmatic error handling. + */ + code: string; + /** @description Human-readable error message describing what went wrong and how to fix it. */ + message: string; + }; + /** + * @description Endpoint for accessing a service running in the sandbox. + * The service must be listening on the specified port inside the sandbox for the endpoint to be available. + */ + Endpoint: { + /** + * @description Public URL to access the service from outside the sandbox. + * Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port} + * Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080 + */ + endpoint: string; + }; + }; + responses: { + /** @description Error response envelope */ + Error: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The request was invalid or malformed */ + BadRequest: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Authentication credentials are missing or invalid */ + Unauthorized: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The authenticated user lacks permission for this operation */ + Forbidden: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The requested resource does not exist */ + NotFound: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The operation conflicts with the current state */ + Conflict: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description An unexpected server error occurred */ + InternalServerError: { + headers: { + "X-Request-ID": components["headers"]["XRequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + /** @description Unique sandbox identifier */ + SandboxId: string; + }; + requestBodies: never; + headers: { + /** @description Unique request identifier for tracing */ + XRequestId: string; + /** @description URI of the newly created or related resource */ + Location: string; + /** @description Suggested delay in seconds before retrying */ + RetryAfter: number; + }; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/sdks/sandbox/javascript/src/config/connection.ts b/sdks/sandbox/javascript/src/config/connection.ts new file mode 100644 index 00000000..d77cd4ad --- /dev/null +++ b/sdks/sandbox/javascript/src/config/connection.ts @@ -0,0 +1,227 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type ConnectionProtocol = "http" | "https"; + +/** + * Options for {@link ConnectionConfig}. + * + * Most users only need `domain`, `protocol`, and `apiKey`. + */ +export interface ConnectionConfigOptions { + /** + * API server domain (host[:port]) without scheme. + * Examples: + * - "localhost:8080" + * - "api.opensandbox.io" + * + * You may also pass a full URL (e.g. "http://localhost:8080" or "https://api.example.com"). + * If the URL includes a path, it will be preserved and `/v1` will be appended automatically. + */ + domain?: string; + protocol?: ConnectionProtocol; + apiKey?: string; + headers?: Record; + + /** + * Request timeout applied to all SDK HTTP calls (best-effort; wraps fetch). + * Defaults to 30 seconds. + */ + requestTimeoutSeconds?: number; + /** + * Enable basic debug logging for HTTP requests (best-effort). + */ + debug?: boolean; +} + +function isNodeRuntime(): boolean { + const p = (globalThis as any)?.process; + return !!(p?.versions?.node); +} + +function redactHeaders(headers: Record): Record { + const out: Record = { ...headers }; + for (const k of Object.keys(out)) { + if (k.toLowerCase() === "open-sandbox-api-key") out[k] = "***"; + } + return out; +} + +function readEnv(name: string): string | undefined { + const env = (globalThis as any)?.process?.env; + const v = env?.[name]; + return typeof v === "string" && v.length ? v : undefined; +} + +function stripTrailingSlashes(s: string): string { + return s.replace(/\/+$/, ""); +} + +function stripV1Suffix(s: string): string { + const trimmed = stripTrailingSlashes(s); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function normalizeDomainBase(input: string): { protocol?: ConnectionProtocol; domainBase: string } { + // Accept a full URL and preserve its path prefix (if any). + if (input.startsWith("http://") || input.startsWith("https://")) { + const u = new URL(input); + const proto = u.protocol === "https:" ? "https" : "http"; + // Keep origin + pathname, drop query/hash. + const base = `${u.origin}${u.pathname}`; + return { protocol: proto, domainBase: stripV1Suffix(base) }; + } + + // No scheme: treat as "host[:port]" or "host[:port]/prefix" and normalize trailing "/v1" or "/". + return { domainBase: stripV1Suffix(input) }; +} + +function createTimedFetch(opts: { + baseFetch: typeof fetch; + timeoutSeconds: number; + debug: boolean; + defaultHeaders?: Record; + label: string; +}): typeof fetch { + const baseFetch = opts.baseFetch; + const timeoutSeconds = opts.timeoutSeconds; + const debug = opts.debug; + const defaultHeaders = opts.defaultHeaders ?? {}; + const label = opts.label; + + return async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const url = typeof input === "string" ? input : (input as any)?.toString?.() ?? String(input); + + const ac = new AbortController(); + const timeoutMs = Math.floor(timeoutSeconds * 1000); + const t = Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => ac.abort(new Error(`[${label}] Request timed out (timeoutSeconds=${timeoutSeconds})`)), timeoutMs) + : undefined; + + const onAbort = () => ac.abort((init?.signal as any)?.reason ?? new Error("Aborted")); + if (init?.signal) { + if (init.signal.aborted) onAbort(); + else init.signal.addEventListener("abort", onAbort, { once: true } as any); + } + + const mergedInit: RequestInit = { + ...init, + signal: ac.signal, + }; + + if (debug) { + const mergedHeaders = { ...defaultHeaders, ...((init?.headers ?? {}) as any) }; + // eslint-disable-next-line no-console + console.log(`[opensandbox:${label}] ->`, method, url, redactHeaders(mergedHeaders)); + } + + try { + const res = await baseFetch(input, mergedInit); + if (debug) { + // eslint-disable-next-line no-console + console.log(`[opensandbox:${label}] <-`, method, url, res.status); + } + return res; + } finally { + if (t) clearTimeout(t); + if (init?.signal) init.signal.removeEventListener("abort", onAbort as any); + } + }; +} + +export class ConnectionConfig { + readonly protocol: ConnectionProtocol; + readonly domain: string; + readonly apiKey?: string; + readonly headers: Record; + readonly fetch: typeof fetch; + /** + * Fetch function intended for long-lived streaming requests (SSE). + * + * This is separate from {@link fetch} so callers can supply a different implementation + * (e.g. Node undici with bodyTimeout disabled) and so the SDK can apply distinct + * timeout semantics for streaming vs normal HTTP calls. + */ + readonly sseFetch: typeof fetch; + readonly requestTimeoutSeconds: number; + readonly debug: boolean; + readonly userAgent: string = "OpenSandbox-JS-SDK/0.1.0"; + + /** + * Create a connection configuration. + * + * Environment variables (optional): + * - `OPEN_SANDBOX_DOMAIN` (default: `localhost:8080`) + * - `OPEN_SANDBOX_API_KEY` + */ + constructor(opts: ConnectionConfigOptions = {}) { + const envDomain = readEnv("OPEN_SANDBOX_DOMAIN"); + const envApiKey = readEnv("OPEN_SANDBOX_API_KEY"); + + const rawDomain = opts.domain ?? envDomain ?? "localhost:8080"; + const normalized = normalizeDomainBase(rawDomain); + + // If the domain includes a scheme, it overrides `protocol`. + this.protocol = normalized.protocol ?? opts.protocol ?? "http"; + this.domain = normalized.domainBase; + this.apiKey = opts.apiKey ?? envApiKey; + this.requestTimeoutSeconds = typeof opts.requestTimeoutSeconds === "number" + ? opts.requestTimeoutSeconds + : 30; + this.debug = !!opts.debug; + + const headers: Record = { ...(opts.headers ?? {}) }; + // Attach API key via header unless the user already provided one. + if (this.apiKey && !headers["OPEN-SANDBOX-API-KEY"]) { + headers["OPEN-SANDBOX-API-KEY"] = this.apiKey; + } + // Best-effort user-agent (Node only). + if (isNodeRuntime() && this.userAgent && !headers["user-agent"] && !headers["User-Agent"]) { + headers["user-agent"] = this.userAgent; + } + this.headers = headers; + + // Node SDK: do not expose custom fetch in ConnectionConfigOptions. + // Use the runtime's global fetch (Node >= 20). + const baseFetch = fetch; + const baseSseFetch = fetch; + + // Normal HTTP calls: apply requestTimeoutSeconds. + this.fetch = createTimedFetch({ + baseFetch, + timeoutSeconds: this.requestTimeoutSeconds, + debug: this.debug, + defaultHeaders: this.headers, + label: "http", + }); + + // Streaming calls (SSE): do not apply SDK-side timeouts; do not proactively disconnect. + this.sseFetch = createTimedFetch({ + baseFetch: baseSseFetch, + timeoutSeconds: 0, + debug: this.debug, + defaultHeaders: this.headers, + label: "sse", + }); + } + + getBaseUrl(): string { + // If `domain` already contains a scheme, treat it as a full base URL prefix. + if (this.domain.startsWith("http://") || this.domain.startsWith("https://")) { + return `${stripV1Suffix(this.domain)}/v1`; + } + return `${this.protocol}://${stripV1Suffix(this.domain)}/v1`; + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/core/constants.ts b/sdks/sandbox/javascript/src/core/constants.ts new file mode 100644 index 00000000..8f7775a0 --- /dev/null +++ b/sdks/sandbox/javascript/src/core/constants.ts @@ -0,0 +1,29 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const DEFAULT_EXECD_PORT = 44772; + +export const DEFAULT_ENTRYPOINT: string[] = ["tail", "-f", "/dev/null"]; + +export const DEFAULT_RESOURCE_LIMITS: Record = { + cpu: "1", + memory: "2Gi", +}; + +export const DEFAULT_TIMEOUT_SECONDS = 600; +export const DEFAULT_READY_TIMEOUT_SECONDS = 30; +export const DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS = 200; + +export const DEFAULT_REQUEST_TIMEOUT_SECONDS = 30; +export const DEFAULT_USER_AGENT = "OpenSandbox-JS-SDK/0.1.0"; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/core/exceptions.ts b/sdks/sandbox/javascript/src/core/exceptions.ts new file mode 100644 index 00000000..d0297e75 --- /dev/null +++ b/sdks/sandbox/javascript/src/core/exceptions.ts @@ -0,0 +1,134 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type SandboxErrorCode = + | "INTERNAL_UNKNOWN_ERROR" + | "READY_TIMEOUT" + | "UNHEALTHY" + | "INVALID_ARGUMENT" + | "UNEXPECTED_RESPONSE" + // Allow server-defined codes as well. + | (string & {}); + +/** + * Structured error payload carried by {@link SandboxException}. + * + * - `code`: stable programmatic identifier + * - `message`: optional human-readable message + */ +export class SandboxError { + static readonly INTERNAL_UNKNOWN_ERROR: SandboxErrorCode = "INTERNAL_UNKNOWN_ERROR"; + static readonly READY_TIMEOUT: SandboxErrorCode = "READY_TIMEOUT"; + static readonly UNHEALTHY: SandboxErrorCode = "UNHEALTHY"; + static readonly INVALID_ARGUMENT: SandboxErrorCode = "INVALID_ARGUMENT"; + static readonly UNEXPECTED_RESPONSE: SandboxErrorCode = "UNEXPECTED_RESPONSE"; + + constructor( + readonly code: SandboxErrorCode, + readonly message?: string, + ) {} +} + +interface SandboxExceptionOpts { + message?: string; + cause?: unknown; + error?: SandboxError; +} + +/** + * Base exception class for all SDK errors. + * + * All errors thrown by this SDK are subclasses of {@link SandboxException}. + */ +export class SandboxException extends Error { + readonly name: string = "SandboxException"; + readonly error: SandboxError; + readonly cause?: unknown; + + constructor(opts: SandboxExceptionOpts = {}) { + super(opts.message); + this.cause = opts.cause; + this.error = opts.error ?? new SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR); + } +} + +export class SandboxApiException extends SandboxException { + readonly name: string = "SandboxApiException"; + readonly statusCode?: number; + readonly requestId?: string; + readonly rawBody?: unknown; + + constructor(opts: SandboxExceptionOpts & { + statusCode?: number; + requestId?: string; + rawBody?: unknown; + }) { + super({ + message: opts.message, + cause: opts.cause, + error: opts.error ?? new SandboxError(SandboxError.UNEXPECTED_RESPONSE, opts.message), + }); + this.statusCode = opts.statusCode; + this.requestId = opts.requestId; + this.rawBody = opts.rawBody; + } +} + +export class SandboxInternalException extends SandboxException { + readonly name: string = "SandboxInternalException"; + + constructor(opts: { message?: string; cause?: unknown }) { + super({ + message: opts.message, + cause: opts.cause, + error: new SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR, opts.message), + }); + } +} + +export class SandboxUnhealthyException extends SandboxException { + readonly name: string = "SandboxUnhealthyException"; + + constructor(opts: { message?: string; cause?: unknown }) { + super({ + message: opts.message, + cause: opts.cause, + error: new SandboxError(SandboxError.UNHEALTHY, opts.message), + }); + } +} + +export class SandboxReadyTimeoutException extends SandboxException { + readonly name: string = "SandboxReadyTimeoutException"; + + constructor(opts: { message?: string; cause?: unknown }) { + super({ + message: opts.message, + cause: opts.cause, + error: new SandboxError(SandboxError.READY_TIMEOUT, opts.message), + }); + } +} + +export class InvalidArgumentException extends SandboxException { + readonly name: string = "InvalidArgumentException"; + + constructor(opts: { message?: string; cause?: unknown }) { + super({ + message: opts.message, + cause: opts.cause, + error: new SandboxError(SandboxError.INVALID_ARGUMENT, opts.message), + }); + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts new file mode 100644 index 00000000..c62d60d1 --- /dev/null +++ b/sdks/sandbox/javascript/src/factory/adapterFactory.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ConnectionConfig } from "../config/connection.js"; +import type { SandboxFiles } from "../services/filesystem.js"; +import type { ExecdCommands } from "../services/execdCommands.js"; +import type { ExecdHealth } from "../services/execdHealth.js"; +import type { ExecdMetrics } from "../services/execdMetrics.js"; +import type { Sandboxes } from "../services/sandboxes.js"; + +export interface CreateLifecycleStackOptions { + connectionConfig: ConnectionConfig; + lifecycleBaseUrl: string; +} + +export interface LifecycleStack { + sandboxes: Sandboxes; +} + +export interface CreateExecdStackOptions { + connectionConfig: ConnectionConfig; + execdBaseUrl: string; +} + +export interface ExecdStack { + commands: ExecdCommands; + files: SandboxFiles; + health: ExecdHealth; + metrics: ExecdMetrics; +} + +/** + * Factory abstraction to keep `Sandbox` and `SandboxManager` decoupled from concrete adapter implementations. + * + * This is primarily useful for advanced integrations (custom transports, dependency injection, testing). + */ +export interface AdapterFactory { + createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack; + createExecdStack(opts: CreateExecdStackOptions): ExecdStack; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts new file mode 100644 index 00000000..eb8ebc2f --- /dev/null +++ b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts @@ -0,0 +1,70 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createExecdClient } from "../openapi/execdClient.js"; +import { createLifecycleClient } from "../openapi/lifecycleClient.js"; + +import { CommandsAdapter } from "../adapters/commandsAdapter.js"; +import { FilesystemAdapter } from "../adapters/filesystemAdapter.js"; +import { HealthAdapter } from "../adapters/healthAdapter.js"; +import { MetricsAdapter } from "../adapters/metricsAdapter.js"; +import { SandboxesAdapter } from "../adapters/sandboxesAdapter.js"; + +import type { AdapterFactory, CreateExecdStackOptions, CreateLifecycleStackOptions, ExecdStack, LifecycleStack } from "./adapterFactory.js"; + +export class DefaultAdapterFactory implements AdapterFactory { + createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack { + const lifecycleClient = createLifecycleClient({ + baseUrl: opts.lifecycleBaseUrl, + apiKey: opts.connectionConfig.apiKey, + headers: opts.connectionConfig.headers, + fetch: opts.connectionConfig.fetch, + }); + const sandboxes = new SandboxesAdapter(lifecycleClient); + return { sandboxes }; + } + + createExecdStack(opts: CreateExecdStackOptions): ExecdStack { + const execdClient = createExecdClient({ + baseUrl: opts.execdBaseUrl, + headers: opts.connectionConfig.headers, + fetch: opts.connectionConfig.fetch, + }); + + const health = new HealthAdapter(execdClient); + const metrics = new MetricsAdapter(execdClient); + const files = new FilesystemAdapter(execdClient, { + baseUrl: opts.execdBaseUrl, + fetch: opts.connectionConfig.fetch, + headers: opts.connectionConfig.headers, + }); + const commands = new CommandsAdapter(execdClient, { + baseUrl: opts.execdBaseUrl, + // Streaming calls (SSE) use a dedicated fetch, aligned with Kotlin/Python SDKs. + fetch: opts.connectionConfig.sseFetch, + headers: opts.connectionConfig.headers, + }); + + return { + commands, + files, + health, + metrics, + }; + } +} + +export function createDefaultAdapterFactory(): AdapterFactory { + return new DefaultAdapterFactory(); +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts new file mode 100644 index 00000000..1a13f881 --- /dev/null +++ b/sdks/sandbox/javascript/src/index.ts @@ -0,0 +1,108 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { + InvalidArgumentException, + SandboxApiException, + SandboxError, + SandboxException, + SandboxInternalException, + SandboxReadyTimeoutException, + SandboxUnhealthyException, +} from "./core/exceptions.js"; + +// Factory pattern (stable public interface; does NOT expose OpenAPI generated models). +export type { AdapterFactory } from "./factory/adapterFactory.js"; +export { DefaultAdapterFactory, createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; + +export { ConnectionConfig } from "./config/connection.js"; +export type { ConnectionConfigOptions, ConnectionProtocol } from "./config/connection.js"; + +export type { + CreateSandboxRequest, + CreateSandboxResponse, + Endpoint, + ListSandboxesParams, + ListSandboxesResponse, + RenewSandboxExpirationRequest, + RenewSandboxExpirationResponse, + SandboxId, + SandboxInfo, +} from "./models/sandboxes.js"; + +export type { Sandboxes } from "./services/sandboxes.js"; + +export { SandboxManager } from "./manager.js"; +export type { SandboxFilter, SandboxManagerOptions } from "./manager.js"; + +export type { ExecdHealth } from "./services/execdHealth.js"; +export type { ExecdMetrics } from "./services/execdMetrics.js"; +export type { + FileInfo, + FileMetadata, + Permission, + RenameFileItem, + ReplaceFileContentItem, + SearchFilesResponse, + FilesInfoResponse, +} from "./models/filesystem.js"; + +export type { + CommandExecution, + RunCommandOpts, + RunCommandRequest, + ServerStreamEvent, + CodeContextRequest, + SupportedLanguage, + Metrics, + SandboxMetrics, + PingResponse, +} from "./models/execd.js"; +export type { ExecdCommands } from "./services/execdCommands.js"; + +export type { + Execution, + ExecutionComplete, + ExecutionError, + ExecutionHandlers, + ExecutionInit, + ExecutionResult, + OutputMessage, +} from "./models/execution.js"; +export { ExecutionEventDispatcher } from "./models/executionEventDispatcher.js"; + +export { + DEFAULT_ENTRYPOINT, + DEFAULT_EXECD_PORT, + DEFAULT_RESOURCE_LIMITS, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_READY_TIMEOUT_SECONDS, + DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +} from "./core/constants.js"; + +export type { + SandboxConnectOptions, + SandboxCreateOptions, +} from "./sandbox.js"; +export { Sandbox } from "./sandbox.js"; + +export type { + ContentReplaceEntry, + MoveEntry, + SearchEntry, + SetPermissionEntry, + WriteEntry, +} from "./models/filesystem.js"; +export type { SandboxFiles } from "./services/filesystem.js"; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/internal.ts b/sdks/sandbox/javascript/src/internal.ts new file mode 100644 index 00000000..1646c0e0 --- /dev/null +++ b/sdks/sandbox/javascript/src/internal.ts @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * INTERNAL / ADVANCED ENTRYPOINT + * + * This subpath exposes low-level OpenAPI clients and adapters for advanced integrations. + * It is intentionally NOT exported from the root entrypoint (`@alibaba-group/opensandbox`), + * because generated OpenAPI types are not considered stable public API. + * + * Import path: + * - `@alibaba-group/opensandbox/internal` + */ + +export { createLifecycleClient } from "./openapi/lifecycleClient.js"; +export type { LifecycleClient } from "./openapi/lifecycleClient.js"; +export { createExecdClient } from "./openapi/execdClient.js"; +export type { ExecdClient } from "./openapi/execdClient.js"; + +// OpenAPI schema types (NOT stable public API; internal-only). +export type { paths as LifecyclePaths } from "./api/lifecycle.js"; +export type { paths as ExecdPaths } from "./api/execd.js"; + +export { SandboxesAdapter } from "./adapters/sandboxesAdapter.js"; +export { HealthAdapter } from "./adapters/healthAdapter.js"; +export { MetricsAdapter } from "./adapters/metricsAdapter.js"; +export { FilesystemAdapter } from "./adapters/filesystemAdapter.js"; +export { CommandsAdapter } from "./adapters/commandsAdapter.js"; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/manager.ts b/sdks/sandbox/javascript/src/manager.ts new file mode 100644 index 00000000..061a9158 --- /dev/null +++ b/sdks/sandbox/javascript/src/manager.ts @@ -0,0 +1,98 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ConnectionConfig, type ConnectionConfigOptions } from "./config/connection.js"; +import { createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; +import type { AdapterFactory } from "./factory/adapterFactory.js"; + +import type { ListSandboxesResponse, SandboxId, SandboxInfo } from "./models/sandboxes.js"; +import type { Sandboxes } from "./services/sandboxes.js"; + +export interface SandboxManagerOptions { + connectionConfig?: ConnectionConfig | ConnectionConfigOptions; + adapterFactory?: AdapterFactory; +} + +export interface SandboxFilter { + states?: string[]; + metadata?: Record; + page?: number; + pageSize?: number; +} + +/** + * Administrative interface for managing sandboxes (list/get/pause/resume/kill/renew). + * + * For interacting *inside* a sandbox, use {@link Sandbox}. + */ +export class SandboxManager { + private readonly sandboxes: Sandboxes; + + private constructor(opts: { sandboxes: Sandboxes }) { + this.sandboxes = opts.sandboxes; + } + + static create(opts: SandboxManagerOptions = {}): SandboxManager { + const connectionConfig = opts.connectionConfig instanceof ConnectionConfig + ? opts.connectionConfig + : new ConnectionConfig(opts.connectionConfig); + const lifecycleBaseUrl = connectionConfig.getBaseUrl(); + const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); + const { sandboxes } = adapterFactory.createLifecycleStack({ connectionConfig, lifecycleBaseUrl }); + return new SandboxManager({ sandboxes }); + } + + listSandboxInfos(filter: SandboxFilter = {}): Promise { + return this.sandboxes.listSandboxes({ + states: filter.states, + metadata: filter.metadata, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + getSandboxInfo(sandboxId: SandboxId): Promise { + return this.sandboxes.getSandbox(sandboxId); + } + + killSandbox(sandboxId: SandboxId): Promise { + return this.sandboxes.deleteSandbox(sandboxId); + } + + pauseSandbox(sandboxId: SandboxId): Promise { + return this.sandboxes.pauseSandbox(sandboxId); + } + + resumeSandbox(sandboxId: SandboxId): Promise { + return this.sandboxes.resumeSandbox(sandboxId); + } + + /** + * Renew expiration by setting expiresAt to now + timeoutSeconds. + */ + async renewSandbox(sandboxId: SandboxId, timeoutSeconds: number): Promise { + const expiresAt = new Date(Date.now() + timeoutSeconds * 1000).toISOString(); + await this.sandboxes.renewSandboxExpiration(sandboxId, { expiresAt }); + } + + /** + * No-op for now (fetch-based implementation doesn't own a pooled transport). + * + * This method exists so callers can consistently release resources when using + * a custom {@link AdapterFactory} implementation. + */ + async close(): Promise { + // no-op + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/execd.ts b/sdks/sandbox/javascript/src/models/execd.ts new file mode 100644 index 00000000..413f637f --- /dev/null +++ b/sdks/sandbox/javascript/src/models/execd.ts @@ -0,0 +1,90 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Execution } from "./execution.js"; + +/** + * Domain models for execd interactions. + * + * IMPORTANT: + * - These are NOT OpenAPI-generated types. + * - They are intentionally stable and JS-friendly. + */ +export interface ServerStreamEvent extends Record { + type: + | "init" + | "stdout" + | "stderr" + | "result" + | "execution_count" + | "execution_complete" + | "error" + | string; + timestamp?: number; + text?: string; + results?: Record; + error?: Record; +} + +export interface RunCommandRequest extends Record { + command: string; + cwd?: string; + background?: boolean; +} + +export interface CodeContextRequest extends Record { + language: string; +} + +export type SupportedLanguage = + | "python" + | "go" + | "javascript" + | "typescript" + | "bash" + | "java"; + +export interface RunCommandOpts { + /** + * Working directory for command execution (maps to API `cwd`). + */ + workingDirectory?: string; + /** + * Run command in detached mode. + */ + background?: boolean; +} + +export type CommandExecution = Execution; + +export interface Metrics extends Record { + cpu_count?: number; + cpu_used_pct?: number; + mem_total_mib?: number; + mem_used_mib?: number; + timestamp?: number; +} + +/** + * Normalized, JS-friendly metrics. + */ +export interface SandboxMetrics { + cpuCount: number; + cpuUsedPercentage: number; + memoryTotalMiB: number; + memoryUsedMiB: number; + timestamp: number; +} + +export type PingResponse = Record; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/execution.ts b/sdks/sandbox/javascript/src/models/execution.ts new file mode 100644 index 00000000..29a97f52 --- /dev/null +++ b/sdks/sandbox/javascript/src/models/execution.ts @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface OutputMessage { + text: string; + timestamp: number; + isError?: boolean; +} + +export interface ExecutionResult { + text?: string; + timestamp: number; + /** + * Raw mime map from execd event (e.g. "text/plain", "text/html", ...) + */ + raw?: Record; +} + +export interface ExecutionError { + name: string; + value: string; + timestamp: number; + traceback: string[]; +} + +export interface ExecutionComplete { + timestamp: number; + executionTimeMs: number; +} + +export interface ExecutionInit { + id: string; + timestamp: number; +} + +export interface Execution { + id?: string; + executionCount?: number; + logs: { + stdout: OutputMessage[]; + stderr: OutputMessage[]; + }; + result: ExecutionResult[]; + error?: ExecutionError; + complete?: ExecutionComplete; +} + +export interface ExecutionHandlers { + /** + * Optional low-level hook for every server-sent event (SSE) received. + * Kept as `unknown` to avoid coupling to a specific OpenAPI schema module. + */ + onEvent?: (ev: unknown) => void | Promise; + onStdout?: (msg: OutputMessage) => void | Promise; + onStderr?: (msg: OutputMessage) => void | Promise; + onResult?: (res: ExecutionResult) => void | Promise; + onExecutionComplete?: (c: ExecutionComplete) => void | Promise; + onError?: (err: ExecutionError) => void | Promise; + onInit?: (init: ExecutionInit) => void | Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/executionEventDispatcher.ts b/sdks/sandbox/javascript/src/models/executionEventDispatcher.ts new file mode 100644 index 00000000..303fdcc0 --- /dev/null +++ b/sdks/sandbox/javascript/src/models/executionEventDispatcher.ts @@ -0,0 +1,97 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Execution, ExecutionComplete, ExecutionError, ExecutionHandlers, ExecutionInit, ExecutionResult, OutputMessage } from "./execution.js"; +import type { ServerStreamEvent } from "./execd.js"; + +function extractText(results: ServerStreamEvent["results"] | undefined): string | undefined { + if (!results || typeof results !== "object") return undefined; + const r = results as any; + const v = r["text/plain"] ?? r.text ?? r.textPlain; + return v == null ? undefined : String(v); +} + +/** + * Dispatches streamed execution events to handlers. + * + * This mutates the provided `execution` object (appending logs/results and setting fields like + * `id`, `executionCount`, and `complete`) and invokes optional callbacks in {@link ExecutionHandlers}. + */ +export class ExecutionEventDispatcher { + constructor( + private readonly execution: Execution, + private readonly handlers?: ExecutionHandlers, + ) {} + + async dispatch(ev: ServerStreamEvent): Promise { + await this.handlers?.onEvent?.(ev); + + const ts = ev.timestamp ?? Date.now(); + switch (ev.type) { + case "init": { + const id = ev.text ?? ""; + if (id) this.execution.id = id; + const init: ExecutionInit = { id, timestamp: ts }; + await this.handlers?.onInit?.(init); + return; + } + case "stdout": { + const msg: OutputMessage = { text: ev.text ?? "", timestamp: ts, isError: false }; + this.execution.logs.stdout.push(msg); + await this.handlers?.onStdout?.(msg); + return; + } + case "stderr": { + const msg: OutputMessage = { text: ev.text ?? "", timestamp: ts, isError: true }; + this.execution.logs.stderr.push(msg); + await this.handlers?.onStderr?.(msg); + return; + } + case "result": { + const r: ExecutionResult = { text: extractText(ev.results), timestamp: ts, raw: ev.results as any }; + this.execution.result.push(r); + await this.handlers?.onResult?.(r); + return; + } + case "execution_count": { + const c = (ev as any).execution_count; + if (typeof c === "number") this.execution.executionCount = c; + return; + } + case "execution_complete": { + const ms = (ev as any).execution_time; + const complete: ExecutionComplete = { timestamp: ts, executionTimeMs: typeof ms === "number" ? ms : 0 }; + this.execution.complete = complete; + await this.handlers?.onExecutionComplete?.(complete); + return; + } + case "error": { + const e = ev.error as any; + if (e) { + const err: ExecutionError = { + name: String(e.ename ?? e.name ?? ""), + value: String(e.evalue ?? e.value ?? ""), + timestamp: ts, + traceback: Array.isArray(e.traceback) ? e.traceback.map(String) : [], + }; + this.execution.error = err; + await this.handlers?.onError?.(err); + } + return; + } + default: + return; + } + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/filesystem.ts b/sdks/sandbox/javascript/src/models/filesystem.ts new file mode 100644 index 00000000..945bd323 --- /dev/null +++ b/sdks/sandbox/javascript/src/models/filesystem.ts @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Domain models for filesystem. + * + * IMPORTANT: + * - These are NOT OpenAPI-generated types. + * - They are intentionally stable and JS-friendly. + */ + +export interface FileInfo extends Record { + path: string; + size?: number; + /** + * Last modification time. + */ + modifiedAt?: Date; + /** + * Creation time. + */ + createdAt?: Date; + mode?: number; + owner?: string; + group?: string; +} + +export interface Permission extends Record { + mode: number; + owner?: string; + group?: string; +} + +export interface FileMetadata extends Record { + path: string; + mode?: number; + owner?: string; + group?: string; +} + +export interface RenameFileItem extends Record { + src: string; + dest: string; +} + +export interface ReplaceFileContentItem extends Record { + old: string; + new: string; +} + +export type FilesInfoResponse = Record; + +export type SearchFilesResponse = FileInfo[]; + +// High-level filesystem facade models used by `sandbox.files`. +export interface WriteEntry { + path: string; + /** + * File data to upload. + * + * Supports: + * - string / bytes / Blob (in-memory) + * - AsyncIterable or ReadableStream (streaming upload for large files) + */ + data?: string | Uint8Array | ArrayBuffer | Blob | AsyncIterable | ReadableStream; + mode?: number; + owner?: string; + group?: string; +} + +export interface SearchEntry { + path: string; + pattern?: string; +} + +export interface MoveEntry { + src: string; + dest: string; +} + +export interface ContentReplaceEntry { + path: string; + oldContent: string; + newContent: string; +} + +export interface SetPermissionEntry { + path: string; + mode: number; + owner?: string; + group?: string; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/models/sandboxes.ts b/sdks/sandbox/javascript/src/models/sandboxes.ts new file mode 100644 index 00000000..5e71a7d3 --- /dev/null +++ b/sdks/sandbox/javascript/src/models/sandboxes.ts @@ -0,0 +1,142 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Domain models for sandbox lifecycle. + * + * IMPORTANT: + * - These are NOT OpenAPI-generated types. + * - They are intentionally stable and JS-friendly. + * + * The internal OpenAPI schemas may change frequently; adapters map responses into these models. + */ + +export type SandboxId = string; + +export interface ImageAuth extends Record { + username?: string; + password?: string; + token?: string; +} + +export interface ImageSpec { + uri: string; + auth?: ImageAuth; +} + +export type ResourceLimits = Record; + +export type SandboxState = + | "Creating" + | "Running" + | "Pausing" + | "Paused" + | "Resuming" + | "Deleting" + | "Deleted" + | "Error" + | string; + +export interface SandboxStatus extends Record { + state: SandboxState; + reason?: string; + message?: string; +} + +export interface SandboxInfo extends Record { + id: SandboxId; + image: ImageSpec; + entrypoint: string[]; + metadata?: Record; + status: SandboxStatus; + /** + * Sandbox creation time. + */ + createdAt: Date; + /** + * Sandbox expiration time (server-side TTL). + */ + expiresAt: Date; +} + +export interface CreateSandboxRequest extends Record { + image: ImageSpec; + entrypoint: string[]; + /** + * Timeout in seconds (server semantics). + */ + timeout: number; + resourceLimits: ResourceLimits; + env?: Record; + metadata?: Record; + extensions?: Record; +} + +export interface CreateSandboxResponse extends Record { + id: SandboxId; + status: SandboxStatus; + metadata?: Record; + /** + * Sandbox expiration time after creation. + */ + expiresAt: Date; + /** + * Sandbox creation time. + */ + createdAt: Date; + entrypoint: string[]; +} + +export interface PaginationInfo extends Record { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + hasNextPage: boolean; +} + +export interface ListSandboxesResponse extends Record { + items: SandboxInfo[]; + pagination?: PaginationInfo; +} + +export interface RenewSandboxExpirationRequest { + expiresAt: string; +} + +export interface RenewSandboxExpirationResponse extends Record { + /** + * Updated expiration time (if the server returns it). + */ + expiresAt?: Date; +} + +export interface Endpoint extends Record { + endpoint: string; +} + +export interface ListSandboxesParams { + /** + * Filter by lifecycle state (the API supports multiple `state` query params). + * Example: `{ states: ["Running", "Paused"] }` + */ + states?: string[]; + /** + * Filter by metadata key-value pairs. + * NOTE: This will be encoded to a single `metadata` query parameter as described in the spec. + */ + metadata?: Record; + page?: number; + pageSize?: number; +}; \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/openapi/execdClient.ts b/sdks/sandbox/javascript/src/openapi/execdClient.ts new file mode 100644 index 00000000..91ea7c5c --- /dev/null +++ b/sdks/sandbox/javascript/src/openapi/execdClient.ts @@ -0,0 +1,49 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createClient from "openapi-fetch"; +import type { Client } from "openapi-fetch"; + +import type { paths as ExecdPaths } from "../api/execd.js"; + +export type ExecdClient = Client; + +export interface CreateExecdClientOptions { + /** + * Base URL to the Execd API (no `/v1` prefix). + * Examples: + * - `http://localhost:44772` + * - `http://api.opensandbox.io/sandboxes//port/44772` + */ + baseUrl: string; + /** + * Extra headers applied to every request. + */ + headers?: Record; + /** + * Custom fetch implementation. + * + * Useful for proxies, custom TLS, request tracing, retries, or running in environments + * where a global `fetch` is not available. + */ + fetch?: typeof fetch; +} + +export function createExecdClient(opts: CreateExecdClientOptions): ExecdClient { + return createClient({ + baseUrl: opts.baseUrl, + headers: opts.headers, + fetch: opts.fetch, + }); +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/openapi/lifecycleClient.ts b/sdks/sandbox/javascript/src/openapi/lifecycleClient.ts new file mode 100644 index 00000000..51a54236 --- /dev/null +++ b/sdks/sandbox/javascript/src/openapi/lifecycleClient.ts @@ -0,0 +1,70 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import createClient from "openapi-fetch"; +import type { Client } from "openapi-fetch"; + +import type { paths as LifecyclePaths } from "../api/lifecycle.js"; + +export type LifecycleClient = Client; + +export interface CreateLifecycleClientOptions { + /** + * Base URL to OpenSandbox Lifecycle API, including the `/v1` prefix. + * Example: `http://localhost:8080/v1` + */ + baseUrl?: string; + /** + * API key for `OPEN-SANDBOX-API-KEY` header. + * If omitted, reads from `process.env.OPEN_SANDBOX_API_KEY` when available. + */ + apiKey?: string; + /** + * Extra headers applied to every request. + */ + headers?: Record; + /** + * Custom fetch implementation. + * + * Useful for proxies, custom TLS, request tracing, retries, or running in environments + * where a global `fetch` is not available. + */ + fetch?: typeof fetch; +} + +function readEnvApiKey(): string | undefined { + // Avoid requiring @types/node by not referencing `process` directly. + // In Node, `globalThis.process.env` exists; in browsers it won't. + const env = (globalThis as any)?.process?.env; + const v = env?.OPEN_SANDBOX_API_KEY; + return typeof v === "string" && v.length ? v : undefined; +} + +export function createLifecycleClient(opts: CreateLifecycleClientOptions = {}): LifecycleClient { + const apiKey = opts.apiKey ?? readEnvApiKey(); + + const headers: Record = { + ...(opts.headers ?? {}), + }; + + if (apiKey && !headers["OPEN-SANDBOX-API-KEY"]) { + headers["OPEN-SANDBOX-API-KEY"] = apiKey; + } + + return createClient({ + baseUrl: opts.baseUrl ?? "http://localhost:8080/v1", + headers, + fetch: opts.fetch, + }); +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/sandbox.ts b/sdks/sandbox/javascript/src/sandbox.ts new file mode 100644 index 00000000..0830aabe --- /dev/null +++ b/sdks/sandbox/javascript/src/sandbox.ts @@ -0,0 +1,420 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + DEFAULT_ENTRYPOINT, + DEFAULT_EXECD_PORT, + DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS, + DEFAULT_READY_TIMEOUT_SECONDS, + DEFAULT_RESOURCE_LIMITS, + DEFAULT_TIMEOUT_SECONDS, +} from "./core/constants.js"; +import { ConnectionConfig, type ConnectionConfigOptions } from "./config/connection.js"; +import type { SandboxFiles } from "./services/filesystem.js"; +import { createDefaultAdapterFactory } from "./factory/defaultAdapterFactory.js"; +import type { AdapterFactory } from "./factory/adapterFactory.js"; + +import type { Sandboxes } from "./services/sandboxes.js"; +import type { ExecdCommands } from "./services/execdCommands.js"; +import type { ExecdHealth } from "./services/execdHealth.js"; +import type { ExecdMetrics } from "./services/execdMetrics.js"; +import type { + CreateSandboxRequest, + Endpoint, + RenewSandboxExpirationResponse, + SandboxId, + SandboxInfo, +} from "./models/sandboxes.js"; +import { SandboxReadyTimeoutException } from "./core/exceptions.js"; + +export interface SandboxCreateOptions { + /** + * Connection configuration for calling the OpenSandbox Lifecycle API and the sandbox's execd API. + */ + connectionConfig?: ConnectionConfig | ConnectionConfigOptions; + /** + * Advanced override: inject a custom adapter factory (custom transports, dependency injection). + */ + adapterFactory?: AdapterFactory; + + /** + * Container image uri, e.g. `python:3.11` + */ + image: + | string + | { uri: string; auth?: { username: string; password: string } }; + + entrypoint?: string[]; + env?: Record; + metadata?: Record; + extensions?: Record; + + /** + * Resource limits applied to the sandbox container. + * + * This is forwarded to the Lifecycle API as `resourceLimits`. + */ + resource?: Record; + /** + * Sandbox timeout in seconds. + */ + timeoutSeconds?: number; + + /** + * Skip readiness checks during create/connect. + * + * When true, the SDK will not wait for lifecycle state `Running` or perform the health check. + * The returned sandbox instance may not be ready yet. + */ + skipHealthCheck?: boolean; + /** + * Optional custom readiness check used by {@link Sandbox.waitUntilReady}. + * + * If provided, the SDK will call this function during readiness checks instead of + * using the default `execd` ping check. + */ + healthCheck?: (sbx: Sandbox) => boolean | Promise; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; +} + +export interface SandboxConnectOptions { + connectionConfig?: ConnectionConfig | ConnectionConfigOptions; + adapterFactory?: AdapterFactory; + sandboxId: SandboxId; + + skipHealthCheck?: boolean; + healthCheck?: (sbx: Sandbox) => boolean | Promise; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function toImageSpec( + image: SandboxCreateOptions["image"] +): CreateSandboxRequest["image"] { + if (typeof image === "string") return { uri: image }; + return { uri: image.uri, auth: image.auth }; +} + +export class Sandbox { + readonly id: SandboxId; + readonly connectionConfig: ConnectionConfig; + + /** + * Lifecycle (sandbox management) service. + */ + readonly sandboxes: Sandboxes; + + /** + * Execd services. + */ + readonly commands: ExecdCommands; + /** + * High-level filesystem facade (JS-friendly). + */ + readonly files: SandboxFiles; + readonly health: ExecdHealth; + readonly metrics: ExecdMetrics; + + /** + * Internal state kept out of the public instance shape. + * + * This avoids nominal typing issues when multiple copies of the SDK exist in a dependency graph. + */ + private static readonly _priv = new WeakMap< + Sandbox, + { + adapterFactory: AdapterFactory; + lifecycleBaseUrl: string; + execdBaseUrl: string; + } + >(); + + private constructor(opts: { + id: SandboxId; + connectionConfig: ConnectionConfig; + adapterFactory: AdapterFactory; + lifecycleBaseUrl: string; + execdBaseUrl: string; + sandboxes: Sandboxes; + commands: ExecdCommands; + files: SandboxFiles; + health: ExecdHealth; + metrics: ExecdMetrics; + }) { + this.id = opts.id; + this.connectionConfig = opts.connectionConfig; + Sandbox._priv.set(this, { + adapterFactory: opts.adapterFactory, + lifecycleBaseUrl: opts.lifecycleBaseUrl, + execdBaseUrl: opts.execdBaseUrl, + }); + + this.sandboxes = opts.sandboxes; + this.commands = opts.commands; + this.files = opts.files; + this.health = opts.health; + this.metrics = opts.metrics; + } + + static async create(opts: SandboxCreateOptions): Promise { + const connectionConfig = + opts.connectionConfig instanceof ConnectionConfig + ? opts.connectionConfig + : new ConnectionConfig(opts.connectionConfig); + const lifecycleBaseUrl = connectionConfig.getBaseUrl(); + const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); + const { sandboxes } = adapterFactory.createLifecycleStack({ + connectionConfig, + lifecycleBaseUrl, + }); + + const req: CreateSandboxRequest = { + image: toImageSpec(opts.image), + entrypoint: opts.entrypoint ?? DEFAULT_ENTRYPOINT, + timeout: Math.floor(opts.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS), + resourceLimits: opts.resource ?? DEFAULT_RESOURCE_LIMITS, + env: opts.env ?? {}, + metadata: opts.metadata ?? {}, + extensions: opts.extensions ?? {}, + }; + + let sandboxId: SandboxId | undefined; + try { + const created = await sandboxes.createSandbox(req); + sandboxId = created.id as SandboxId; + + const endpoint = await sandboxes.getSandboxEndpoint( + sandboxId, + DEFAULT_EXECD_PORT + ); + const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`; + + const { commands, files, health, metrics } = + adapterFactory.createExecdStack({ + connectionConfig, + execdBaseUrl, + }); + + const sbx = new Sandbox({ + id: sandboxId, + connectionConfig, + adapterFactory, + lifecycleBaseUrl, + execdBaseUrl, + sandboxes, + commands, + files, + health, + metrics, + }); + + if (!(opts.skipHealthCheck ?? false)) { + await sbx.waitUntilReady({ + readyTimeoutSeconds: + opts.readyTimeoutSeconds ?? DEFAULT_READY_TIMEOUT_SECONDS, + pollingIntervalMillis: + opts.healthCheckPollingInterval ?? + DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS, + healthCheck: opts.healthCheck, + }); + } + + return sbx; + } catch (err) { + if (sandboxId) { + try { + await sandboxes.deleteSandbox(sandboxId); + } catch { + // Ignore cleanup failure; surface original error. + } + } + throw err; + } + } + + static async connect(opts: SandboxConnectOptions): Promise { + const connectionConfig = + opts.connectionConfig instanceof ConnectionConfig + ? opts.connectionConfig + : new ConnectionConfig(opts.connectionConfig); + const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); + const lifecycleBaseUrl = connectionConfig.getBaseUrl(); + const { sandboxes } = adapterFactory.createLifecycleStack({ + connectionConfig, + lifecycleBaseUrl, + }); + + const endpoint = await sandboxes.getSandboxEndpoint( + opts.sandboxId, + DEFAULT_EXECD_PORT + ); + const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`; + const { commands, files, health, metrics } = + adapterFactory.createExecdStack({ + connectionConfig, + execdBaseUrl, + }); + + const sbx = new Sandbox({ + id: opts.sandboxId, + connectionConfig, + adapterFactory, + lifecycleBaseUrl, + execdBaseUrl, + sandboxes, + commands, + files, + health, + metrics, + }); + + if (!(opts.skipHealthCheck ?? false)) { + await sbx.waitUntilReady({ + readyTimeoutSeconds: + opts.readyTimeoutSeconds ?? DEFAULT_READY_TIMEOUT_SECONDS, + pollingIntervalMillis: + opts.healthCheckPollingInterval ?? + DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS, + healthCheck: opts.healthCheck, + }); + } + + return sbx; + } + + async getInfo(): Promise { + return await this.sandboxes.getSandbox(this.id); + } + + async isHealthy(): Promise { + try { + return await this.health.ping(); + } catch { + return false; + } + } + + async getMetrics() { + return await this.metrics.getMetrics(); + } + + async pause(): Promise { + await this.sandboxes.pauseSandbox(this.id); + } + + /** + * Resume a paused sandbox and return a fresh, connected Sandbox instance. + * + * After resume, the execd endpoint may change, so this method returns a new + * {@link Sandbox} instance with a refreshed execd base URL. + */ + async resume( + opts: { + skipHealthCheck?: boolean; + readyTimeoutSeconds?: number; + healthCheckPollingInterval?: number; + } = {} + ): Promise { + await this.sandboxes.resumeSandbox(this.id); + return await Sandbox.connect({ + sandboxId: this.id, + connectionConfig: this.connectionConfig, + adapterFactory: Sandbox._priv.get(this)!.adapterFactory, + skipHealthCheck: opts.skipHealthCheck ?? false, + readyTimeoutSeconds: opts.readyTimeoutSeconds, + healthCheckPollingInterval: opts.healthCheckPollingInterval, + }); + } + + /** + * Resume a paused sandbox by id, then connect to its execd endpoint. + */ + static async resume(opts: SandboxConnectOptions): Promise { + const connectionConfig = + opts.connectionConfig instanceof ConnectionConfig + ? opts.connectionConfig + : new ConnectionConfig(opts.connectionConfig); + const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory(); + const lifecycleBaseUrl = connectionConfig.getBaseUrl(); + const { sandboxes } = adapterFactory.createLifecycleStack({ + connectionConfig, + lifecycleBaseUrl, + }); + await sandboxes.resumeSandbox(opts.sandboxId); + return await Sandbox.connect({ ...opts, connectionConfig, adapterFactory }); + } + + async kill(): Promise { + await this.sandboxes.deleteSandbox(this.id); + } + + /** + * Renew expiration by setting expiresAt to now + timeoutSeconds. + */ + async renew(timeoutSeconds: number): Promise { + const expiresAt = new Date( + Date.now() + timeoutSeconds * 1000 + ).toISOString(); + return await this.sandboxes.renewSandboxExpiration(this.id, { expiresAt }); + } + + /** + * Get sandbox endpoint for a port (STRICT: no scheme), e.g. "localhost:44772" or "domain/route/.../44772". + */ + async getEndpoint(port: number): Promise { + return await this.sandboxes.getSandboxEndpoint(this.id, port); + } + + /** + * Get absolute endpoint URL with scheme (convenience for HTTP clients). + */ + async getEndpointUrl(port: number): Promise { + const ep = await this.getEndpoint(port); + return `${this.connectionConfig.protocol}://${ep.endpoint}`; + } + + async waitUntilReady(opts: { + readyTimeoutSeconds: number; + pollingIntervalMillis: number; + healthCheck?: (sbx: Sandbox) => boolean | Promise; + }): Promise { + const deadline = Date.now() + opts.readyTimeoutSeconds * 1000; + + // Wait until execd becomes reachable and passes health check. + while (true) { + if (Date.now() > deadline) { + throw new SandboxReadyTimeoutException({ + message: `Sandbox not ready: timed out waiting for health check (timeoutSeconds=${opts.readyTimeoutSeconds})`, + }); + } + try { + if (opts.healthCheck) { + const ok = await opts.healthCheck(this); + if (ok) return; + } else { + const ok = await this.health.ping(); + if (ok) return; + } + } catch { + // ignore and retry + } + await sleep(opts.pollingIntervalMillis); + } + } +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/services/execdCommands.ts b/sdks/sandbox/javascript/src/services/execdCommands.ts new file mode 100644 index 00000000..46e81770 --- /dev/null +++ b/sdks/sandbox/javascript/src/services/execdCommands.ts @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ExecutionHandlers } from "../models/execution.js"; +import type { CommandExecution, RunCommandOpts, ServerStreamEvent } from "../models/execd.js"; + +export interface ExecdCommands { + /** + * Run a command and stream server events (SSE). This is the lowest-level API. + */ + runStream(command: string, opts?: RunCommandOpts, signal?: AbortSignal): AsyncIterable; + + /** + * Convenience: run a command, consume the stream, and build a structured execution result. + */ + run(command: string, opts?: RunCommandOpts, handlers?: ExecutionHandlers, signal?: AbortSignal): Promise; + + /** + * Interrupt the current execution in the given context/session. + * + * Note: Execd spec uses `DELETE /command?id=`. + */ + interrupt(sessionId: string): Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/services/execdHealth.ts b/sdks/sandbox/javascript/src/services/execdHealth.ts new file mode 100644 index 00000000..52fc6e16 --- /dev/null +++ b/sdks/sandbox/javascript/src/services/execdHealth.ts @@ -0,0 +1,17 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface ExecdHealth { + ping(): Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/services/execdMetrics.ts b/sdks/sandbox/javascript/src/services/execdMetrics.ts new file mode 100644 index 00000000..b44be450 --- /dev/null +++ b/sdks/sandbox/javascript/src/services/execdMetrics.ts @@ -0,0 +1,19 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { SandboxMetrics } from "../models/execd.js"; + +export interface ExecdMetrics { + getMetrics(): Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/services/filesystem.ts b/sdks/sandbox/javascript/src/services/filesystem.ts new file mode 100644 index 00000000..14a1418c --- /dev/null +++ b/sdks/sandbox/javascript/src/services/filesystem.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { SearchFilesResponse } from "../models/filesystem.js"; +import type { + ContentReplaceEntry, + FileInfo, + MoveEntry, + SearchEntry, + SetPermissionEntry, + WriteEntry, +} from "../models/filesystem.js"; + +/** + * High-level filesystem facade (JS-friendly). + * + * This interface provides a convenience layer over the underlying execd filesystem API: + * it offers common operations (read/write/search/move/delete) and supports streaming I/O for large files. + */ +export interface SandboxFiles { + getFileInfo(paths: string[]): Promise>; + search(entry: SearchEntry): Promise; + + createDirectories(entries: Pick[]): Promise; + deleteDirectories(paths: string[]): Promise; + + writeFiles(entries: WriteEntry[]): Promise; + readFile(path: string, opts?: { encoding?: string; range?: string }): Promise; + readBytes(path: string, opts?: { range?: string }): Promise; + readBytesStream(path: string, opts?: { range?: string }): AsyncIterable; + + deleteFiles(paths: string[]): Promise; + moveFiles(entries: MoveEntry[]): Promise; + replaceContents(entries: ContentReplaceEntry[]): Promise; + setPermissions(entries: SetPermissionEntry[]): Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/src/services/sandboxes.ts b/sdks/sandbox/javascript/src/services/sandboxes.ts new file mode 100644 index 00000000..dfd1c699 --- /dev/null +++ b/sdks/sandbox/javascript/src/services/sandboxes.ts @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { + CreateSandboxRequest, + CreateSandboxResponse, + Endpoint, + ListSandboxesParams, + ListSandboxesResponse, + RenewSandboxExpirationRequest, + RenewSandboxExpirationResponse, + SandboxId, + SandboxInfo, +} from "../models/sandboxes.js"; + +export interface Sandboxes { + createSandbox(req: CreateSandboxRequest): Promise; + getSandbox(sandboxId: SandboxId): Promise; + listSandboxes(params?: ListSandboxesParams): Promise; + deleteSandbox(sandboxId: SandboxId): Promise; + + pauseSandbox(sandboxId: SandboxId): Promise; + resumeSandbox(sandboxId: SandboxId): Promise; + + renewSandboxExpiration( + sandboxId: SandboxId, + req: RenewSandboxExpirationRequest, + ): Promise; + + getSandboxEndpoint(sandboxId: SandboxId, port: number): Promise; +} \ No newline at end of file diff --git a/sdks/sandbox/javascript/tsconfig.json b/sdks/sandbox/javascript/tsconfig.json new file mode 100644 index 00000000..ac50168f --- /dev/null +++ b/sdks/sandbox/javascript/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt index 76cc7484..209222c4 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt @@ -66,11 +66,16 @@ class ConnectionConfig private constructor( fun getBaseUrl(): String { val currentDomain = getDomain() - // Allow domain to override protocol if it explicitly starts with a scheme + // Python semantics: + // - If `domain` includes a scheme, treat it as a full base URL (without `/v1`) and append `/v1`. + // - If `domain` does not include a scheme, build `protocol://domain/v1`. + // Also normalize trailing slashes and avoid duplicating `/v1`. if (currentDomain.startsWith("http://") || currentDomain.startsWith("https://")) { - return currentDomain + val trimmed = currentDomain.removeSuffix("/") + return if (trimmed.endsWith("/$API_VERSION")) trimmed else "$trimmed/$API_VERSION" } - return "$protocol://$currentDomain/$API_VERSION" + val trimmed = currentDomain.removeSuffix("/") + return if (trimmed.endsWith("/$API_VERSION")) "$protocol://${trimmed.removeSuffix("/$API_VERSION")}/$API_VERSION" else "$protocol://$trimmed/$API_VERSION" } /** @@ -91,8 +96,8 @@ class ConnectionConfig private constructor( * and will **not** be evicted by the SDK on close. * * ### Notes - * - `domain` may include a scheme (e.g. `https://example.com`); in that case the SDK will use it - * as-is and ignore [protocol] when constructing the base URL. + * - `domain` may include a scheme (e.g. `https://example.com`); in that case the SDK will ignore [protocol] + * and append `/$API_VERSION` automatically when constructing the base URL. */ class Builder internal constructor() { private var apiKey: String? = null diff --git a/sdks/tsconfig.base.json b/sdks/tsconfig.base.json new file mode 100644 index 00000000..3d15bd73 --- /dev/null +++ b/sdks/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + + "declaration": true, + "declarationMap": true, + "removeComments": false, + + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/server/example.batchsandbox-template.yaml b/server/example.batchsandbox-template.yaml index 49deb011..d207d9c0 100644 --- a/server/example.batchsandbox-template.yaml +++ b/server/example.batchsandbox-template.yaml @@ -7,10 +7,6 @@ # Metadata template (will be merged with runtime-generated metadata) metadata: - annotations: - template-source: "batchsandbox-template.yaml" - managed-by: "opensandbox" - # Spec template spec: replicas: 1 diff --git a/server/src/services/constants.py b/server/src/services/constants.py index 8036459e..afb9c0f3 100644 --- a/server/src/services/constants.py +++ b/server/src/services/constants.py @@ -55,6 +55,7 @@ class SandboxErrorCodes: UNKNOWN_ERROR = "SANDBOX::UNKNOWN_ERROR" API_NOT_SUPPORTED = "SANDBOX::API_NOT_SUPPORTED" INVALID_METADATA_LABEL = "SANDBOX::INVALID_METADATA_LABEL" + INVALID_PARAMETER = "SANDBOX::INVALID_PARAMETER" __all__ = [ diff --git a/server/src/services/k8s/batchsandbox_provider.py b/server/src/services/k8s/batchsandbox_provider.py index 81d056a1..4d73e7cc 100644 --- a/server/src/services/k8s/batchsandbox_provider.py +++ b/server/src/services/k8s/batchsandbox_provider.py @@ -17,6 +17,7 @@ """ import logging +import shlex from datetime import datetime from typing import Dict, List, Any, Optional @@ -75,9 +76,47 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: - """Create a BatchSandbox workload.""" + """ + Create a BatchSandbox workload. + + Supports both template-based and pool-based creation: + - Template mode (default): Creates workload with user-specified image, resources, and env + - Pool mode (when extensions contains 'poolRef'): Creates workload from pre-warmed pool, + only entrypoint and env can be customized + + Args: + sandbox_id: Unique sandbox identifier + namespace: Kubernetes namespace + image_spec: Container image specification (not used in pool mode) + entrypoint: Container entrypoint command + env: Environment variables + resource_limits: Resource limits (not used in pool mode) + labels: Labels to apply + expires_at: Expiration time + execd_image: execd daemon image (not used in pool mode) + extensions: General extension field for additional configuration. + When contains 'poolRef', enables pool-based creation. + + Returns: + Dict with 'name' and 'uid' of created BatchSandbox + """ batchsandbox_name = f"sandbox-{sandbox_id}" + extensions = extensions or {} + + # If poolRef is provided and not empty, create workload from pool + if extensions.get("poolRef"): + # When using pool, only entrypoint and env can be customized + return self._create_workload_from_pool( + batchsandbox_name=batchsandbox_name, + namespace=namespace, + labels=labels, + pool_ref=extensions["poolRef"], + expires_at=expires_at, + entrypoint=entrypoint, + env=env, + ) # Build init container for execd installation init_container = self._build_execd_init_container(execd_image) @@ -138,27 +177,136 @@ def create_workload( "uid": created["metadata"]["uid"], } + def _create_workload_from_pool( + self, + batchsandbox_name: str, + namespace: str, + labels: Dict[str, str], + pool_ref: str, + expires_at: datetime, + entrypoint: List[str], + env: Dict[str, str], + ) -> Dict[str, Any]: + """ + Create BatchSandbox workload from a pre-warmed resource pool. + + Pool-based creation uses poolRef to reference an existing pool. + The pool already defines the pod template, so no additional template is needed. + Only entrypoint and env can be customized. + + Args: + batchsandbox_name: Name of the BatchSandbox resource + namespace: Kubernetes namespace + labels: Labels to apply + pool_ref: Reference to the resource pool + expires_at: Expiration time + entrypoint: Container entrypoint command (can be customized) + env: Environment variables (can be customized) + + Returns: + Dict with 'name' and 'uid' of created BatchSandbox + + Raises: + SandboxError: If required parameters are invalid + """ + runtime_manifest = { + "apiVersion": f"{self.group}/{self.version}", + "kind": "BatchSandbox", + "metadata": { + "name": batchsandbox_name, + "namespace": namespace, + "labels": labels, + }, + "spec": { + "replicas": 1, + "poolRef": pool_ref, + "expireTime": expires_at.isoformat(), + "taskTemplate": self._build_task_template(entrypoint, env), + }, + } + + # Pool-based creation does not need template merging + # Create BatchSandbox directly + created = self.custom_api.create_namespaced_custom_object( + group=self.group, + version=self.version, + namespace=namespace, + plural=self.plural, + body=runtime_manifest, + ) + + return { + "name": created["metadata"]["name"], + "uid": created["metadata"]["uid"], + } + + # Todo support empty cmd or env + def _build_task_template( + self, + entrypoint: List[str], + env: Dict[str, str], + ) -> Dict[str, Any]: + """ + Build taskTemplate for pool-based BatchSandbox. + + In pool mode, task should use bootstrap.sh to start execd and business process. + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh python app.py &" + + Note: All entrypoint arguments are properly shell-escaped using shlex.quote + to prevent shell injection and preserve arguments with spaces or special characters. + + Args: + entrypoint: Container entrypoint command + env: Environment variables + + Returns: + Dict: taskTemplate specification with TaskSpec structure + """ + # Build command: execute bootstrap.sh with entrypoint in background + # Use shlex.quote to safely escape each entrypoint argument to prevent shell injection + escaped_entrypoint = ' '.join(shlex.quote(arg) for arg in entrypoint) + user_process_cmd = f"/opt/opensandbox/bin/bootstrap.sh {escaped_entrypoint} &" + + wrapped_command = ["/bin/sh", "-c", user_process_cmd] + + # Convert env dict to k8s EnvVar format + env_list = [{"name": k, "value": v} for k, v in env.items()] if env else [] + + # Return TaskTemplateSpec structure + return { + "spec": { + "process": { + "command": wrapped_command, + "env": env_list, + } + } + } + def _build_execd_init_container(self, execd_image: str) -> V1Container: """ Build init container for execd installation. + This init container copies execd binary and bootstrap.sh script from + execd image to shared volume, making them available to the main container. + + The bootstrap.sh script (from execd image) will: + - Start execd in background (redirects logs to /tmp/execd.log) + - Use exec to replace current process with user's command + Args: execd_image: execd container image Returns: V1Container: Init container spec """ - # Build the script with proper shell syntax + # Copy execd binary and bootstrap.sh from image to shared volume script = ( - "cp ./execd /opt/opensandbox/execd/execd && " - "chmod +x /opt/opensandbox/execd/execd && " - "cat > /opt/opensandbox/execd/bootstrap.sh << 'BOOTSTRAP_EOF'\n" - "#!/bin/sh\n" - "set -e\n" - "/opt/opensandbox/execd/execd >/tmp/execd.log 2>&1 &\n" - 'exec "$@"\n' - "BOOTSTRAP_EOF\n" - "chmod +x /opt/opensandbox/execd/bootstrap.sh" + "cp ./execd /opt/opensandbox/bin/execd && " + "cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && " + "chmod +x /opt/opensandbox/bin/execd && " + "chmod +x /opt/opensandbox/bin/bootstrap.sh" ) return V1Container( @@ -169,7 +317,7 @@ def _build_execd_init_container(self, execd_image: str) -> V1Container: volume_mounts=[ V1VolumeMount( name="opensandbox-bin", - mount_path="/opt/opensandbox/execd" + mount_path="/opt/opensandbox/bin" ) ], ) @@ -196,8 +344,10 @@ def _build_main_container( Returns: V1Container: Main container spec """ - # Convert env dict to V1EnvVar list + # Convert env dict to V1EnvVar list and inject EXECD path env_vars = [V1EnvVar(name=k, value=v) for k, v in env.items()] + # Add EXECD environment variable to specify execd binary path + env_vars.append(V1EnvVar(name="EXECD", value="/opt/opensandbox/bin/execd")) # Build resource requirements resources = None @@ -208,7 +358,7 @@ def _build_main_container( ) # Wrap entrypoint with bootstrap script to start execd - wrapped_command = ["/opt/opensandbox/execd/bootstrap.sh"] + entrypoint + wrapped_command = ["/opt/opensandbox/bin/bootstrap.sh"] + entrypoint return V1Container( name="sandbox", @@ -219,7 +369,7 @@ def _build_main_container( volume_mounts=[ V1VolumeMount( name="opensandbox-bin", - mount_path="/opt/opensandbox/execd" + mount_path="/opt/opensandbox/bin" ) ], ) diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index 725e13ab..ca8fd443 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -226,13 +226,13 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse Wait for the Pod to be Running and have an IP address before returning. Args: - request: Sandbox creation request + request: Sandbox creation request. Returns: CreateSandboxResponse: Created sandbox information with Running state Raises: - HTTPException: If creation fails or timeout + HTTPException: If creation fails, timeout, or invalid parameters """ # Validate request ensure_entrypoint(request.entrypoint) @@ -271,6 +271,7 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse labels=labels, expires_at=expires_at, execd_image=self.execd_image, + extensions=request.extensions, ) logger.info( @@ -317,6 +318,16 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse except HTTPException: raise + except ValueError as e: + # Handle parameter validation errors from provider + logger.error(f"Invalid parameters for sandbox creation: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_PARAMETER, + "message": str(e), + }, + ) from e except Exception as e: logger.error(f"Error creating sandbox: {e}") raise HTTPException( diff --git a/server/src/services/k8s/workload_provider.py b/server/src/services/k8s/workload_provider.py index 44debb99..146135bf 100644 --- a/server/src/services/k8s/workload_provider.py +++ b/server/src/services/k8s/workload_provider.py @@ -43,6 +43,7 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """ Create a new workload resource. @@ -57,6 +58,8 @@ def create_workload( labels: Labels to apply to the workload expires_at: Expiration time execd_image: execd daemon image + extensions: General extension field for passing additional configuration. + This is a flexible field for various use cases (e.g., ``poolRef`` for pool-based creation). Returns: Dict containing workload metadata (name, uid, etc.) diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index dbb020ae..90cc1e2a 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -163,14 +163,15 @@ def test_create_workload_wraps_entrypoint_with_bootstrap(self, mock_k8s_client): main_container = body["spec"]["template"]["spec"]["containers"][0] assert main_container["command"] == [ - "/opt/opensandbox/execd/bootstrap.sh", + "/opt/opensandbox/bin/bootstrap.sh", "/usr/bin/python", "app.py" ] def test_create_workload_converts_env_to_list(self, mock_k8s_client): """ - Test case: Verify environment variable dict converted to list + Test case: Verify environment variable dict converted to list. + Also verifies EXECD environment variable is automatically injected. """ provider = BatchSandboxProvider(mock_k8s_client) mock_api = mock_k8s_client.get_custom_objects_api() @@ -193,9 +194,13 @@ def test_create_workload_converts_env_to_list(self, mock_k8s_client): body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] env_vars = body["spec"]["template"]["spec"]["containers"][0]["env"] - assert len(env_vars) == 2 + # Should have user env vars plus EXECD + assert len(env_vars) == 3 env_dict = {e["name"]: e["value"] for e in env_vars} - assert env_dict == {"FOO": "bar", "BAZ": "qux"} + assert env_dict["FOO"] == "bar" + assert env_dict["BAZ"] == "qux" + # Verify EXECD is automatically injected + assert env_dict["EXECD"] == "/opt/opensandbox/bin/execd" def test_create_workload_sets_resource_limits_and_requests(self, mock_k8s_client): """ @@ -622,3 +627,282 @@ def test_get_endpoint_info_returns_none_on_empty_array(self): result = provider.get_endpoint_info(workload, 8080) assert result is None + + # ===== Pool-based Creation Tests ===== + + def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client): + """ + Test that pool-based creation ignores image_spec parameter. + + Pool already defines the image, so image_spec is not used even if provided. + This verifies backward compatibility - no error is raised. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri="python:3.11"), + entrypoint=["python", "app.py"], + env={}, + resource_limits={}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + # Should succeed and return workload info + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify poolRef is used + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + + def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client): + """ + Test that pool-based creation ignores resource_limits parameter. + + Pool already defines the resources, so resource_limits is not used even if provided. + This verifies backward compatibility - no error is raised. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={}, + resource_limits={"cpu": "1", "memory": "1Gi"}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + # Should succeed and return workload info + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify poolRef is used + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + + def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client): + """ + Test that pool-based creation allows customizing entrypoint and env. + + Verifies taskTemplate structure is correctly generated with user's entrypoint and env. + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + result = provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={"FOO": "bar"}, + resource_limits={}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + extensions={"poolRef": "my-pool"} + ) + + assert result == {"name": "sandbox-test-id", "uid": "test-uid"} + + # Verify the call + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + assert body["spec"]["poolRef"] == "my-pool" + assert "taskTemplate" in body["spec"] + + # Verify taskTemplate structure + task_template = body["spec"]["taskTemplate"] + assert "spec" in task_template + assert "process" in task_template["spec"] + command = task_template["spec"]["process"]["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Command should contain bootstrap.sh execution + # Example: /opt/opensandbox/bin/bootstrap.sh python app.py & + assert "/opt/opensandbox/bin/bootstrap.sh python app.py" in command[2] + assert command[2].endswith(" &") + assert task_template["spec"]["process"]["env"] == [{"name": "FOO", "value": "bar"}] + + def test_build_task_template_with_env(self, mock_k8s_client): + """ + Test _build_task_template with environment variables. + + Verifies: + - Command uses shell wrapper: /bin/sh -c "..." + - Entrypoint executed via bootstrap.sh in background (&) + - Env list formatted correctly for K8s + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &" + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["/usr/bin/python", "app.py"], + env={"KEY1": "value1", "KEY2": "value2"} + ) + + assert "spec" in result + assert "process" in result["spec"] + process_task = result["spec"]["process"] + + # Verify command structure + command = process_task["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Should execute via bootstrap.sh in background (&) + assert "/opt/opensandbox/bin/bootstrap.sh" in command[2] + assert "/usr/bin/python" in command[2] + assert "app.py" in command[2] + # Should end with & (run in background) + assert command[2].endswith("&") + + # Verify env list + assert process_task["env"] == [ + {"name": "KEY1", "value": "value1"}, + {"name": "KEY2", "value": "value2"} + ] + + def test_build_task_template_without_env(self, mock_k8s_client): + """ + Test _build_task_template without environment variables. + + Verifies command is wrapped in shell and executes via bootstrap.sh in background. + + Generated command example: + /bin/sh -c "/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &" + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["/usr/bin/python", "app.py"], + env={} + ) + + assert "spec" in result + assert "process" in result["spec"] + process_task = result["spec"]["process"] + assert process_task["env"] == [] + # Without env, command directly calls bootstrap.sh in background + command = process_task["command"] + assert command[0] == "/bin/sh" + assert command[1] == "-c" + # Check escaped entrypoint + assert "/opt/opensandbox/bin/bootstrap.sh" in command[2] + assert "/usr/bin/python" in command[2] + assert "app.py" in command[2] + assert command[2].endswith(" &") + + def test_build_task_template_uses_default_env_path(self, mock_k8s_client): + """ + Test that taskTemplate executes bootstrap.sh properly. + + Verifies: + - Entrypoint is properly escaped + - Command runs in background + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["python", "app.py"], + env={"TEST_VAR": "test_value"} + ) + + command = result["spec"]["process"]["command"][2] + # Should execute bootstrap.sh in background + assert "/opt/opensandbox/bin/bootstrap.sh" in command + assert "python" in command + assert "app.py" in command + assert command.endswith(" &") + + def test_build_task_template_escapes_special_characters(self, mock_k8s_client): + """ + Test that taskTemplate properly escapes arguments with spaces, quotes, and special chars. + + This prevents shell injection and ensures arguments are preserved correctly. + For example: ['python', '-c', 'print("a b")'] should work correctly. + """ + provider = BatchSandboxProvider(mock_k8s_client) + + result = provider._build_task_template( + entrypoint=["python", "-c", 'print("hello world")'], + env={"KEY": "value with spaces", "QUOTE": "it's fine"} + ) + + command = result["spec"]["process"]["command"][2] + + # Verify entrypoint args are properly escaped + assert "python" in command + assert "-c" in command + # The python code with spaces and quotes should be properly escaped + assert "'print(" in command or '"print(' in command # Escaped + + # Verify env is passed through env list, not in command + env_list = result["spec"]["process"]["env"] + assert {"name": "KEY", "value": "value with spaces"} in env_list + assert {"name": "QUOTE", "value": "it's fine"} in env_list + + def test_create_workload_poolref_builds_correct_manifest(self, mock_k8s_client): + """ + Test complete pool-based BatchSandbox manifest structure. + + Verifies: + - Basic metadata (apiVersion, kind, name, labels) + - Pool-specific fields (poolRef, taskTemplate, expireTime) + - No template field (pool mode doesn't use pod template) + """ + provider = BatchSandboxProvider(mock_k8s_client) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test-id", "uid": "test-uid"} + } + + expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc) + + provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri=""), + entrypoint=["python", "app.py"], + env={"FOO": "bar"}, + resource_limits={}, + labels={"test": "label"}, + expires_at=expires_at, + execd_image="execd:latest", + extensions={"poolRef": "test-pool"} + ) + + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + + # Verify basic structure + assert body["apiVersion"] == "sandbox.opensandbox.io/v1alpha1" + assert body["kind"] == "BatchSandbox" + assert body["metadata"]["name"] == "sandbox-test-id" + assert body["metadata"]["labels"] == {"test": "label"} + + # Verify pool-specific fields + assert body["spec"]["replicas"] == 1 + assert body["spec"]["poolRef"] == "test-pool" + assert body["spec"]["expireTime"] == "2025-12-31T10:00:00+00:00" + assert "taskTemplate" in body["spec"] + + # Verify no template field (pool-based doesn't use template) + assert "template" not in body["spec"] diff --git a/tests/javascript/README.md b/tests/javascript/README.md new file mode 100644 index 00000000..e776cd34 --- /dev/null +++ b/tests/javascript/README.md @@ -0,0 +1,40 @@ +# OpenSandbox JavaScript E2E Tests + +This folder contains strict E2E tests for the JavaScript/TypeScript SDKs, aligned with `OpenSandbox/tests/python` and `OpenSandbox/tests/java`. + +## Prerequisites + +- Node.js (via nvm): **>= 20** +- pnpm (via corepack or global install) +- OpenSandbox server running + +## Environment variables + +These tests follow the same naming as Python tests: + +- `OPENSANDBOX_TEST_DOMAIN` (default: `localhost:8080`) +- `OPENSANDBOX_TEST_PROTOCOL` (default: `http`) +- `OPENSANDBOX_TEST_API_KEY` (default: `e2e-test`) +- `OPENSANDBOX_SANDBOX_DEFAULT_IMAGE` (default: code-interpreter image) + +## Run + +```bash +cd OpenSandbox/tests/javascript + +# Node >= 20 is required (SDK engines: node >= 20) +source ~/.nvm/nvm.sh +nvm use 22 + +# Ensure pnpm is available (repo pins pnpm@9.x) +corepack enable +corepack prepare pnpm@9.15.0 --activate + +# Install test dependencies (vitest, typescript) +pnpm install + +# Run tests (also builds SDKs) +pnpm test +``` + + diff --git a/tests/javascript/eslint.config.mjs b/tests/javascript/eslint.config.mjs new file mode 100644 index 00000000..9c50c1f7 --- /dev/null +++ b/tests/javascript/eslint.config.mjs @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["node_modules/**", "build/**", "**/*.d.ts"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parserOptions: { + // Keep tests lint lightweight: do not require type-aware linting. + // This avoids needing to include tool configs (e.g. vitest.config.ts) in tsconfig. + }, + globals: { + console: "readonly", + process: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, +); + diff --git a/tests/javascript/package.json b/tests/javascript/package.json new file mode 100644 index 00000000..964537ea --- /dev/null +++ b/tests/javascript/package.json @@ -0,0 +1,27 @@ +{ + "name": "opensandbox-javascript-e2e-tests", + "version": "1.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@9.15.0", + "scripts": { + "pretest": "pnpm install --prefer-offline", + "prep:sdk": "pnpm -C ../../sdks install --prefer-offline && pnpm -C ../../sdks run build:js", + "lint": "eslint . --max-warnings 0", + "test": "pnpm run prep:sdk && pnpm exec vitest run", + "pretest:ci": "pnpm install --prefer-offline", + "test:ci": "pnpm run prep:sdk && pnpm exec vitest run --reporter=default --reporter=junit --outputFile=build/test-results/junit.xml" + }, + "dependencies": { + "@alibaba-group/opensandbox": "link:../../sdks/sandbox/javascript", + "@alibaba-group/opensandbox-code-interpreter": "link:../../sdks/code-interpreter/javascript" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^20.11.30", + "eslint": "^9.39.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.52.0", + "vitest": "^2.1.9" + } +} diff --git a/tests/javascript/pnpm-lock.yaml b/tests/javascript/pnpm-lock.yaml new file mode 100644 index 00000000..cc0858da --- /dev/null +++ b/tests/javascript/pnpm-lock.yaml @@ -0,0 +1,1783 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@alibaba-group/opensandbox': + specifier: link:../../sdks/sandbox/javascript + version: link:../../sdks/sandbox/javascript + '@alibaba-group/opensandbox-code-interpreter': + specifier: link:../../sdks/code-interpreter/javascript + version: link:../../sdks/code-interpreter/javascript + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + '@types/node': + specifier: ^20.11.30 + version: 20.19.27 + eslint: + specifier: ^9.39.2 + version: 9.39.2 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.52.0 + version: 8.52.0(eslint@9.39.2)(typescript@5.9.3) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@20.19.27) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + 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.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + 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.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + 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} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + 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'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + + '@typescript-eslint/eslint-plugin@8.52.0': + resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.52.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.52.0': + resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.52.0': + resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.52.0': + resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.52.0': + resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.52.0': + resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.52.0': + resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.52.0': + resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.52.0': + resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.52.0': + resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + 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==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + 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'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + 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==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + 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@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + 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.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + 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 + + 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.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + 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'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + 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==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + 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.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + 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'} + + 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'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + 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.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + 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'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + 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.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + 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@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.52.0: + resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + 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 + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': + dependencies: + eslint: 9.39.2 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + 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.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.55.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.55.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.55.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 + eslint: 9.39.2 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 + debug: 4.4.3 + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.52.0': + dependencies: + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 + + '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.52.0': {} + + '@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.52.0(eslint@9.39.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.52.0': + dependencies: + '@typescript-eslint/types': 8.52.0 + eslint-visitor-keys: 4.2.1 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.27))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.27) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + 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-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + 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 + + check-error@2.1.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + 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@9.39.2: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + 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.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.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.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + 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.3): + optionalDependencies: + picomatch: 4.0.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.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + 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.merge@4.6.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + 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 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + rollup@4.55.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 + fsevents: 2.3.3 + + semver@7.7.3: {} + + 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: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.52.0(eslint@9.39.2)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2)(typescript@5.9.3) + eslint: 9.39.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@2.1.9(@types/node@20.19.27): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.27) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.27): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.55.1 + optionalDependencies: + '@types/node': 20.19.27 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@20.19.27): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.27) + vite-node: 2.1.9(@types/node@20.19.27) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.27 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + 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 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/tests/javascript/tests/base_e2e.ts b/tests/javascript/tests/base_e2e.ts new file mode 100644 index 00000000..7edee95d --- /dev/null +++ b/tests/javascript/tests/base_e2e.ts @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ConnectionConfig } from "@alibaba-group/opensandbox"; + +export const DEFAULT_DOMAIN = "localhost:8080"; +export const DEFAULT_PROTOCOL = "http"; +export const DEFAULT_API_KEY = "e2e-test"; +export const DEFAULT_IMAGE = + "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest"; + +export const TEST_DOMAIN = process.env.OPENSANDBOX_TEST_DOMAIN ?? DEFAULT_DOMAIN; +export const TEST_PROTOCOL = process.env.OPENSANDBOX_TEST_PROTOCOL ?? DEFAULT_PROTOCOL; +export const TEST_API_KEY = process.env.OPENSANDBOX_TEST_API_KEY ?? DEFAULT_API_KEY; +export const TEST_IMAGE = process.env.OPENSANDBOX_SANDBOX_DEFAULT_IMAGE ?? DEFAULT_IMAGE; + +export function getSandboxImage(): string { + return TEST_IMAGE; +} + +export function createConnectionConfig(): ConnectionConfig { + return new ConnectionConfig({ + domain: TEST_DOMAIN, + protocol: TEST_PROTOCOL === "https" ? "https" : "http", + apiKey: TEST_API_KEY, + }); +} + +export function nowMs(): number { + return Date.now(); +} + +export function assertRecentTimestampMs(ts: number, toleranceMs = 180_000): void { + if (typeof ts !== "number" || ts <= 0) throw new Error(`invalid timestamp: ${ts}`); + const delta = Math.abs(nowMs() - ts); + if (delta > toleranceMs) { + throw new Error(`timestamp too far from now: delta=${delta}ms (ts=${ts})`); + } +} + +export function assertEndpointHasPort(endpoint: string, expectedPort: number): void { + if (!endpoint) throw new Error("endpoint is empty"); + if (endpoint.includes("://")) throw new Error(`unexpected scheme in endpoint: ${endpoint}`); + + if (endpoint.includes("/")) { + if (!endpoint.endsWith(`/${expectedPort}`)) { + throw new Error(`endpoint route must end with /${expectedPort}: ${endpoint}`); + } + const domain = endpoint.split("/", 1)[0]; + if (!domain) throw new Error(`missing domain in endpoint: ${endpoint}`); + return; + } + + const idx = endpoint.lastIndexOf(":"); + if (idx < 0) throw new Error(`missing :port in endpoint: ${endpoint}`); + const host = endpoint.slice(0, idx); + const port = endpoint.slice(idx + 1); + if (!host) throw new Error(`missing host in endpoint: ${endpoint}`); + if (!/^\d+$/.test(port)) throw new Error(`non-numeric port in endpoint: ${endpoint}`); + if (Number(port) !== expectedPort) throw new Error(`endpoint port mismatch: ${endpoint} != :${expectedPort}`); +} \ No newline at end of file diff --git a/tests/javascript/tests/test_code_interpreter_e2e.test.ts b/tests/javascript/tests/test_code_interpreter_e2e.test.ts new file mode 100644 index 00000000..686fc407 --- /dev/null +++ b/tests/javascript/tests/test_code_interpreter_e2e.test.ts @@ -0,0 +1,269 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { Sandbox, type ExecutionHandlers } from "@alibaba-group/opensandbox"; + +import { + CodeInterpreter, + SupportedLanguages, +} from "@alibaba-group/opensandbox-code-interpreter"; + +import { + assertEndpointHasPort, + assertRecentTimestampMs, + createConnectionConfig, + getSandboxImage, +} from "./base_e2e.ts"; + +let sandbox: Sandbox | null = null; +let ci: CodeInterpreter | null = null; + +beforeAll(async () => { + const connectionConfig = createConnectionConfig(); + + sandbox = await Sandbox.create({ + connectionConfig, + image: getSandboxImage(), + entrypoint: ["/opt/opensandbox/code-interpreter.sh"], + timeoutSeconds: 15 * 60, + readyTimeoutSeconds: 60, + metadata: { tag: "e2e-code-interpreter" }, + env: { + E2E_TEST: "true", + GO_VERSION: "1.25", + JAVA_VERSION: "21", + NODE_VERSION: "22", + PYTHON_VERSION: "3.12", + }, + healthCheckPollingInterval: 200, + }); + + ci = await CodeInterpreter.create(sandbox); +}, 10 * 60_000); + +afterAll(async () => { + if (!sandbox) return; + try { + await sandbox.kill(); + } catch { + // ignore + } +}, 5 * 60_000); + +test("01 creation and basic functionality", async () => { + if (!sandbox || !ci) throw new Error("not initialized"); + + expect(ci.id).toBe(sandbox.id); + expect(await sandbox.isHealthy()).toBe(true); + + const info = await sandbox.getInfo(); + expect(info.status.state).toBe("Running"); + + const ep = await sandbox.getEndpoint(44772); + assertEndpointHasPort(ep.endpoint, 44772); + + const metrics = await sandbox.getMetrics(); + assertRecentTimestampMs(metrics.timestamp); +}); + +test("01b context management: get/list/delete/deleteContexts", async () => { + if (!ci) throw new Error("not initialized"); + + const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + expect(ctx.id).toBeTruthy(); + expect(ctx.language).toBe("python"); + + const got = await ci.codes.getContext(ctx.id!); + expect(got.id).toBe(ctx.id); + expect(got.language).toBe("python"); + + const all = await ci.codes.listContexts(); + expect(all.some((c) => c.id === ctx.id)).toBe(true); + + const pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON); + expect(pyOnly.some((c) => c.id === ctx.id)).toBe(true); + + await ci.codes.deleteContext(ctx.id!); + await expect(ci.codes.getContext(ctx.id!)).rejects.toBeTruthy(); + + // Bulk cleanup should not throw. + await ci.codes.deleteContexts(SupportedLanguages.PYTHON); +}); + +test("02 java code execution", async () => { + if (!ci) throw new Error("not initialized"); + + const javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA); + expect(javaCtx.id).toBeTruthy(); + expect(javaCtx.language).toBe("java"); + + const stdout: string[] = []; + const errors: string[] = []; + const initIds: string[] = []; + + const handlers: ExecutionHandlers = { + onStdout: (m) => { + stdout.push(m.text); + }, + onError: (e) => { + errors.push(e.name); + }, + onInit: (i) => { + initIds.push(i.id); + }, + }; + + const r = await ci.codes.run( + 'System.out.println("Hello from Java!");\nint result = 2 + 2;\nSystem.out.println("2 + 2 = " + result);\nresult', + { context: javaCtx, handlers } + ); + expect(r.id).toBeTruthy(); + expect(r.error).toBeUndefined(); + expect(r.result[0]?.text).toBe("4"); + expect(initIds).toHaveLength(1); + expect(errors).toHaveLength(0); + expect(stdout.some((s) => s.includes("Hello from Java!"))).toBe(true); + + const err = await ci.codes.run("int x = 10 / 0; // ArithmeticException", { + context: javaCtx, + }); + expect(err.error).toBeTruthy(); + expect(err.error?.name).toBe("EvalException"); +}); + +test("03 python code execution + direct language + persistence", async () => { + if (!ci) throw new Error("not initialized"); + + const direct = await ci.codes.run("result = 2 + 2\nresult", { + language: SupportedLanguages.PYTHON, + }); + expect(direct.error).toBeUndefined(); + expect(direct.result[0]?.text).toBe("4"); + + const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON); + await ci.codes.run("x = 42", { context: ctx }); + const r = await ci.codes.run("result = x\nresult", { context: ctx }); + expect(r.result[0]?.text).toBe("42"); + + const bad = await ci.codes.run("print(undefined_variable)", { context: ctx }); + expect(bad.error).toBeTruthy(); +}); + +test("04 go and typescript execution (smoke)", async () => { + if (!ci) throw new Error("not initialized"); + + const goCtx = await ci.codes.createContext(SupportedLanguages.GO); + const go = await ci.codes.run( + 'package main\nimport "fmt"\nfunc main() { fmt.Print("hi"); result := 2+2; fmt.Print(result) }', + { context: goCtx } + ); + expect(go.id).toBeTruthy(); + + const tsCtx = await ci.codes.createContext(SupportedLanguages.TYPESCRIPT); + const ts = await ci.codes.run( + "console.log('Hello from TypeScript!');\nconst result: number = 2 + 2;\nresult", + { + context: tsCtx, + } + ); + expect(ts.id).toBeTruthy(); +}); + +test("05 context isolation", async () => { + if (!ci) throw new Error("not initialized"); + + const python1 = await ci.codes.createContext(SupportedLanguages.PYTHON); + const python2 = await ci.codes.createContext(SupportedLanguages.PYTHON); + await ci.codes.run("secret_value1 = 'python1_secret'", { context: python1 }); + + const ok = await ci.codes.run("result = secret_value1\nresult", { + context: python1, + }); + expect(ok.error).toBeUndefined(); + + const bad = await ci.codes.run("result = secret_value1\nresult", { + context: python2, + }); + expect(bad.error).toBeTruthy(); + expect(bad.error?.name).toBe("NameError"); +}); + +test("06 concurrent execution", async () => { + if (!ci) throw new Error("not initialized"); + + const py = await ci.codes.createContext(SupportedLanguages.PYTHON); + const java = await ci.codes.createContext(SupportedLanguages.JAVA); + const go = await ci.codes.createContext(SupportedLanguages.GO); + + const [r1, r2, r3] = await Promise.all([ + ci.codes.run( + "import time\nfor i in range(3):\n print(i)\n time.sleep(0.1)", + { context: py } + ), + ci.codes.run( + "for (int i=0;i<3;i++){ System.out.println(i); try{Thread.sleep(100);}catch(Exception e){} }", + { context: java } + ), + ci.codes.run( + 'package main\nimport "fmt"\nfunc main(){ for i:=0;i<3;i++{ fmt.Print(i) } }', + { context: go } + ), + ]); + + expect(r1.id).toBeTruthy(); + expect(r2.id).toBeTruthy(); + expect(r3.id).toBeTruthy(); +}); + +test("07 interrupt code execution + fake id", async () => { + if (!ci) throw new Error("not initialized"); + const ci0 = ci; + + const ctx = await ci0.codes.createContext(SupportedLanguages.PYTHON); + + let initId: string | null = null; + let runTask: Promise | null = null; + const initReceived = new Promise((resolve) => { + const handlers: ExecutionHandlers = { + onInit: (i) => { + initId = i.id; + assertRecentTimestampMs(i.timestamp); + resolve(); + }, + }; + + runTask = ci0.codes.run( + "import time\nfor i in range(100):\n print(i)\n time.sleep(0.2)", + { context: ctx, handlers } + ); + }); + + await initReceived; + if (!initId) throw new Error("missing init id"); + await ci0.codes.interrupt(initId); + + // Important: always await/catch the execution task to avoid Vitest reporting + // unhandled rejections when the server closes the streaming connection. + if (runTask) { + try { + await runTask; + } catch { + // Expected in some environments: interrupt may terminate the stream abruptly. + } + } + + await expect(ci0.codes.interrupt(`fake-${Date.now()}`)).rejects.toBeTruthy(); +}); diff --git a/tests/javascript/tests/test_sandbox_e2e.test.ts b/tests/javascript/tests/test_sandbox_e2e.test.ts new file mode 100644 index 00000000..01609739 --- /dev/null +++ b/tests/javascript/tests/test_sandbox_e2e.test.ts @@ -0,0 +1,411 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { + Sandbox, + DEFAULT_EXECD_PORT, + SandboxManager, + type ExecutionHandlers, + type ExecutionComplete, + type ExecutionError, + type ExecutionInit, + type ExecutionResult, + type OutputMessage, +} from "@alibaba-group/opensandbox"; + +import { + assertEndpointHasPort, + assertRecentTimestampMs, + createConnectionConfig, + getSandboxImage, +} from "./base_e2e.ts"; + +let sandbox: Sandbox | null = null; + +beforeAll(async () => { + const connectionConfig = createConnectionConfig(); + + sandbox = await Sandbox.create({ + connectionConfig, + image: getSandboxImage(), + timeoutSeconds: 2 * 60, + readyTimeoutSeconds: 60, + metadata: { tag: "e2e-test" }, + entrypoint: ["tail", "-f", "/dev/null"], + env: { + E2E_TEST: "true", + GO_VERSION: "1.25", + JAVA_VERSION: "21", + NODE_VERSION: "22", + PYTHON_VERSION: "3.12", + }, + healthCheckPollingInterval: 200, + }); +}, 5 * 60_000); + +afterAll(async () => { + if (!sandbox) return; + try { + // keep teardown best-effort + await sandbox.kill(); + } catch { + // ignore + } +}, 5 * 60_000); + +test("01 sandbox lifecycle, health, endpoint, metrics, renew, connect", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + expect(typeof sandbox.id).toBe("string"); + expect(await sandbox.isHealthy()).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + const info = await sandbox.getInfo(); + expect(info.id).toBe(sandbox.id); + expect(info.status.state).toBe("Running"); + expect(info.entrypoint).toEqual(["tail", "-f", "/dev/null"]); + expect(info.metadata?.tag).toBe("e2e-test"); + + const ep = await sandbox.getEndpoint(DEFAULT_EXECD_PORT); + expect(ep).toBeTruthy(); + expect(typeof ep.endpoint).toBe("string"); + assertEndpointHasPort(ep.endpoint, DEFAULT_EXECD_PORT); + + const metrics = await sandbox.getMetrics(); + expect(metrics.cpuCount).toBeGreaterThan(0); + expect(metrics.cpuUsedPercentage).toBeGreaterThanOrEqual(0); + expect(metrics.cpuUsedPercentage).toBeLessThanOrEqual(100); + expect(metrics.memoryTotalMiB).toBeGreaterThan(0); + expect(metrics.memoryUsedMiB).toBeGreaterThanOrEqual(0); + expect(metrics.memoryUsedMiB).toBeLessThanOrEqual(metrics.memoryTotalMiB); + assertRecentTimestampMs(metrics.timestamp, 120_000); + + const renewResp = await sandbox.renew(5 * 60); + expect(renewResp.expiresAt).toBeTruthy(); + expect(renewResp.expiresAt).toBeInstanceOf(Date); + + const connectionConfig = sandbox.connectionConfig; + const sandbox2 = await Sandbox.connect({ + sandboxId: sandbox.id, + connectionConfig, + }); + try { + expect(sandbox2.id).toBe(sandbox.id); + expect(await sandbox2.isHealthy()).toBe(true); + const r = await sandbox2.commands.run("echo connect-ok"); + expect(r.error).toBeUndefined(); + expect(r.logs.stdout[0]?.text).toBe("connect-ok"); + } finally { + // no local resources to close + } +}); + +test("01b sandbox manager: list + get", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + const manager = SandboxManager.create({ connectionConfig: sandbox.connectionConfig }); + + const list = await manager.listSandboxInfos({ + states: ["Running"], + metadata: { tag: "e2e-test" }, + pageSize: 50, + }); + expect(Array.isArray(list.items)).toBe(true); + expect(list.items.some((s) => s.id === sandbox!.id)).toBe(true); + + const info = await manager.getSandboxInfo(sandbox.id); + expect(info.id).toBe(sandbox.id); + expect(info.metadata?.tag).toBe("e2e-test"); +}); + +test("02 command execution: success, cwd, background, failure", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + const stdoutMessages: OutputMessage[] = []; + const stderrMessages: OutputMessage[] = []; + const results: ExecutionResult[] = []; + const initEvents: ExecutionInit[] = []; + const completedEvents: ExecutionComplete[] = []; + const errors: ExecutionError[] = []; + + const handlers: ExecutionHandlers = { + onStdout: (m) => { + stdoutMessages.push(m); + }, + onStderr: (m) => { + stderrMessages.push(m); + }, + onResult: (r) => { + results.push(r); + }, + onInit: (i) => { + initEvents.push(i); + }, + onExecutionComplete: (c) => { + completedEvents.push(c); + }, + onError: (e) => { + errors.push(e); + }, + }; + + const ok = await sandbox.commands.run( + "echo 'Hello OpenSandbox E2E'", + undefined, + handlers + ); + expect(ok.id).toBeTruthy(); + expect(ok.error).toBeUndefined(); + expect(ok.logs.stdout).toHaveLength(1); + expect(ok.logs.stdout[0]?.text).toBe("Hello OpenSandbox E2E"); + assertRecentTimestampMs(ok.logs.stdout[0]!.timestamp); + + expect(initEvents).toHaveLength(1); + expect(completedEvents).toHaveLength(1); + expect(errors).toHaveLength(0); + + const pwd = await sandbox.commands.run("pwd", { workingDirectory: "/tmp" }); + expect(pwd.error).toBeUndefined(); + expect(pwd.logs.stdout[0]?.text).toBe("/tmp"); + + const start = Date.now(); + await sandbox.commands.run("sleep 30", { background: true }); + expect(Date.now() - start).toBeLessThan(10_000); + + // failure contract: error exists; completion should be absent + stdoutMessages.length = 0; + stderrMessages.length = 0; + results.length = 0; + initEvents.length = 0; + completedEvents.length = 0; + errors.length = 0; + + const fail = await sandbox.commands.run( + "nonexistent-command-that-does-not-exist", + undefined, + handlers + ); + expect(fail.id).toBeTruthy(); + expect(fail.error).toBeTruthy(); + expect(fail.error?.name).toBe("CommandExecError"); + expect(fail.logs.stderr.length).toBeGreaterThan(0); + expect( + fail.logs.stderr.some((m) => + m.text.includes("nonexistent-command-that-does-not-exist") + ) + ).toBe(true); + expect(completedEvents.length).toBe(0); +}); + +test("03 filesystem operations: CRUD + replace/move/delete + range + stream", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + const ts = Date.now(); + const dir1 = `/tmp/fs_test1_${ts}`; + const dir2 = `/tmp/fs_test2_${ts}`; + + await sandbox.files.createDirectories([ + { path: dir1, mode: 755 }, + { path: dir2, mode: 644 }, + ]); + + const infoMap = await sandbox.files.getFileInfo([dir1, dir2]); + expect(infoMap[dir1]?.path).toBe(dir1); + expect(infoMap[dir2]?.path).toBe(dir2); + expect(infoMap[dir1]?.mode).toBe(755); + expect(infoMap[dir2]?.mode).toBe(644); + + const ls = await sandbox.commands.run("ls -la | grep fs_test", { + workingDirectory: "/tmp", + }); + expect(ls.error).toBeUndefined(); + expect(ls.logs.stdout).toHaveLength(2); + + const file1 = `${dir1}/test_file1.txt`; + const file2 = `${dir1}/test_file2.txt`; + const file3 = `${dir1}/test_file3.txt`; + const content = "Hello Filesystem!\nLine 2 with special chars: åäö\nLine 3"; + const bytes = new TextEncoder().encode(content); + + // Align with Python/Kotlin semantics but keep E2E portable across different base images: + // prefer "nogroup"/"nobody" if present, otherwise fall back to "root". + const ownerPick = await sandbox.commands.run( + `id -u nobody >/dev/null 2>&1 && echo nobody || echo root`, + { workingDirectory: "/tmp" } + ); + expect(ownerPick.error).toBeUndefined(); + const ownerName = (ownerPick.logs.stdout[0]?.text || "root").trim(); + + const groupPick = await sandbox.commands.run( + `getent group nogroup >/dev/null 2>&1 && echo nogroup || echo root`, + { workingDirectory: "/tmp" } + ); + expect(groupPick.error).toBeUndefined(); + const groupName = (groupPick.logs.stdout[0]?.text || "root").trim(); + + await sandbox.files.writeFiles([ + { path: file1, data: content, mode: 644 }, + { path: file2, data: bytes, mode: 755 }, + { path: file3, data: bytes, mode: 755, owner: ownerName, group: groupName }, + ]); + + const searched = await sandbox.files.search({ path: dir1, pattern: "*" }); + const searchedPaths = new Set(searched.map((f) => f.path)); + expect(searchedPaths.has(file1)).toBe(true); + expect(searchedPaths.has(file2)).toBe(true); + expect(searchedPaths.has(file3)).toBe(true); + + const read1 = await sandbox.files.readFile(file1, { encoding: "utf-8" }); + const read1Partial = await sandbox.files.readFile(file1, { + encoding: "utf-8", + range: "bytes=0-9", + }); + const read2 = await sandbox.files.readBytes(file2); + let read3 = new Uint8Array(); + for await (const chunk of sandbox.files.readBytesStream(file3)) { + const merged = new Uint8Array(read3.length + chunk.length); + merged.set(read3, 0); + merged.set(chunk, read3.length); + read3 = merged; + } + + expect(read1).toBe(content); + expect(new TextDecoder("utf-8").decode(read2)).toBe(content); + expect(new TextDecoder("utf-8").decode(read3)).toBe(content); + expect(read1Partial).toBe(content.slice(0, 10)); + + await sandbox.files.setPermissions([ + { path: file1, mode: 755, owner: ownerName, group: groupName }, + { path: file2, mode: 600, owner: ownerName, group: groupName }, + ]); + const perms = await sandbox.files.getFileInfo([file1, file2]); + expect(perms[file1]?.mode).toBe(755); + expect(perms[file1]?.owner).toBe(ownerName); + expect(perms[file1]?.group).toBe(groupName); + expect(perms[file2]?.mode).toBe(600); + + const updated1 = `${content}\nAppended line to file1`; + const updated2 = `${content}\nAppended line to file2`; + await new Promise((r) => setTimeout(r, 50)); + await sandbox.files.writeFiles([ + { path: file1, data: updated1, mode: 644 }, + { path: file2, data: updated2, mode: 755 }, + ]); + expect(await sandbox.files.readFile(file1)).toBe(updated1); + expect(await sandbox.files.readFile(file2)).toBe(updated2); + + await new Promise((r) => setTimeout(r, 50)); + await sandbox.files.replaceContents([ + { + path: file1, + oldContent: "Appended line to file1", + newContent: "Replaced line in file1", + }, + ]); + const replaced = await sandbox.files.readFile(file1); + expect(replaced.includes("Replaced line in file1")).toBe(true); + expect(replaced.includes("Appended line to file1")).toBe(false); + + const movedPath = `${dir2}/moved_file3.txt`; + await sandbox.files.moveFiles([{ src: file3, dest: movedPath }]); + expect(await sandbox.files.readFile(movedPath)).toBe(content); + + await sandbox.files.deleteFiles([file2]); + await expect(sandbox.files.readFile(file2)).rejects.toBeTruthy(); + + await sandbox.files.deleteDirectories([dir1, dir2]); + const verify = await sandbox.commands.run( + `test ! -d ${dir1} && test ! -d ${dir2} && echo OK`, + { workingDirectory: "/tmp" } + ); + expect(verify.error).toBeUndefined(); + expect(verify.logs.stdout[0]?.text).toBe("OK"); +}); + +test("04 interrupt command", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + const initEvents: ExecutionInit[] = []; + const completed: ExecutionComplete[] = []; + const errors: ExecutionError[] = []; + let initResolve: ((v: ExecutionInit) => void) | null = null; + const initPromise = new Promise((r) => (initResolve = r)); + + const handlers: ExecutionHandlers = { + onInit: (i) => { + initEvents.push(i); + initResolve?.(i); + }, + onExecutionComplete: (c) => { + completed.push(c); + }, + onError: (e) => { + errors.push(e); + }, + }; + + const task = sandbox.commands.run("sleep 30", undefined, handlers); + const init = await initPromise; + expect(init.id).toBeTruthy(); + assertRecentTimestampMs(init.timestamp); + + await sandbox.commands.interrupt(init.id); + const exec = await task; + expect(exec.id).toBe(init.id); + expect(completed.length > 0 || errors.length > 0).toBe(true); +}); + +test("05 sandbox pause + resume", async () => { + if (!sandbox) throw new Error("sandbox not created"); + + await new Promise((r) => setTimeout(r, 20_000)); + await sandbox.pause(); + + let state = "Pausing"; + for (let i = 0; i < 300; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const info = await sandbox.getInfo(); + state = info.status.state; + if (state !== "Pausing") break; + } + expect(state).toBe("Paused"); + + // pause => unhealthy + let healthy = true; + for (let i = 0; i < 10; i++) { + healthy = await sandbox.isHealthy(); + if (!healthy) break; + await new Promise((r) => setTimeout(r, 500)); + } + expect(healthy).toBe(false); + + sandbox = await sandbox.resume({ + readyTimeoutSeconds: 60, + healthCheckPollingInterval: 200, + }); + + let ok = false; + for (let i = 0; i < 60; i++) { + await new Promise((r) => setTimeout(r, 1000)); + ok = await sandbox.isHealthy(); + if (ok) break; + } + expect(ok).toBe(true); + + const echo = await sandbox.commands.run("echo resume-ok"); + expect(echo.error).toBeUndefined(); + expect(echo.logs.stdout[0]?.text).toBe("resume-ok"); +}); \ No newline at end of file diff --git a/tests/javascript/tsconfig.json b/tests/javascript/tsconfig.json new file mode 100644 index 00000000..c3139ff7 --- /dev/null +++ b/tests/javascript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["tests"] +} + + diff --git a/tests/javascript/vitest.config.ts b/tests/javascript/vitest.config.ts new file mode 100644 index 00000000..21c07ad1 --- /dev/null +++ b/tests/javascript/vitest.config.ts @@ -0,0 +1,28 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + // These E2E tests can be slow depending on the provider. + testTimeout: 15 * 60_000, + hookTimeout: 15 * 60_000, + // Keep ordering deterministic (mirrors ordered Python/Java E2E suites). + sequence: { + concurrent: false, + }, + }, +}); \ No newline at end of file