diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index dd2de33e57..8d5d6d157e 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -23,6 +23,11 @@ does not contribute to product usage metrics. `install.sh` defaults to the *latest tagged* release — the canary is therefore checking that the most recent public release still installs, not the just-published `dev` build. The `kubernetes` job is the exception: it pins to `0.0.0-dev` chart + `:dev` images. +The canary does not install or import `@nvidia/openshell-sdk`. TypeScript SDK +validation lives in the `TypeScript SDK` branch check, including a publish +dry-run. The tagged release workflow publishes the package to GitHub Packages; +verify that job directly when diagnosing SDK publication failures. + ## Trigger paths The workflow has two triggers: diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index d53381c1e9..77161883c1 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -274,3 +274,32 @@ jobs: - name: Lint run: mise run markdown:lint + + sdk-typescript: + name: TypeScript SDK + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Check TypeScript SDK + run: mise run sdk:ts:ci + + # Exercise the full release publish path (version stamp, dist-tag, + # prepublishOnly, tarball) without uploading. Uses the off-tag dev + # version, which validates the prerelease dist-tag branch too. + - name: Verify publishable artifact (dry-run) + env: + OPENSHELL_NPM_PUBLISH_ARGS: --dry-run + run: | + OPENSHELL_NPM_VERSION="$(uv run python tasks/scripts/release.py get-version --npm)" \ + mise run sdk:ts:publish diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 8d43e390cf..da84584fae 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -40,6 +40,7 @@ jobs: outputs: python_version: ${{ steps.v.outputs.python }} cargo_version: ${{ steps.v.outputs.cargo }} + npm_version: ${{ steps.v.outputs.npm }} deb_version: ${{ steps.v.outputs.deb }} rpm_version: ${{ steps.v.outputs.rpm_version }} rpm_release: ${{ steps.v.outputs.rpm_release }} @@ -65,6 +66,7 @@ jobs: set -euo pipefail echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" + echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" >> "$GITHUB_OUTPUT" echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" @@ -1071,6 +1073,44 @@ jobs: working-directory: ./fern run: fern generate --docs + publish-sdk-typescript: + name: Publish TypeScript SDK + needs: [compute-versions, release] + runs-on: linux-amd64-cpu8 + timeout-minutes: 15 + permissions: + contents: read + packages: write + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install tools + run: mise install --locked + + - name: Configure npm auth for GitHub Packages + working-directory: ./sdk/typescript + run: | + { + echo "@nvidia:registry=https://npm.pkg.github.com" + echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' + } > .npmrc + + - name: Publish + env: + OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mise run sdk:ts:publish + release-helm: name: Release Helm Chart (OCI) needs: [compute-versions, release, tag-ghcr-release] diff --git a/.gitignore b/.gitignore index b6df45ef1f..1d6407690e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports coverage.out +coverage/ htmlcov/ .tox/ .nox/ diff --git a/AGENTS.md b/AGENTS.md index 7e494a7b5d..51483f88e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, and bypass monitoring | | `crates/openshell-vfio/` | VFIO support | PCI and GPU passthrough preparation and lifecycle | | `python/openshell/` | Python SDK | Python bindings and CLI packaging | +| `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types | | `proto/` | Protobuf definitions | gRPC service contracts | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | | `docs/` | Published docs | MDX pages, navigation, and content assets | @@ -213,6 +214,14 @@ ocsf_emit!(event); - Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries. - Tests use bufconn for in-process gRPC and testify for assertions. +## TypeScript SDK (`sdk/typescript/`) + +- Run `mise run sdk:ts:ci` for codegen, proto lint, Biome lint, type checking, unit tests, coverage, and build validation. +- Proto bindings are generated with `mise run sdk:ts:proto` from the files selected in `sdk/typescript/buf.gen.yaml`. +- Generated files under `sdk/typescript/src/gen/` are build outputs and must not be committed. +- Keep the curated API free of generated wire types; expose full generated messages and RPCs through `@nvidia/openshell-sdk/raw`. +- The release workflow publishes the package to GitHub Packages. Branch checks exercise the publish path with `npm publish --dry-run`. + ## Python - Always use `uv` for Python commands (e.g., `uv pip install`, `uv run`, `uv venv`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c072f341c..e6ea9d52d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -451,6 +451,7 @@ Bazel does not yet cover `mise run gateway`, `mise run sandbox`, `mise run e2e`, | `crates/` | Rust crates | | `python/` | Python SDK and bindings | | `sdk/go/` | Go SDK (types, gRPC clients, converters) | +| `sdk/typescript/` | TypeScript SDK (Connect client and generated protobuf bindings) | | `proto/` | Protocol buffer definitions | | `tasks/` | `mise` task definitions and build scripts | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | diff --git a/architecture/build.md b/architecture/build.md index aea32e1e82..5c5751772a 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -12,6 +12,7 @@ OpenShell builds these main artifacts: |---|---| | Gateway binary | `crates/openshell-server` | | CLI package and Python SDK | `python/openshell` plus Rust binaries where packaged | +| TypeScript SDK package | `sdk/typescript` | | Gateway container image | `deploy/docker/Dockerfile.gateway` | | Supervisor container image | `deploy/docker/Dockerfile.supervisor` | | Helm chart | `deploy/helm/openshell` | @@ -213,6 +214,20 @@ pins them back in with `[tool.maturin].include` globs. The release workflows install each Linux wheel in a clean image and import `openshell.sandbox` as a smoke check. +## TypeScript SDK Packaging + +The native TypeScript SDK in `sdk/typescript` uses Connect over the generated +OpenShell protobuf surface. `sdk/typescript/buf.gen.yaml` selects the client +proto closure, and `mise run sdk:ts:proto` generates gitignored sources under +`src/gen`. TypeScript compilation includes those sources in `dist`, so package +consumers do not run code generation. + +Branch checks run `mise run sdk:ts:ci`, enforce an 80% line-coverage floor, and +exercise version stamping plus `npm publish --dry-run`. Tagged releases publish +`@nvidia/openshell-sdk` to GitHub Packages. The repository keeps package version +`0.0.0`; the release task derives and temporarily stamps the npm version from +the release tag. + ## CI and E2E Required checks run on GitHub Actions. Workflows that use NVIDIA self-hosted runners trigger from copy-pr-bot mirror branches, so trusted PRs are mirrored into `pull-request/` branches before those workflows run. `main` also uses GitHub merge queue so the final queued integration commit is validated before it merges. diff --git a/docs/_components/jsx.d.ts b/docs/_components/jsx.d.ts index b03bbc0f27..7aceb005a5 100644 --- a/docs/_components/jsx.d.ts +++ b/docs/_components/jsx.d.ts @@ -1 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + declare const React: unknown; \ No newline at end of file diff --git a/fern/components/CustomFooter.tsx b/fern/components/CustomFooter.tsx index fab392c407..49601bb017 100644 --- a/fern/components/CustomFooter.tsx +++ b/fern/components/CustomFooter.tsx @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + /** * Custom footer for NVIDIA docs (Fern native header/footer). * Markup and class names match the original custom-app footer 1:1 so that diff --git a/scripts/update_license_headers.py b/scripts/update_license_headers.py index 0f2d87ddd5..aa72b50171 100755 --- a/scripts/update_license_headers.py +++ b/scripts/update_license_headers.py @@ -43,6 +43,10 @@ ".yaml": "#", ".yml": "#", ".rego": "#", + ".ts": "//", + ".tsx": "//", + ".mts": "//", + ".cts": "//", } # Directories to skip entirely (relative to repo root). @@ -55,6 +59,7 @@ ".git", ".cache", "python/openshell/_proto", + "sdk/typescript/src/gen", } # Individual filenames to skip. @@ -103,6 +108,10 @@ def is_excluded(rel: Path) -> bool: """Return True if a path should be skipped.""" rel_str = rel.as_posix() + # Vendored dependencies never carry our headers, at any depth. + if "node_modules" in rel.parts: + return True + # Exact filename exclusions. if rel.name in EXCLUDE_FILES: return True diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore new file mode 100644 index 0000000000..63f9dcce4f --- /dev/null +++ b/sdk/typescript/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +src/gen/ +dist/ +*.tsbuildinfo +.npmrc diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000000..e12353bdeb --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,186 @@ +# @nvidia/openshell-sdk + +TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. + +Distributed via GitHub Packages. A public npm release under the same name follows once the npm org is in place; the install specifier and API are unchanged across that move. + +Use the SDK and gateway from the same OpenShell release when possible. The raw +types and RPC descriptors are generated from the protobuf definitions in that +release; curated methods remain compatible while those RPC contracts remain +compatible. + +## Install + +Published to GitHub Packages, so point the `@nvidia` scope at it with a project `.npmrc`: + +```shell +@nvidia:registry=https://npm.pkg.github.com +``` + +Authenticate with a GitHub token that has `read:packages`, then: + +```shell +npm install @nvidia/openshell-sdk +``` + +## Usage + +```ts +import { OpenShellClient } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ + gateway: 'https://gateway.example.com', + oidcToken: process.env.OPENSHELL_TOKEN, +}) + +const sandbox = await client.sandbox.create({ + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', +}) +await client.sandbox.waitReady(sandbox.name, 120) + +const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +console.log(result.stdout.toString()) + +await client.sandbox.delete(sandbox.name) +``` + +`connect()` constructs a lazy client; call `health()` when startup must verify +gateway reachability. Authentication material is static for the client's +lifetime, so create a new client after refreshing an OIDC or edge token. The +root client has no explicit close method because Connect does not retain a +dedicated session. Close operation-scoped streams and forward handles instead. + +Express the create-time safety boundary with `policy`. Sandbox-scoped `setPolicy` +cannot introduce static policy fields later, so set filesystem, landlock, +process, and initial network policy at creation. For proto spec fields the +curated shape does not surface, `rawSpec` is an escape hatch that shallow- +overrides the assembled spec at the top level (any field it sets wins): + +```ts +await client.sandbox.create({ + image, + policy: { version: 1, networkPolicies: {} }, + rawSpec: { logLevel: 'debug', template: { runtimeClassName: 'gvisor' } }, +}) +``` + +### Scoped clients + +`client.sandbox` is a `SandboxClient`. If you only need sandboxes, connect one +directly — same API, one less hop: + +```ts +import { SandboxClient } from '@nvidia/openshell-sdk' + +const sandbox = await SandboxClient.connect({ gateway, oidcToken }) +await sandbox.create({ image }) +``` + +## Streaming and interactive exec + +`execStream` yields stdout/stderr chunks as they arrive, so long or chatty commands surface output incrementally instead of buffering until exit. The stream ends with a terminal `{ type: 'exit', exitCode }` event, yielded in-band so a failing command cannot look successful under `for await`. Discriminate it with `'type' in event`. If the gateway closes the stream without an exit event, `execStream` throws. `exec` drains `execStream` internally, so its buffered `ExecResult` is unchanged. + +```ts +for await (const event of client.sandbox.execStream(name, ['pytest', '-q'])) { + if ('type' in event) console.log(`exit ${event.exitCode}`) + else process[event.stream].write(event.data) // 'stdout' | 'stderr' +} +``` + +`execInteractive` is the TTY + stdin transport primitive. Drive it by consuming `output`, which yields the same chunk/exit events; `done` resolves with the exit code once the stream reaches its exit event and rejects if it ends without one. It ships raw bytes only; raw mode, signal forwarding, and SIGWINCH stay with the caller. + +```ts +const session = await client.sandbox.execInteractive(name, ['bash']) +session.write(Buffer.from('echo hi\n')) +session.resize(120, 40) +for await (const event of session.output) { + if (!('type' in event)) process.stdout.write(event.data) +} +const code = await session.done +``` + +## Port forwarding + +`forward` binds a local TCP listener and tunnels each accepted connection into the sandbox for the lifetime of the Node process. Call `close()` on teardown. + +```ts +const fwd = await client.sandbox.forward(name, { + targetPort: 8000, + onConnectionError: (error) => console.error(error), +}) +// ... reach the sandbox service at 127.0.0.1:fwd.localPort ... +await fwd.close() +``` + +`close()` is idempotent. It cancels active forwarding RPCs, destroys accepted +sockets, and waits for their cleanup. + +## SSH sessions, providers, config and policy + +```ts +const ssh = await client.sandbox.createSshSession(name) +await client.sandbox.revokeSshSession(ssh.token) + +await client.sandbox.attachProvider(name, 'claude') +await client.sandbox.listProviders(name) +await client.sandbox.detachProvider(name, 'claude') + +const config = await client.sandbox.getConfig(name) +config.policy!.networkPolicies['web'] = { name: 'web', endpoints: [], binaries: [] } +await client.sandbox.setPolicy(name, config.policy!, { wait: true }) +await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolValue', value: true } }) +``` + +Sandbox-scoped `setPolicy` may only change `networkPolicies`; static fields (`filesystem`, `landlock`, `process`) must match the create-time policy. Sandbox-scoped setting deletes are rejected by the gateway, so only upsert (`setSetting`) is exposed here. + +## Surface and roadmap + +The SDK's goal is agent parity: anything the OpenShell gateway can do should be reachable from typed code, not only the CLI. The API is organized as scoped sub-clients over one shared connection, mirroring the CLI's verbs. + +- `client.sandbox` (`SandboxClient`) is available today: sandbox lifecycle, exec, forward, SSH, sandbox-scoped providers, config, and policy. +- `client.gateway` (`GatewayClient`) is planned: gateway-scoped config and settings, health, and cluster status. +- `client.providers` (`ProviderClient`) is planned: gateway-scoped provider CRUD and profiles. + +`health()` lives at the root today and will move under `client.gateway` (with a root alias) when that lands. + +Curated methods are added deliberately, so some gateway RPCs are not yet wrapped in a typed helper. Rather than ship methods that exist but throw, the SDK omits what it has not curated and gives you the raw escape hatch below to reach the full gateway surface today. Omission means "not yet ergonomic," never "impossible." + +### Advanced: raw escape hatch + +`client.raw` is a generated client for every gateway RPC, including surface the curated sub-clients do not wrap yet (gateway config, provider CRUD, policy status, watch, logs, and the full observed `Sandbox`). `client.transport` is the shared connection, so extra clients reuse one socket. Generated request and response types live at `@nvidia/openshell-sdk/raw`. + +```ts +import { OpenShellClient } from '@nvidia/openshell-sdk' +import type { GetGatewayConfigResponse } from '@nvidia/openshell-sdk/raw' + +const client = await OpenShellClient.connect({ gateway, oidcToken }) + +// Reach RPCs the curated surface does not wrap yet: +const cfg: GetGatewayConfigResponse = await client.raw.getGatewayConfig({}) +const status = await client.raw.getSandboxPolicyStatus({ name: 'my-sandbox', version: 0, global: false }) +``` + +The raw layer returns the generated wire messages verbatim, preserving proto distinctions (an omitted optional versus an explicitly empty map) that the curated types may smooth over. As curated sub-clients land, prefer them; `raw` stays as the always-available floor. + +## Boundaries + +The SDK ships primitives, not the CLI's terminal experience. Some things are intentionally out of scope: + +- **Interactive `connect()` / PTY ownership.** `execInteractive`, `createSshSession`, and `forward` are the transport primitives; raw mode, OpenSSH `ProxyCommand`, and terminal glue stay in the CLI. +- **`upload()` / `download()`.** There is no file-transfer RPC — the CLI does tar-over-SSH. For small payloads, `exec`/`execStream` with `stdin` covers it. A first-class gateway file-transfer RPC is a follow-up. +- **Detached / background forwards.** An in-process forward cannot outlive its caller; `forward` is process-lifetime only. + +## Development + +The version field is a `0.0.0` placeholder; CI stamps the real version from the git release tag at publish time, matching the Rust and Python packages. + +```shell +mise run sdk:ts:proto # generate stubs from proto/ with buf +mise run sdk:ts:format # Biome: format + safe fixes (writes) +mise run sdk:ts:lint # Biome: lint + format check (read-only) +mise run sdk:ts:typecheck # tsc --noEmit +mise run sdk:ts:test # Vitest unit tests with an 80% line-coverage gate +mise run sdk:ts:build # emit dist/ +``` + +Formatting and linting are handled by [Biome](https://biomejs.dev) (`biome.json`): 2-space indent, single quotes, semicolons, 120-column width. Generated `src/gen/` is excluded. `sdk:ts:lint` runs in CI as part of `sdk:ts:ci`. diff --git a/sdk/typescript/biome.json b/sdk/typescript/biome.json new file mode 100644 index 0000000000..434459d309 --- /dev/null +++ b/sdk/typescript/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!src/gen", "!dist", "!coverage"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml new file mode 100644 index 0000000000..757f0bd73e --- /dev/null +++ b/sdk/typescript/buf.gen.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Code generation for the TypeScript SDK. The proto module boundary and +# validation policy live in the repo-level buf.yaml; this template only drives +# generation. buf compiles the module with its own compiler (no protoc) and +# runs the connect-es plugin from this package's devDependencies. Limited to +# the client-surface closure so we don't emit the unused inference/compute/test +# protos; well-known types resolve through @bufbuild/protobuf/wkt and are not +# generated. +version: v2 +clean: true +inputs: + - directory: ../../proto + paths: + - ../../proto/openshell.proto + - ../../proto/sandbox.proto + - ../../proto/datamodel.proto + - ../../proto/options.proto +plugins: + - local: ./node_modules/.bin/protoc-gen-es + out: src/gen + opt: + - target=ts + - import_extension=js diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000000..6c3664cedd --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,1956 @@ +{ + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.2.3", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.4", + "@bufbuild/buf": "^1.71.0", + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.4.tgz", + "integrity": "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.4", + "@biomejs/cli-darwin-x64": "2.5.4", + "@biomejs/cli-linux-arm64": "2.5.4", + "@biomejs/cli-linux-arm64-musl": "2.5.4", + "@biomejs/cli-linux-x64": "2.5.4", + "@biomejs/cli-linux-x64-musl": "2.5.4", + "@biomejs/cli-win32-arm64": "2.5.4", + "@biomejs/cli-win32-x64": "2.5.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.4.tgz", + "integrity": "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.4.tgz", + "integrity": "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.4.tgz", + "integrity": "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.4.tgz", + "integrity": "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.4.tgz", + "integrity": "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.4.tgz", + "integrity": "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.4.tgz", + "integrity": "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.4.tgz", + "integrity": "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@bufbuild/buf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.71.0.tgz", + "integrity": "sha512-GDcjBCwLgHT/4nX4YSnYatZ7sDZDpHV6dxQvoT2/P6gKvV23O6hl8NryzLIRKmeau0FRXpQKHVy1dMfnBSpy+w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.71.0", + "@bufbuild/buf-darwin-x64": "1.71.0", + "@bufbuild/buf-linux-aarch64": "1.71.0", + "@bufbuild/buf-linux-armv7": "1.71.0", + "@bufbuild/buf-linux-x64": "1.71.0", + "@bufbuild/buf-win32-arm64": "1.71.0", + "@bufbuild/buf-win32-x64": "1.71.0" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-qZ7xZQyen/jOKFPVs3dlN9pMA56PI4YEo3r4/9ixtiH9gyFgfowR31axsocUgXGThjiN8mvOA8WfpG2tvaSvsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.71.0.tgz", + "integrity": "sha512-2w95pc3X+z06/J66i6uNzA8QPuVOpbPrwyb6tkK0AcJFNvKPVYr4BxVC2koyImrQ3rxY1n9q8qviWMjSvq9fOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.71.0.tgz", + "integrity": "sha512-dwxErryMI3MRwtP/IgfdrqEjiAmVpttGhmO3xihiJIV2EAXt9J5yjzHhEDvnSgQ6nmNjEvO5QczcIaQjZEwF6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.71.0.tgz", + "integrity": "sha512-pfc+Qexm5C59VeRUjVmEvxkCXT5QbMR1R/CUtcSlk+spOFVwna0bSpkqIsky3kkHfzxiNSOsz3iki9/pAVX+CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.71.0.tgz", + "integrity": "sha512-Y7jLxr3wpMkpQSqZU/MrDmDSCkF4GxvhIL7wnNdSRpkhYAY6TPRHN+5nNgV7jp6mQ0zQSYh0MGxBeMgt/UVdmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.71.0.tgz", + "integrity": "sha512-UrxtD99zLE1qImtQC/W3a9cuj0/kB53B1bK38kmCMRFow939FhdZtqTRjbnZWauRi/pzAsjDyPCvnTa2XKT8Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.71.0.tgz", + "integrity": "sha512-+npiOimJ7ggeLul3KFwSlOjZnAZYwt3el64dJ3nJQMnui0avyvsRmU02o1bZI5yUnBvhcnTWdEbfRXUnkkVtgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.1.tgz", + "integrity": "sha512-SWa7XvRYRouMo+vBQmpNFZ+ZEqQ8AC0LpL4QWAo1gvstLhFh/Y7Nf/a+MK7ZxDq5LZSThwfk974L1sFxO3OaGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.1", + "@bufbuild/protoplugin": "2.12.1" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.12.1" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.1.tgz", + "integrity": "sha512-PY58KxQVAD1BnnKtStOctsMoegEVGfBnY5AOqVQOIu711nA13oYtTqJM8df5lUQg2J1DR3XxUXptE+fWX5oLdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.1", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.2.tgz", + "integrity": "sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.2" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..20e1da18bc --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,58 @@ +{ + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "description": "Official TypeScript SDK for the OpenShell gateway.", + "license": "Apache-2.0", + "type": "module", + "homepage": "https://github.com/NVIDIA/OpenShell", + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/OpenShell.git", + "directory": "sdk/typescript" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./raw": { + "types": "./dist/raw.d.ts", + "import": "./dist/raw.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=20.3" + }, + "scripts": { + "gen": "buf generate", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "format": "biome check --write .", + "lint": "biome ci .", + "test": "vitest run --coverage", + "prepublishOnly": "npm run gen && npm run build" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.3", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.4", + "@bufbuild/buf": "^1.71.0", + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts new file mode 100644 index 0000000000..5e72c27ba0 --- /dev/null +++ b/sdk/typescript/src/client.test.ts @@ -0,0 +1,1033 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Unit tests for SandboxClient against an in-memory OpenShell service. Every +// RPC is stubbed with createRouterTransport, so these exercise request +// assembly, u64/int64->string rendering, enum lowercasing, fromConnect code +// mapping, the exec/execStream drain, execInteractive framing, and the +// forward() byte relay without a running gateway. + +import * as net from 'node:net'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { + errorCode, + PHASE_NAMES, + POLICY_SOURCE_NAMES, + Pushable, + SandboxClient, + SCOPE_NAMES, + STATUS_NAMES, +} from './client.js'; +import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js'; +import { PolicySource, SettingScope } from './gen/sandbox_pb.js'; + +function client(impl: Partial>): SandboxClient { + const transport: Transport = createRouterTransport((router) => { + router.service(OpenShell, impl); + }); + return new SandboxClient(transport); +} + +function readySandbox( + name: string, + id: string, + resourceVersion = 7n, +): MessageInitShape { + return { + sandbox: { + metadata: { id, name, labels: { team: 'aire' }, resourceVersion }, + status: { phase: SandboxPhase.READY }, + }, + }; +} + +const enc = (s: string) => new TextEncoder().encode(s); + +describe('exec / execStream', () => { + it('resolves the id via get, frames tty:false, and buffers the result (backward compat)', async () => { + let execReq: { sandboxId?: string; tty?: boolean; command?: string[] } = {}; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* (req) { + execReq = req; + yield { payload: { case: 'stdout', value: { data: enc('hello ') } } }; + yield { payload: { case: 'stderr', value: { data: enc('warn') } } }; + yield { payload: { case: 'stdout', value: { data: enc('world') } } }; + yield { payload: { case: 'exit', value: { exitCode: 3 } } }; + }, + }); + + const result = await sandbox.exec('sb', ['/bin/sh', '-c', 'echo hi']); + expect(execReq.sandboxId).toBe('sb-id-1'); + expect(execReq.tty).toBe(false); + expect(execReq.command).toEqual(['/bin/sh', '-c', 'echo hi']); + expect(result.exitCode).toBe(3); + expect(result.stdout.toString()).toBe('hello world'); + expect(result.stderr.toString()).toBe('warn'); + expect(Buffer.isBuffer(result.stdout)).toBe(true); + }); + + it('execStream yields incremental chunks then a terminal exit event', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('a') } } }; + yield { payload: { case: 'stderr', value: { data: enc('b') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + + const chunks: Array<{ stream: string; data: string }> = []; + let exitCode: number | undefined; + for await (const event of sandbox.execStream('sb', ['x'])) { + if ('type' in event) exitCode = event.exitCode; + else chunks.push({ stream: event.stream, data: event.data.toString() }); + } + expect(chunks).toEqual([ + { stream: 'stdout', data: 'a' }, + { stream: 'stderr', data: 'b' }, + ]); + expect(exitCode).toBe(0); + }); + + it('surfaces a nonzero exit via for-await', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('boom') } } }; + yield { payload: { case: 'exit', value: { exitCode: 2 } } }; + }, + }); + + let streamed: number | undefined; + for await (const event of sandbox.execStream('sb', ['pytest'])) { + if ('type' in event) streamed = event.exitCode; + } + expect(streamed).toBe(2); + }); + + it('surfaces a nonzero exit via exec()', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('boom') } } }; + yield { payload: { case: 'exit', value: { exitCode: 2 } } }; + }, + }); + const result = await sandbox.exec('sb', ['pytest']); + expect(result.exitCode).toBe(2); + expect(result.stdout.toString()).toBe('boom'); + }); + + it('execStream throws when the stream ends without an exit event', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('partial') } } }; + }, + }); + await expect( + (async () => { + for await (const _event of sandbox.execStream('sb', ['x'])) { + // drain to completion + } + })(), + ).rejects.toMatchObject({ code: 'rpc' }); + }); + + it('exec throws when the stream ends without an exit event', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('partial') } } }; + }, + }); + await expect(sandbox.exec('sb', ['x'])).rejects.toMatchObject({ code: 'rpc' }); + }); + + it('execStream rejects when the caller signal is already aborted', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('never') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const signal = AbortSignal.abort(); + await expect( + (async () => { + for await (const _event of sandbox.execStream('sb', ['x'], { signal })) { + // drain to completion + } + })(), + ).rejects.toBeInstanceOf(Error); + }); + + it('exec rejects when the caller signal aborts mid-stream', async () => { + const controller = new AbortController(); + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + execSandbox: async function* (_req, ctx) { + yield { payload: { case: 'stdout', value: { data: enc('partial') } } }; + await new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new ConnectError('canceled', Code.Canceled)), { + once: true, + }); + }); + }, + }); + setTimeout(() => controller.abort(), 10); + await expect(sandbox.exec('sb', ['x'], { signal: controller.signal })).rejects.toBeInstanceOf(Error); + }); + + it('maps a NotFound from get() to an SdkError not_found', async () => { + const sandbox = client({ + getSandbox: () => { + throw new ConnectError('missing', Code.NotFound); + }, + }); + await expect(sandbox.exec('sb', ['x'])).rejects.toMatchObject({ + code: 'not_found', + }); + await expect(sandbox.exec('sb', ['x'])).rejects.toSatisfy((e) => errorCode(e) === 'not_found'); + }); +}); + +describe('create', () => { + it('sends the curated policy through spec.policy', async () => { + let created: { spec?: { policy?: { version?: number } } } = {}; + const sandbox = client({ + createSandbox: (req) => { + created = req; + return readySandbox('sb', 'sb-id'); + }, + }); + await sandbox.create({ image: 'img', policy: { version: 1, networkPolicies: {} } }); + expect(created.spec?.policy?.version).toBe(1); + }); + + it('rawSpec reaches an ungated field and overrides a curated one', async () => { + let created: { + spec?: { + logLevel?: string; + template?: { image?: string }; + providers?: string[]; + }; + } = {}; + const sandbox = client({ + createSandbox: (req) => { + created = req; + return readySandbox('sb', 'sb-id'); + }, + }); + await sandbox.create({ + image: 'curated-image', + providers: ['claude'], + rawSpec: { logLevel: 'debug', template: { image: 'raw-image' } }, + }); + // Ungated field only reachable via rawSpec. + expect(created.spec?.logLevel).toBe('debug'); + // rawSpec wins on a field the curated shape also sets. + expect(created.spec?.template?.image).toBe('raw-image'); + // Curated fields rawSpec does not touch survive. + expect(created.spec?.providers).toEqual(['claude']); + }); + + it('rejects gateway sandboxes missing required metadata', async () => { + const sandbox = client({ + getSandbox: () => ({ sandbox: { status: { phase: SandboxPhase.READY } } }), + }); + await expect(sandbox.get('sb')).rejects.toMatchObject({ code: 'invalid_config' }); + }); +}); + +describe('waits', () => { + it('waitReady rejects rather than hanging when get() never resolves', async () => { + const sandbox = client({ + // Only settles when the per-poll deadline signal aborts the call. + getSandbox: (_req, ctx) => + new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new ConnectError('canceled', Code.Canceled))); + }), + }); + await expect(sandbox.waitReady('sb', 0.2)).rejects.toMatchObject({ code: 'connect' }); + }); + + it('waitReady rejects when a caller AbortController fires mid-wait', async () => { + const controller = new AbortController(); + const sandbox = client({ + getSandbox: (_req, ctx) => + new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new ConnectError('canceled', Code.Canceled))); + }), + }); + setTimeout(() => controller.abort(), 30); + await expect(sandbox.waitReady('sb', 30, { signal: controller.signal })).rejects.toMatchObject({ + code: 'connect', + }); + }); + + it('waitDeleted resolves when the gateway reports NotFound', async () => { + const sandbox = client({ + getSandbox: () => { + throw new ConnectError('gone', Code.NotFound); + }, + }); + await expect(sandbox.waitDeleted('sb', 1)).resolves.toBeUndefined(); + }); + + it('waitDeleted rejects rather than hanging when get() never resolves', async () => { + const sandbox = client({ + getSandbox: (_req, ctx) => + new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new ConnectError('canceled', Code.Canceled))); + }), + }); + await expect(sandbox.waitDeleted('sb', 0.2)).rejects.toMatchObject({ code: 'connect' }); + }); +}); + +describe('Pushable', () => { + it('rejects a pending direct iterator next() when ended with an error', async () => { + const input = new Pushable(); + const iterator = input[Symbol.asyncIterator](); + const next = iterator.next(); + const error = new Error('input failed'); + input.end(error); + await expect(next).rejects.toBe(error); + }); +}); + +describe('execInteractive', () => { + it('sends start first with tty/cols/rows, streams output, and resolves done', async () => { + const cases: string[] = []; + let started: { tty?: boolean; cols?: number; rows?: number; sandboxId?: string } | undefined; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-9'), + execSandboxInteractive: async function* (requests) { + for await (const input of requests) { + cases.push(input.payload.case ?? 'none'); + if (input.payload.case === 'start') { + started = input.payload.value; + yield { + payload: { case: 'stdout', value: { data: enc('ready\n') } }, + }; + } else if (input.payload.case === 'stdin') { + yield { + payload: { case: 'stdout', value: { data: input.payload.value } }, + }; + } + } + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + + const session = await sandbox.execInteractive('sb', ['bash'], { + cols: 120, + rows: 40, + }); + const out: string[] = []; + const collector = (async () => { + for await (const event of session.output) { + if (!('type' in event)) out.push(event.data.toString()); + } + })(); + + session.write(Buffer.from('echo hi')); + // Let the echo round-trip before closing the input stream. + await new Promise((r) => setTimeout(r, 20)); + session.close(); + + await collector; + const code = await session.done; + expect(code).toBe(0); + expect(cases[0]).toBe('start'); + expect(started?.tty).toBe(true); + expect(started?.cols).toBe(120); + expect(started?.rows).toBe(40); + expect(started?.sandboxId).toBe('sb-id-9'); + expect(out.join('')).toContain('ready\n'); + expect(out.join('')).toContain('echo hi'); + }); +}); + +describe('exec done settlement', () => { + it('resolves done even when the consumer breaks right after the exit event', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + // eslint-disable-next-line require-yield + execSandboxInteractive: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('hi') } } }; + yield { payload: { case: 'exit', value: { exitCode: 3 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + for await (const event of session.output) { + if ('type' in event) break; // break on exit: the generator never resumes + } + // Without settling `done` before the exit yield, this would hang forever. + expect(await session.done).toBe(3); + }); + + it('rejects done and throws from output when the stream errors before exit', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('partial') } } }; + throw new ConnectError('boom', Code.Internal); + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await expect( + (async () => { + for await (const _event of session.output) { + // drain until the stream error surfaces + } + })(), + ).rejects.toMatchObject({ code: 'rpc' }); + await expect(session.done).rejects.toMatchObject({ code: 'rpc' }); + }); + + it('rejects done when the consumer abandons output before an exit event', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + // eslint-disable-next-line require-yield + execSandboxInteractive: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('one') } } }; + yield { payload: { case: 'stdout', value: { data: enc('two') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + for await (const event of session.output) { + if (!('type' in event)) break; // abandon on the first chunk, before exit + } + await expect(session.done).rejects.toMatchObject({ code: 'rpc' }); + }); +}); + +describe('providers', () => { + it('attach/detach assemble the request and map the changed flag + sandbox ref', async () => { + let attachReq: { + sandboxName?: string; + providerName?: string; + expectedResourceVersion?: bigint; + } = {}; + let detachReq: { expectedResourceVersion?: bigint } = {}; + const sandbox = client({ + attachSandboxProvider: (req) => { + attachReq = req; + return { sandbox: readySandbox('sb', 'sb-id').sandbox, attached: true }; + }, + detachSandboxProvider: (req) => { + detachReq = req; + return { + sandbox: readySandbox('sb', 'sb-id').sandbox, + detached: false, + }; + }, + }); + + const attach = await sandbox.attachProvider('sb', 'claude'); + expect(attachReq.sandboxName).toBe('sb'); + expect(attachReq.providerName).toBe('claude'); + expect(attachReq.expectedResourceVersion).toBe(0n); + expect(attach.changed).toBe(true); + expect(attach.sandbox.resourceVersion).toBe('7'); + + const detach = await sandbox.detachProvider('sb', 'claude', { + expectedResourceVersion: '42', + }); + expect(detachReq.expectedResourceVersion).toBe(42n); + expect(detach.changed).toBe(false); + }); + + it('lists providers with u64 resourceVersion rendered as a string', async () => { + const sandbox = client({ + listSandboxProviders: () => ({ + providers: [ + { + metadata: { + id: 'p1', + name: 'claude', + labels: { a: 'b' }, + resourceVersion: 99n, + }, + type: 'claude', + }, + ], + }), + }); + const providers = await sandbox.listProviders('sb'); + expect(providers).toEqual([ + { + id: 'p1', + name: 'claude', + type: 'claude', + labels: { a: 'b' }, + resourceVersion: '99', + }, + ]); + }); +}); + +describe('config / policy', () => { + it('getConfig lowercases scope + policySource and renders u64 as strings', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + getSandboxConfig: () => ({ + policy: { version: 1, networkPolicies: {} }, + version: 4, + policyHash: 'hash-a', + settings: { + 'net.timeout': { + value: { value: { case: 'intValue', value: 30n } }, + scope: SettingScope.SANDBOX, + }, + }, + configRevision: 123n, + policySource: PolicySource.GLOBAL, + globalPolicyVersion: 2, + providerEnvRevision: 456n, + }), + }); + const config = await sandbox.getConfig('sb'); + expect(config.version).toBe(4); + expect(config.policyHash).toBe('hash-a'); + expect(config.policySource).toBe('global'); + expect(config.configRevision).toBe('123'); + expect(config.providerEnvRevision).toBe('456'); + expect(config.settings['net.timeout']?.scope).toBe('sandbox'); + expect(config.settings['net.timeout']?.value?.value).toEqual({ + case: 'intValue', + value: 30n, + }); + }); + + it('setPolicy sends global=false + version pin and (wait) polls until the hash matches', async () => { + let updateReq: { + name?: string; + global?: boolean; + expectedResourceVersion?: bigint; + policy?: unknown; + } = {}; + let configCalls = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + updateConfig: (req) => { + updateReq = req; + return { + version: 5, + policyHash: 'target', + settingsRevision: 10n, + deleted: false, + }; + }, + getSandboxConfig: () => { + configCalls += 1; + const policyHash = configCalls >= 2 ? 'target' : 'stale'; + return { + policy: { version: 1, networkPolicies: {} }, + version: 5, + policyHash, + settings: {}, + configRevision: 1n, + policySource: PolicySource.SANDBOX, + globalPolicyVersion: 0, + providerEnvRevision: 0n, + }; + }, + }); + + const result = await sandbox.setPolicy( + 'sb', + { + version: 1, + networkPolicies: { web: { name: 'web', endpoints: [], binaries: [] } }, + }, + { wait: true, expectedResourceVersion: '7' }, + ); + expect(updateReq.name).toBe('sb'); + expect(updateReq.global).toBe(false); + expect(updateReq.expectedResourceVersion).toBe(7n); + expect(updateReq.policy).toBeDefined(); + expect(result.version).toBe(5); + expect(result.policyHash).toBe('target'); + expect(result.settingsRevision).toBe('10'); + expect(configCalls).toBeGreaterThanOrEqual(2); + }); + + // Fix #4 residual: setPolicy(..., {wait:true}) must not hang forever when the + // getConfig poll stalls. Each poll RPC is bounded by the remaining deadline, + // so a getSandboxConfig that never settles on its own is aborted and the wait + // rejects instead of pending forever. The handler resolves only on the call + // signal firing, proving the per-poll deadline (not the sleep loop) is what + // bounds the returned promise. + it('setPolicy wait rejects when the config poll stalls past the deadline', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + updateConfig: () => ({ version: 5, policyHash: 'target', settingsRevision: 10n, deleted: false }), + getSandboxConfig: (_req, ctx) => + new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }), + }); + + await expect( + sandbox.setPolicy('sb', { version: 1, networkPolicies: {} }, { wait: true, waitTimeoutSecs: 0.2 }), + ).rejects.toMatchObject({ code: 'connect' }); + }, 5000); + + it('setSetting upserts a single sandbox-scoped setting (global=false)', async () => { + let req: { + name?: string; + settingKey?: string; + global?: boolean; + settingValue?: unknown; + } = {}; + const sandbox = client({ + updateConfig: (r) => { + req = r; + return { + version: 6, + policyHash: '', + settingsRevision: 11n, + deleted: false, + }; + }, + }); + const result = await sandbox.setSetting('sb', 'feature.enabled', { + value: { case: 'boolValue', value: true }, + }); + expect(req.name).toBe('sb'); + expect(req.settingKey).toBe('feature.enabled'); + expect(req.global).toBe(false); + expect(req.settingValue).toMatchObject({ + value: { case: 'boolValue', value: true }, + }); + expect(result.settingsRevision).toBe('11'); + }); + + it('rejects a non-u64 expectedResourceVersion with invalid_config (no raw SyntaxError)', async () => { + // versionPin runs during request assembly, before any RPC is issued. + const sandbox = client({}); + await expect( + sandbox.setPolicy('sb', { version: 1, networkPolicies: {} }, { expectedResourceVersion: 'not-a-number' }), + ).rejects.toMatchObject({ code: 'invalid_config' }); + }); +}); + +describe('ssh sessions', () => { + it('creates a session, omitting expiresAtMs when 0 and rendering it as a string otherwise', async () => { + const withExpiry = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => ({ + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost: 'gw.example', + gatewayPort: 8443, + gatewayScheme: 'https', + hostKeyFingerprint: 'SHA256:abc', + expiresAtMs: 1730000000000n, + }), + }); + const session = await withExpiry.createSshSession('sb'); + expect(session).toEqual({ + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost: 'gw.example', + gatewayPort: 8443, + gatewayScheme: 'https', + hostKeyFingerprint: 'SHA256:abc', + expiresAtMs: '1730000000000', + }); + + const noExpiry = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => ({ + sandboxId: 'sb-id', + token: 'tok-2', + gatewayHost: 'gw', + gatewayPort: 80, + gatewayScheme: 'http', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + }); + const bare = await noExpiry.createSshSession('sb'); + expect(bare.expiresAtMs).toBeUndefined(); + expect(bare.hostKeyFingerprint).toBeUndefined(); + }); + + it('revokeSshSession returns the revoked flag', async () => { + const sandbox = client({ revokeSshSession: () => ({ revoked: true }) }); + expect(await sandbox.revokeSshSession('tok')).toBe(true); + }); + + it('rejects a response that violates the ProxyCommand trust-boundary contract', async () => { + const base = { + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost: 'gw.example', + gatewayPort: 8443, + gatewayScheme: 'https', + hostKeyFingerprint: 'SHA256:abc', + expiresAtMs: 0n, + }; + const cases: Array> = [ + { ...base, sandboxId: 'different-sandbox' }, + { ...base, gatewayScheme: 'ftp' }, + { ...base, token: 'tok; rm -rf /' }, + { ...base, gatewayPort: 70000 }, + { ...base, gatewayHost: 'bad[host]' }, + { ...base, gatewayHost: '::::' }, + { ...base, gatewayHost: 'bad..example' }, + { ...base, hostKeyFingerprint: `SHA256:${'a'.repeat(257)}` }, + ]; + for (const resp of cases) { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => resp, + }); + await expect(sandbox.createSshSession('sb')).rejects.toMatchObject({ + code: 'invalid_config', + }); + } + }); + + it('accepts IPv4 and bracketed IPv6 gateway hosts', async () => { + for (const gatewayHost of ['127.0.0.1', '[::1]']) { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => ({ + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost, + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + }); + await expect(sandbox.createSshSession('sb')).resolves.toMatchObject({ gatewayHost }); + } + }); +}); + +describe('forward', () => { + it('binds a local port and relays bytes both ways, minting + revoking a token', async () => { + let sshReq: { sandboxId?: string } = {}; + let revokedToken: string | undefined; + let initFrame: { sandboxId?: string; authorizationToken?: string; target?: unknown } | undefined; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-forward'), + createSshSession: (req) => { + sshReq = req; + return { + sandboxId: 'sb-id-forward', + token: 'fwd-tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }; + }, + revokeSshSession: (req) => { + revokedToken = req.token; + return { revoked: true }; + }, + forwardTcp: async function* (requests) { + for await (const frame of requests) { + if (frame.payload.case === 'init') { + initFrame = frame.payload.value; + } else if (frame.payload.case === 'data') { + yield { payload: { case: 'data', value: frame.payload.value } }; + } + } + }, + }); + + const handle = await sandbox.forward('sb', { targetPort: 9000 }); + expect(handle.localPort).toBeGreaterThan(0); + expect(handle.targetPort).toBe(9000); + expect(handle.targetHost).toBe('127.0.0.1'); + + const echoed = await new Promise((resolve, reject) => { + const socket = net.connect(handle.localPort, handle.localHost, () => { + socket.write('ping-through-forward'); + }); + const buf: Buffer[] = []; + socket.on('data', (d) => { + buf.push(d); + if (Buffer.concat(buf).length >= 'ping-through-forward'.length) { + resolve(Buffer.concat(buf).toString()); + socket.end(); + } + }); + socket.on('error', reject); + }); + + expect(echoed).toBe('ping-through-forward'); + expect(sshReq.sandboxId).toBe('sb-id-forward'); + expect(initFrame?.sandboxId).toBe('sb-id-forward'); + expect(initFrame?.authorizationToken).toBe('fwd-tok'); + expect(initFrame?.target).toMatchObject({ + case: 'tcp', + value: { host: '127.0.0.1', port: 9000 }, + }); + + await handle.close(); + await handle.closed; + // The per-connection revoke is best-effort and fires on teardown. + await new Promise((r) => setTimeout(r, 20)); + expect(revokedToken).toBe('fwd-tok'); + }); + + it('rejects when the sandbox is not ready', async () => { + const sandbox = client({ + getSandbox: () => ({ + sandbox: { + metadata: { id: 'sb-id', name: 'sb' }, + status: { phase: SandboxPhase.PROVISIONING }, + }, + }), + }); + await expect(sandbox.forward('sb', { targetPort: 9000 })).rejects.toMatchObject({ code: 'connect' }); + }); + + // Backpressure (fix #6): the sandbox->local relay must stop pulling gRPC + // frames when socket.write() returns false and resume after 'drain', so a + // slow local reader cannot make Node buffer sandbox output without bound. + // Flood a large payload at a paused reader that only drains in small bites; + // every byte must still arrive intact and in order. + it('honors socket backpressure on the sandbox->local relay without dropping bytes', async () => { + const CHUNKS = 256; + const CHUNK = 64 * 1024; // 16 MiB total, well past any socket highWaterMark + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-bp'), + createSshSession: () => ({ + sandboxId: 'sb-id-bp', + token: 'bp-tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + revokeSshSession: () => ({ revoked: true }), + // Ignore inbound frames; just blast a large, verifiable byte stream back. + forwardTcp: async function* () { + for (let i = 0; i < CHUNKS; i++) { + yield { payload: { case: 'data' as const, value: new Uint8Array(CHUNK).fill(i & 0xff) } }; + } + }, + }); + + const handle = await sandbox.forward('sb', { targetPort: 9000 }); + const received = await new Promise((resolve, reject) => { + const socket = net.connect(handle.localPort, handle.localHost); + const buf: Buffer[] = []; + let total = 0; + socket.on('connect', () => socket.write('go')); + socket.on('data', (d) => { + buf.push(d); + total += d.length; + // Simulate a slow consumer: pause, then resume on the next tick. This + // keeps the OS/Node buffer near-full so writes return false and the + // relay must await 'drain'. + socket.pause(); + setTimeout(() => socket.resume(), 0); + if (total >= CHUNKS * CHUNK) resolve(Buffer.concat(buf)); + }); + socket.on('error', reject); + }); + + expect(received.length).toBe(CHUNKS * CHUNK); + // Verify order + integrity: chunk i is filled with (i & 0xff). + for (let i = 0; i < CHUNKS; i++) { + expect(received[i * CHUNK]).toBe(i & 0xff); + expect(received[i * CHUNK + CHUNK - 1]).toBe(i & 0xff); + } + + await handle.close(); + await handle.closed; + }); + + // Fix #7: the accepted socket must have an 'error' handler before + // forwardConnection awaits createSshSession, or a peer reset in that window + // emits an unhandled 'error' and crashes the process. + it('survives a forwarded socket that resets during the session-mint window', async () => { + let releaseSession: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseSession = resolve; + }); + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-reset'), + createSshSession: async () => { + // Hold the RPC open so the accepted socket sits in the pre-handler window. + await gate; + return { + sandboxId: 'sb-id-reset', + token: 'reset-tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }; + }, + // biome-ignore lint/correctness/useYield: the socket is reset before any frame is relayed + forwardTcp: async function* () { + return; + }, + revokeSshSession: () => ({ revoked: true }), + }); + + const handle = await sandbox.forward('sb', { targetPort: 9000 }); + await new Promise((resolve) => { + const socket = net.connect(handle.localPort, handle.localHost, () => { + // Abort mid-mint; the server-side accepted socket may see an + // ECONNRESET 'error' before forwardConnection attaches its handlers. + socket.destroy(new Error('peer reset')); + setTimeout(resolve, 30); + }); + socket.on('error', () => {}); // ignore the client-side reset + }); + + releaseSession?.(); + // The listener still shuts down cleanly after the aborted connection. + await handle.close(); + await handle.closed; + }); + + it('reports per-connection failures without taking down the listener', async () => { + let report!: (error: unknown) => void; + const reported = new Promise((resolve) => { + report = resolve; + }); + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-error'), + createSshSession: () => { + throw new ConnectError('mint failed', Code.Internal); + }, + }); + + const handle = await sandbox.forward('sb', { + targetPort: 9000, + onConnectionError: (error) => { + report(error); + throw new Error('consumer callback failed'); + }, + }); + await new Promise((resolve) => { + const socket = net.connect(handle.localPort, handle.localHost, () => resolve()); + socket.on('error', () => {}); + }); + await expect(reported).resolves.toMatchObject({ code: 'rpc' }); + expect(handle.localPort).toBeGreaterThan(0); + await expect(handle.close()).resolves.toBeUndefined(); + }); + + it('close is idempotent and waits for active forward RPC cancellation', async () => { + let streamStarted!: () => void; + const started = new Promise((resolve) => { + streamStarted = resolve; + }); + let streamAborted = false; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-close'), + createSshSession: () => ({ + sandboxId: 'sb-id-close', + token: 'close-tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + forwardTcp: async function* (_requests, ctx) { + streamStarted(); + await new Promise((resolve) => { + ctx.signal.addEventListener( + 'abort', + () => { + streamAborted = true; + resolve(); + }, + { once: true }, + ); + }); + throw new ConnectError('canceled', Code.Canceled); + }, + revokeSshSession: () => ({ revoked: true }), + }); + + const handle = await sandbox.forward('sb', { targetPort: 9000 }); + const socket = net.connect(handle.localPort, handle.localHost); + socket.on('error', () => {}); + await started; + await Promise.all([handle.close(), handle.close(), handle.closed]); + expect(streamAborted).toBe(true); + socket.destroy(); + }); +}); + +// The lowercase enum-name unions in client.ts are a hand-maintained mirror of +// the generated proto enums. This pins every hand-written literal to its +// generated member name (lowercased), so a proto enum change that slips past +// the exhaustive Record type is still caught here at runtime. +describe('enum name maps', () => { + function numericMembers(genEnum: Record): Array<[string, number]> { + return Object.entries(genEnum).filter((e): e is [string, number] => typeof e[1] === 'number'); + } + + const cases: Array<[string, Record, Record]> = [ + ['SandboxPhase', SandboxPhase, PHASE_NAMES], + ['ServiceStatus', ServiceStatus, STATUS_NAMES], + ['SettingScope', SettingScope, SCOPE_NAMES], + ['PolicySource', PolicySource, POLICY_SOURCE_NAMES], + ]; + + for (const [label, genEnum, names] of cases) { + it(`${label} maps every generated member to its lowercased name`, () => { + const members = numericMembers(genEnum); + for (const [name, value] of members) { + expect(names[value]).toBe(name.toLowerCase()); + } + // No missing or extra map entries versus the generated enum. + expect(Object.keys(names).length).toBe(members.length); + }); + } +}); + +describe('raw escape hatch', () => { + it('reaches uncurated RPCs and returns generated wire messages', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + getGatewayConfig: () => ({ settings: {}, settingsRevision: 42n }), + }); + + // An RPC with no curated wrapper is still reachable through raw. + const cfg = await sandbox.raw.getGatewayConfig({}); + expect(cfg.settingsRevision).toBe(42n); + + // raw returns the full generated message: the enum stays numeric, where the + // curated get() would lowercase status.phase to 'ready'. + const resp = await sandbox.raw.getSandbox({ name: 'sb' }); + expect(resp.sandbox?.status?.phase).toBe(SandboxPhase.READY); + expect(resp.sandbox?.metadata?.name).toBe('sb'); + + // The shared transport is exposed for building extra clients. + expect(sandbox.transport).toBeDefined(); + }); +}); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..4650e3fdde --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,1234 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// The OpenShell gateway client: a thin, idiomatic ergonomics layer over the +// protobuf-generated gRPC stubs (src/gen/). Resource operations live on scoped +// clients (`SandboxClient`, mirroring the Python SDK) that OpenShellClient +// composes as `client.sandbox.*`, mirroring the CLI's noun-verb model; each +// scoped client is also usable standalone via its own `connect()`. Gateway- +// scoped calls (`health`) stay top-level. A scoped client owns proto request +// assembly, the curated public types, the ExecSandbox server-stream drain, and +// the waitReady/waitDeleted poll loops. Transport and auth live in +// transport.ts; the error taxonomy in errors.ts. + +import type { AddressInfo } from 'node:net'; +import * as net from 'node:net'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; +import { errorCode, fromConnect, SdkError } from './errors.js'; +import type { Provider } from './gen/datamodel_pb.js'; +import type { Sandbox, UpdateConfigResponse } from './gen/openshell_pb.js'; +import { + type ExecSandboxInputSchema, + OpenShell, + SandboxPhase, + type SandboxSpecSchema, + ServiceStatus, + type TcpForwardFrameSchema, +} from './gen/openshell_pb.js'; +import type { EffectiveSetting, GetSandboxConfigResponse, SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; +import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValueSchema } from './gen/sandbox_pb.js'; +import { validateSshResponse } from './ssh-validate.js'; +import { buildTransport, type ConnectOptions } from './transport.js'; + +// The policy and setting value shapes are the generated protobuf messages; +// re-export them rather than re-curating a parallel surface. Callers round-trip +// `getConfig().policy` back into `setPolicy`, and build `SettingValue`s inline. +export type { SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; +export type { ConnectOptions }; +export { errorCode }; + +// ---- Curated public types -------------------------------------------------- + +// The gateway enums (SandboxPhase, ServiceStatus, SettingScope, PolicySource) +// arrive as protoc-gen-es numeric enums. The lowercase literal unions below are +// a hand-maintained mirror of them so consumers get exhaustive, typo-proof +// switches instead of a bare `string`. They are deliberately duplicated by +// hand, not generated, and are expected to stay stable. If a proto enum ever +// gains, removes, or renames a member, update the matching union AND its +// `*_NAMES` map below: the exhaustive `Record` stops compiling, and the +// 'enum name maps' drift test in client.test.ts fails until both sides agree. + +/** Lowercase mirror of the generated `SandboxPhase` enum. Hand-maintained. */ +export type SandboxPhaseName = + | 'unspecified' + | 'provisioning' + | 'ready' + | 'error' + | 'deleting' + | 'unknown' + | 'stopping' + | 'stopped' + | 'starting'; + +/** Lowercase mirror of the generated `ServiceStatus` enum. Hand-maintained. */ +export type HealthStatus = 'unspecified' | 'healthy' | 'degraded' | 'unhealthy'; + +/** Lowercase mirror of the generated `SettingScope` enum. Hand-maintained. */ +export type SettingScopeName = 'unspecified' | 'sandbox' | 'global'; + +/** Lowercase mirror of the generated `PolicySource` enum. Hand-maintained. */ +export type PolicySourceName = 'unspecified' | 'sandbox' | 'global'; + +export interface Health { + status: HealthStatus; + version: string; +} + +export interface SandboxSpec { + name?: string; + image?: string; + labels?: Record; + environment?: Record; + providers?: string[]; + gpu?: boolean; + /** + * Create-time sandbox policy (the safety boundary). Sandbox-scoped + * `setPolicy` cannot introduce static fields later, so express filesystem, + * landlock, process, and initial network policy here. + */ + policy?: MessageInitShape; + /** + * Advanced escape hatch: the full generated proto spec. Curated fields build + * the base spec, then `rawSpec` shallow-overrides at the top spec level, so + * any field it sets wins. Use it to reach proto spec fields the curated shape + * does not surface (template runtime class, resource limits, log level, and + * future additions) without an SDK change. + */ + rawSpec?: MessageInitShape; +} + +export interface SandboxRef { + id: string; + name: string; + phase: SandboxPhaseName; + labels: Record; + /** u64 rendered as a string — JS numbers can't hold it safely. */ + resourceVersion: string; +} + +export interface ListOptions { + limit?: number; + offset?: number; + labelSelector?: string; +} + +export interface ExecOptions { + workdir?: string; + environment?: Record; + timeoutSecs?: number; + stdin?: Buffer; + /** Abort the exec (and the in-flight stream RPC) early. */ + signal?: AbortSignal; +} + +export interface ExecResult { + exitCode: number; + stdout: Buffer; + stderr: Buffer; +} + +/** One stdout/stderr chunk yielded by `execStream`/`execInteractive`. */ +export interface ExecStreamChunk { + stream: 'stdout' | 'stderr'; + data: Buffer; +} + +// The terminal event of an exec stream, carrying the command exit code. It is +// yielded in-band (not returned) so `for await` consumers cannot discard it. +// Discriminate against ExecStreamChunk with `'type' in event`. +export interface ExecExitEvent { + type: 'exit'; + exitCode: number; +} + +/** An exec stream item: a stdout/stderr chunk or the terminal exit event. */ +export type ExecStreamEvent = ExecStreamChunk | ExecExitEvent; + +export interface ExecInteractiveOptions { + workdir?: string; + environment?: Record; + timeoutSecs?: number; + /** Request a pseudo-terminal (default true). */ + tty?: boolean; + /** Initial terminal columns (0 = server default). */ + cols?: number; + /** Initial terminal rows (0 = server default). */ + rows?: number; + /** Abort the interactive exec (and the in-flight stream RPC) early. */ + signal?: AbortSignal; +} + +// The transport half of an interactive exec: raw stdin/stdout/stderr plus +// resize, with no terminal glue. Drive it by consuming `output`, which yields +// chunks then a terminal exit event; `done` resolves with the exit code once +// the stream reaches that exit event and rejects if the stream ends without one. +export interface ExecInteractiveSession { + output: AsyncIterable; + write(data: Buffer): void; + resize(cols: number, rows: number): void; + close(): void; + done: Promise; +} + +/** Cancellation for the poll-based wait helpers. */ +export interface WaitOptions { + /** Abort the wait (and the in-flight poll RPC) early. */ + signal?: AbortSignal; +} + +export interface ForwardOptions { + /** Loopback TCP port inside the sandbox to dial. */ + targetPort: number; + /** Target host inside the sandbox (loopback only). Default 127.0.0.1. */ + targetHost?: string; + /** Local port to bind. Default 0 (ephemeral). */ + localPort?: number; + /** Local address to bind. Default 127.0.0.1. */ + localHost?: string; + /** Abort forward setup and tear down the local listener early. */ + signal?: AbortSignal; + /** Receives failures from individual accepted connections. */ + onConnectionError?: (error: SdkError) => void; +} + +// A process-lifetime local listener that tunnels each accepted connection into +// the sandbox. Call `close()` on teardown; `closed` resolves once the listener +// is fully torn down. An in-process forward cannot outlive the Node process. +export interface ForwardHandle { + localHost: string; + localPort: number; + targetHost: string; + targetPort: number; + close(): Promise; + closed: Promise; +} + +export interface SshSession { + sandboxId: string; + token: string; + gatewayHost: string; + gatewayPort: number; + gatewayScheme: string; + hostKeyFingerprint?: string; + /** int64 ms-since-epoch rendered as a string; omitted when 0 (no expiry). */ + expiresAtMs?: string; +} + +export interface ProviderRef { + id: string; + name: string; + type: string; + labels: Record; + /** u64 rendered as a string. */ + resourceVersion: string; +} + +export interface ProviderChange { + sandbox: SandboxRef; + /** True when the attach/detach actually changed the attachment set. */ + changed: boolean; +} + +export interface ProviderChangeOptions { + /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ + expectedResourceVersion?: string; +} + +/** Effective value of one setting plus the scope it resolved from. */ +export interface EffectiveSettingView { + value?: SettingValue; + /** 'unspecified' | 'sandbox' | 'global'. */ + scope: SettingScopeName; +} + +export interface SandboxConfig { + policy?: SandboxPolicy; + version: number; + policyHash: string; + settings: Record; + /** u64 rendered as a string. */ + configRevision: string; + /** 'unspecified' | 'sandbox' | 'global'. */ + policySource: PolicySourceName; + globalPolicyVersion: number; + /** u64 rendered as a string. */ + providerEnvRevision: string; +} + +export interface SetPolicyOptions { + /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ + expectedResourceVersion?: string; + /** Poll getConfig until the applied policy hash is observed. */ + wait?: boolean; + /** Bound the `wait` poll (seconds). Default 60. */ + waitTimeoutSecs?: number; +} + +export interface UpdateConfigResult { + version: number; + policyHash: string; + /** u64 rendered as a string. */ + settingsRevision: string; + deleted: boolean; +} + +// ---- enum → lowercase string ----------------------------------------------- + +// Exported for the enum-name drift test only; not re-exported from index.ts, so +// they are not part of the public package API. +export const PHASE_NAMES: Record = { + [SandboxPhase.UNSPECIFIED]: 'unspecified', + [SandboxPhase.PROVISIONING]: 'provisioning', + [SandboxPhase.READY]: 'ready', + [SandboxPhase.ERROR]: 'error', + [SandboxPhase.DELETING]: 'deleting', + [SandboxPhase.UNKNOWN]: 'unknown', + [SandboxPhase.STOPPING]: 'stopping', + [SandboxPhase.STOPPED]: 'stopped', + [SandboxPhase.STARTING]: 'starting', +}; +export const STATUS_NAMES: Record = { + [ServiceStatus.UNSPECIFIED]: 'unspecified', + [ServiceStatus.HEALTHY]: 'healthy', + [ServiceStatus.DEGRADED]: 'degraded', + [ServiceStatus.UNHEALTHY]: 'unhealthy', +}; +export const SCOPE_NAMES: Record = { + [SettingScope.UNSPECIFIED]: 'unspecified', + [SettingScope.SANDBOX]: 'sandbox', + [SettingScope.GLOBAL]: 'global', +}; +export const POLICY_SOURCE_NAMES: Record = { + [PolicySource.UNSPECIFIED]: 'unspecified', + [PolicySource.SANDBOX]: 'sandbox', + [PolicySource.GLOBAL]: 'global', +}; + +function phaseName(p: SandboxPhase): SandboxPhaseName { + return PHASE_NAMES[p] ?? 'unspecified'; +} +function statusName(s: ServiceStatus): HealthStatus { + return STATUS_NAMES[s] ?? 'unspecified'; +} +function scopeName(s: SettingScope): SettingScopeName { + return SCOPE_NAMES[s] ?? 'unspecified'; +} +function policySourceName(s: PolicySource): PolicySourceName { + return POLICY_SOURCE_NAMES[s] ?? 'unspecified'; +} + +function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { + if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response'); + const meta = sandbox.metadata; + if (!meta?.id || !meta.name) { + throw new SdkError('invalid_config', 'sandbox metadata.id and metadata.name are required in gateway responses'); + } + return { + id: meta.id, + name: meta.name, + phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED), + labels: meta?.labels ?? {}, + resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + }; +} + +function providerRef(provider: Provider): ProviderRef { + const meta = provider.metadata; + return { + id: meta?.id ?? '', + name: meta?.name ?? '', + type: provider.type, + labels: meta?.labels ?? {}, + resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + }; +} + +function sandboxConfig(resp: GetSandboxConfigResponse): SandboxConfig { + const settings: Record = {}; + for (const [key, setting] of Object.entries(resp.settings)) { + settings[key] = effectiveSetting(setting); + } + return { + ...(resp.policy ? { policy: resp.policy } : {}), + version: resp.version, + policyHash: resp.policyHash, + settings, + configRevision: resp.configRevision.toString(), + policySource: policySourceName(resp.policySource), + globalPolicyVersion: resp.globalPolicyVersion, + providerEnvRevision: resp.providerEnvRevision.toString(), + }; +} + +function effectiveSetting(setting: EffectiveSetting): EffectiveSettingView { + return { + ...(setting.value ? { value: setting.value } : {}), + scope: scopeName(setting.scope), + }; +} + +function updateConfigResult(resp: UpdateConfigResponse): UpdateConfigResult { + return { + version: resp.version, + policyHash: resp.policyHash, + settingsRevision: resp.settingsRevision.toString(), + deleted: resp.deleted, + }; +} + +// Optimistic-concurrency version pin: absent/empty means 0n (server uses the +// current version, backward-compatible). A mismatch surfaces as Aborted → +// SdkError code 'aborted'. +function versionPin(value: string | undefined): bigint { + if (!value) return 0n; + let pin: bigint; + try { + pin = BigInt(value); + } catch { + // BigInt() throws a raw SyntaxError on non-integer input; keep the SdkError + // taxonomy intact so callers' errorCode() checks still match. + throw new SdkError('invalid_config', `expectedResourceVersion is not a u64: '${value}'`); + } + if (pin < 0n) { + throw new SdkError('invalid_config', `expectedResourceVersion is not a u64: '${value}'`); + } + return pin; +} + +const FORWARD_CHUNK = 64 * 1024; + +// Build CallOptions that bound one poll RPC by the remaining wall-clock budget +// and honor caller cancellation, so a stalled RPC cannot outlive the deadline. +function deadlineOptions(remainingMs: number, signal?: AbortSignal): CallOptions { + const timeout = AbortSignal.timeout(Math.max(0, remainingMs)); + return { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }; +} + +// Translate a poll failure at the wait boundary: caller cancellation and +// deadline expiry become explicit SdkErrors; anything else propagates. +function mapWaitError(err: unknown, name: string, deadline: number, signal?: AbortSignal): SdkError { + if (signal?.aborted) return new SdkError('connect', `wait for sandbox '${name}' aborted`); + if (Date.now() >= deadline) return new SdkError('connect', `timed out waiting for sandbox '${name}'`); + return err instanceof SdkError ? err : fromConnect(err); +} + +// Sleep between polls, bounded by the remaining deadline and interruptible by +// the caller signal so the returned promise stays within its timeout budget. +function waitSleep(delayMs: number, deadline: number, signal?: AbortSignal): Promise { + const bounded = Math.min(delayMs, Math.max(0, deadline - Date.now())); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, bounded); + const onAbort = (): void => { + clearTimeout(timer); + reject(new SdkError('connect', 'wait aborted')); + }; + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +// Wait for a socket to drain before writing more. Resolves on 'drain', and +// also on 'close'/'error' so a pending await never leaks when the socket is +// torn down mid-backpressure; short-circuits if it is already gone. +function waitForDrain(socket: net.Socket): Promise { + if (socket.writableEnded || socket.destroyed) return Promise.resolve(); + return new Promise((resolve) => { + const done = (): void => { + socket.removeListener('drain', done); + socket.removeListener('close', done); + socket.removeListener('error', done); + resolve(); + }; + socket.once('drain', done); + socket.once('close', done); + socket.once('error', done); + }); +} + +// An async-iterable queue for the client-send half of bidi streams. Producers +// `push()` frames; the connect transport consumes them as it drains the send +// side. `end()` closes the stream (optionally with an error). `onDrain` fires +// when the buffered queue empties via consumption, so callers can relieve TCP +// backpressure. +export class Pushable implements AsyncIterable { + private readonly queue: T[] = []; + private readonly waiting: Array<{ + resolve: (result: IteratorResult) => void; + reject: (error: unknown) => void; + }> = []; + private ended = false; + private error: unknown; + onDrain?: () => void; + + get size(): number { + return this.queue.length; + } + + push(value: T): void { + if (this.ended) return; + const waiter = this.waiting.shift(); + if (waiter) { + waiter.resolve({ value, done: false }); + } else { + this.queue.push(value); + } + } + + end(error?: unknown): void { + if (this.ended) return; + this.ended = true; + this.error = error; + let waiter = this.waiting.shift(); + while (waiter) { + if (error !== undefined) waiter.reject(error); + else waiter.resolve({ value: undefined as never, done: true }); + waiter = this.waiting.shift(); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for (;;) { + if (this.queue.length > 0) { + const value = this.queue.shift() as T; + if (this.queue.length === 0) this.onDrain?.(); + yield value; + continue; + } + if (this.ended) { + if (this.error !== undefined) throw this.error; + return; + } + const next = await new Promise>((resolve, reject) => { + this.waiting.push({ resolve, reject }); + }); + if (next.done) { + if (this.error !== undefined) throw this.error; + return; + } + yield next.value; + } + } +} + +// ---- sandbox client -------------------------------------------------------- + +// Sandbox lifecycle + exec. Usable standalone via `SandboxClient.connect()`, +// or reached as `client.sandbox` on an OpenShellClient, which shares one +// transport (one connection) across all of its scoped clients. +export class SandboxClient { + private readonly grpc: Client; + + /** + * Advanced escape hatch: a generated client for every gateway RPC, including + * surface the curated methods do not wrap yet. Request/response types are the + * generated wire messages (import them from '@nvidia/openshell-sdk/raw'). + */ + readonly raw: Client; + /** The shared Connect transport, for building extra clients over the same connection. */ + readonly transport: Transport; + + // Takes a transport rather than options so OpenShellClient can compose + // several scoped clients over a single connection. For standalone use, + // prefer the SandboxClient.connect() factory below. + constructor(transport: Transport, grpc = createClient(OpenShell, transport)) { + this.transport = transport; + this.grpc = grpc; + this.raw = this.grpc; + } + + /** + * Constructs a lazy Connect client. No network request is made until the + * first RPC; call get() or another operation to verify reachability. + */ + static async connect(options: ConnectOptions): Promise { + return new SandboxClient(buildTransport(options)); + } + + async create(spec: SandboxSpec): Promise { + try { + // Curated fields build the base spec; rawSpec then shallow-overrides at + // the top spec level (Object.assign, so any field it sets wins). The + // runtime assign avoids the generated $typeName upgrading the literal and + // rejecting the curated `template: { image }` init shorthand. + const specInit: MessageInitShape = { + environment: spec.environment ?? {}, + providers: spec.providers ?? [], + template: spec.image ? { image: spec.image } : undefined, + resourceRequirements: spec.gpu ? { gpu: {} } : undefined, + policy: spec.policy, + }; + if (spec.rawSpec) Object.assign(specInit, spec.rawSpec); + + const resp = await this.grpc.createSandbox({ + name: spec.name ?? '', + labels: spec.labels ?? {}, + spec: specInit, + }); + return sandboxRef(resp.sandbox); + } catch (e) { + throw fromConnect(e); + } + } + + async get(name: string, callOptions?: CallOptions): Promise { + try { + const resp = await this.grpc.getSandbox({ name }, callOptions); + return sandboxRef(resp.sandbox); + } catch (e) { + throw fromConnect(e); + } + } + + async list(options?: ListOptions | null): Promise { + try { + const resp = await this.grpc.listSandboxes({ + limit: options?.limit ?? 0, + offset: options?.offset ?? 0, + labelSelector: options?.labelSelector ?? '', + }); + return resp.sandboxes.map((s) => sandboxRef(s)); + } catch (e) { + throw fromConnect(e); + } + } + + async delete(name: string): Promise { + try { + const resp = await this.grpc.deleteSandbox({ name }); + return resp.deleted; + } catch (e) { + throw fromConnect(e); + } + } + + // Poll until the sandbox is ready. The timeout bounds the returned promise, + // not just the sleep loop: each poll RPC carries the remaining deadline (and + // any caller signal), so a stalled get() is aborted rather than hanging. + async waitReady(name: string, timeoutSecs: number, options?: WaitOptions | null): Promise { + const deadline = Date.now() + timeoutSecs * 1000; + const signal = options?.signal; + let delay = 250; + for (;;) { + if (signal?.aborted) throw new SdkError('connect', `wait for sandbox '${name}' aborted`); + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`); + let ref: SandboxRef; + try { + ref = await this.get(name, deadlineOptions(deadline - Date.now(), signal)); + } catch (e) { + throw mapWaitError(e, name, deadline, signal); + } + if (ref.phase === 'ready') return ref; + if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`); + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`); + await waitSleep(delay, deadline, signal); + delay = Math.min(delay * 2, 2000); + } + } + + // Poll until the sandbox is gone. Timeout and cancellation bound the returned + // promise the same way as waitReady. + async waitDeleted(name: string, timeoutSecs: number, options?: WaitOptions | null): Promise { + const deadline = Date.now() + timeoutSecs * 1000; + const signal = options?.signal; + let delay = 250; + for (;;) { + if (signal?.aborted) throw new SdkError('connect', `wait for sandbox '${name}' aborted`); + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); + try { + await this.get(name, deadlineOptions(deadline - Date.now(), signal)); + } catch (e) { + if (e instanceof SdkError && e.code === 'not_found') return; + throw mapWaitError(e, name, deadline, signal); + } + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); + await waitSleep(delay, deadline, signal); + delay = Math.min(delay * 2, 2000); + } + } + + // Stream stdout/stderr as they arrive, then a terminal exit event. The exit + // is yielded in-band (not returned) so `for await` consumers cannot silently + // discard it: a failing command is impossible to miss. If the gateway closes + // the stream without an exit event, this throws. `exec()` drains this same + // path to reconstruct the buffered result. + async *execStream( + name: string, + command: string[], + options?: ExecOptions | null, + ): AsyncGenerator { + try { + // Resolve the sandbox id first, exactly like the gateway client. + const sandbox = await this.get(name, options?.signal ? { signal: options.signal } : undefined); + const stream = this.grpc.execSandbox( + { + sandboxId: sandbox.id, + command, + workdir: options?.workdir ?? '', + environment: options?.environment ?? {}, + timeoutSeconds: options?.timeoutSecs ?? 0, + stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), + tty: false, + }, + { signal: options?.signal }, + ); + + let sawExit = false; + for await (const event of stream) { + switch (event.payload.case) { + case 'stdout': + yield { + stream: 'stdout', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'stderr': + yield { + stream: 'stderr', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'exit': + sawExit = true; + yield { type: 'exit', exitCode: event.payload.value.exitCode }; + break; + } + } + if (!sawExit) throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event'); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let exitCode: number | undefined; + for await (const event of this.execStream(name, command, options)) { + if ('type' in event) { + exitCode = event.exitCode; + } else if (event.stream === 'stdout') { + stdout.push(event.data); + } else { + stderr.push(event.data); + } + } + if (exitCode === undefined) throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event'); + return { + exitCode, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }; + } + + // TTY + stdin transport half of an interactive exec. The first client frame + // is the `start` variant carrying the exec request; subsequent frames are + // `stdin`/`resize`. No terminal glue: raw mode, signal forwarding, and + // SIGWINCH stay with the caller. + async execInteractive( + name: string, + command: string[], + options?: ExecInteractiveOptions | null, + ): Promise { + let sandboxId: string; + try { + sandboxId = (await this.get(name, options?.signal ? { signal: options.signal } : undefined)).id; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + + const input = new Pushable>(); + input.push({ + payload: { + case: 'start', + value: { + sandboxId, + command, + workdir: options?.workdir ?? '', + environment: options?.environment ?? {}, + timeoutSeconds: options?.timeoutSecs ?? 0, + stdin: new Uint8Array(), + tty: options?.tty ?? true, + cols: options?.cols ?? 0, + rows: options?.rows ?? 0, + }, + }, + }); + + const stream = this.grpc.execSandboxInteractive(input, { signal: options?.signal }); + let resolveDone!: (code: number) => void; + let rejectDone!: (err: unknown) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + // `done` may settle before (or without) anyone awaiting it. A lone handler + // keeps an unobserved rejection from surfacing as an unhandledRejection; + // real awaiters still receive it through their own handler. + void done.catch(() => {}); + // Settle exactly once. The exit code wins; error/abandonment only apply + // when no exit was observed. + let settled = false; + const settleExit = (code: number): void => { + if (settled) return; + settled = true; + resolveDone(code); + }; + const settleError = (err: unknown): void => { + if (settled) return; + settled = true; + rejectDone(err); + }; + + async function* output(): AsyncGenerator { + let sawExit = false; + try { + for await (const event of stream) { + switch (event.payload.case) { + case 'stdout': + yield { + stream: 'stdout', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'stderr': + yield { + stream: 'stderr', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'exit': + sawExit = true; + // Settle `done` before yielding: a consumer that breaks on the + // exit event abandons the generator at the yield, so anything + // after it would never run. + settleExit(event.payload.value.exitCode); + yield { type: 'exit', exitCode: event.payload.value.exitCode }; + break; + } + } + if (!sawExit) { + throw new SdkError('rpc', 'ExecSandboxInteractive stream ended without an exit event'); + } + } catch (e) { + const err = e instanceof SdkError ? e : fromConnect(e); + settleError(err); + throw err; + } finally { + input.end(); + // Consumer abandoned the stream before an exit event (early break or + // return): settle `done` so it can never hang. + settleError(new SdkError('rpc', 'exec output abandoned before exit')); + } + } + + return { + output: output(), + write(data: Buffer): void { + input.push({ payload: { case: 'stdin', value: new Uint8Array(data) } }); + }, + resize(cols: number, rows: number): void { + input.push({ payload: { case: 'resize', value: { cols, rows } } }); + }, + close(): void { + input.end(); + }, + done, + }; + } + + // Bind a local TCP listener that tunnels each accepted connection into the + // sandbox. Mirrors the CLI service forward: READY check, then per socket mint + // a short-lived SSH session token, open a forwardTcp bidi whose first frame is + // the `init` (TCP target + token), relay bytes both ways in ~64 KiB chunks, + // and revoke the token on close. Process-lifetime only. + async forward(name: string, opts: ForwardOptions): Promise { + const targetHost = opts.targetHost ?? '127.0.0.1'; + const targetPort = opts.targetPort; + const localHost = opts.localHost ?? '127.0.0.1'; + const localPort = opts.localPort ?? 0; + + let sandboxId: string; + try { + const ref = await this.get(name, opts.signal ? { signal: opts.signal } : undefined); + if (ref.phase !== 'ready') { + throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`); + } + sandboxId = ref.id; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + + const sockets = new Set(); + const controllers = new Set(); + const connectionTasks = new Set>(); + let closing = false; + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + // Guard the window before forwardConnection attaches its own handlers + // (it first awaits createSshSession). Without a synchronous 'error' + // listener a peer reset here emits an unhandled 'error' and crashes the + // process; forwardConnection's catch still tears the socket down. + socket.on('error', () => {}); + const controller = new AbortController(); + controllers.add(controller); + const task = this.forwardConnection(socket, sandboxId, name, targetHost, targetPort, controller.signal) + .catch((error: unknown) => { + if (!closing) { + try { + opts.onConnectionError?.(error instanceof SdkError ? error : fromConnect(error)); + } catch { + // Consumer callbacks must not turn a handled connection failure + // into an unhandled rejection or prevent forward cleanup. + } + } + }) + .finally(() => { + controllers.delete(controller); + connectionTasks.delete(task); + }); + connectionTasks.add(task); + }); + + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + await new Promise((resolve, reject) => { + const onError = (err: unknown): void => { + reject( + new SdkError( + 'io', + `failed to bind local forward on ${localHost}:${localPort}: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }; + server.once('error', onError); + server.listen(localPort, localHost, () => { + server.removeListener('error', onError); + resolve(); + }); + }); + + let teardownPromise: Promise | undefined; + const onAbort = (): void => { + void teardown(); + }; + const teardown = (): Promise => { + if (teardownPromise) return teardownPromise; + closing = true; + opts.signal?.removeEventListener('abort', onAbort); + teardownPromise = (async () => { + for (const controller of controllers) controller.abort(); + for (const socket of sockets) socket.destroy(); + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())); + } + await Promise.allSettled([...connectionTasks]); + resolveClosed(); + })(); + return teardownPromise; + }; + + // Caller cancellation tears the local listener down the same way close() does. + if (opts.signal) { + if (opts.signal.aborted) void teardown(); + else opts.signal.addEventListener('abort', onAbort, { once: true }); + } + + const addr = server.address() as AddressInfo | null; + return { + localHost, + localPort: addr ? addr.port : localPort, + targetHost, + targetPort, + close: teardown, + closed, + }; + } + + private async forwardConnection( + socket: net.Socket, + sandboxId: string, + name: string, + targetHost: string, + targetPort: number, + signal: AbortSignal, + ): Promise { + let token: string | undefined; + const input = new Pushable>(); + input.onDrain = () => socket.resume(); + try { + const session = await this.grpc.createSshSession({ sandboxId }, { signal }); + // Defense-in-depth: the token feeds forwardTcp authorization, so hold it + // to the same trust-boundary contract as createSshSession. A violation + // tears down this one socket via the catch below. + validateSshResponse(session, sandboxId); + token = session.token; + input.push({ + payload: { + case: 'init', + value: { + sandboxId, + serviceId: `service-forward:${name}:${targetHost}:${targetPort}`, + target: { + case: 'tcp', + value: { host: targetHost, port: targetPort }, + }, + authorizationToken: token, + }, + }, + }); + + socket.on('data', (chunk: Buffer) => { + for (let off = 0; off < chunk.length; off += FORWARD_CHUNK) { + const slice = chunk.subarray(off, Math.min(off + FORWARD_CHUNK, chunk.length)); + input.push({ + payload: { case: 'data', value: new Uint8Array(slice) }, + }); + } + if (input.size >= 64) socket.pause(); + }); + socket.on('end', () => input.end()); + socket.on('error', (error) => input.end(error)); + socket.on('close', () => input.end()); + + const onAbort = (): void => { + input.end(new SdkError('canceled', 'forward connection closed')); + socket.destroy(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + + try { + for await (const frame of this.grpc.forwardTcp(input, { signal })) { + if (frame.payload.case !== 'data') continue; + const data = frame.payload.value; + if (data.length === 0) continue; + // Respect backpressure: if the local socket buffer is full, stop + // pulling sandbox data until it drains so memory stays bounded. + if (!socket.write(Buffer.from(data))) await waitForDrain(socket); + } + } finally { + signal.removeEventListener('abort', onAbort); + } + socket.end(); + } catch (e) { + socket.destroy(); + throw e instanceof SdkError ? e : fromConnect(e); + } finally { + input.end(); + if (token !== undefined) { + try { + await this.grpc.revokeSshSession({ token }, { signal }); + } catch { + // Best-effort revoke; the token expires on its own regardless. + } + } + } + } + + // Mint a short-lived SSH session token for the sandbox — the input side of + // ssh-config / ProxyCommand and forwardTcp authorization. + async createSshSession(name: string): Promise { + try { + const sandbox = await this.get(name); + const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id }); + // Reject any response outside the proto trust-boundary contract before + // handing these values to the caller (they feed OpenSSH ProxyCommand). + validateSshResponse(resp, sandbox.id); + return { + sandboxId: resp.sandboxId, + token: resp.token, + gatewayHost: resp.gatewayHost, + gatewayPort: resp.gatewayPort, + gatewayScheme: resp.gatewayScheme, + ...(resp.hostKeyFingerprint ? { hostKeyFingerprint: resp.hostKeyFingerprint } : {}), + ...(resp.expiresAtMs !== 0n ? { expiresAtMs: resp.expiresAtMs.toString() } : {}), + }; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async revokeSshSession(token: string): Promise { + try { + const resp = await this.grpc.revokeSshSession({ token }); + return resp.revoked; + } catch (e) { + throw fromConnect(e); + } + } + + async attachProvider( + name: string, + provider: string, + options?: ProviderChangeOptions | null, + ): Promise { + try { + const resp = await this.grpc.attachSandboxProvider({ + sandboxName: name, + providerName: provider, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached }; + } catch (e) { + throw fromConnect(e); + } + } + + async detachProvider( + name: string, + provider: string, + options?: ProviderChangeOptions | null, + ): Promise { + try { + const resp = await this.grpc.detachSandboxProvider({ + sandboxName: name, + providerName: provider, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached }; + } catch (e) { + throw fromConnect(e); + } + } + + async listProviders(name: string): Promise { + try { + const resp = await this.grpc.listSandboxProviders({ sandboxName: name }); + return resp.providers.map((p) => providerRef(p)); + } catch (e) { + throw fromConnect(e); + } + } + + async getConfig(name: string, callOptions?: CallOptions): Promise { + try { + const sandbox = await this.get(name, callOptions); + const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, callOptions); + return sandboxConfig(resp); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + // Update the sandbox-scoped policy. Sandbox scope (global=false) may only + // change network_policies; static fields must match the create-time policy or + // the gateway rejects the update. With `wait`, poll getConfig until the + // applied policy hash is observed. + async setPolicy( + name: string, + policy: MessageInitShape, + options?: SetPolicyOptions | null, + ): Promise { + try { + const resp = await this.grpc.updateConfig({ + name, + policy, + global: false, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + const result = updateConfigResult(resp); + if (options?.wait) await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs); + return result; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + // Upsert a single sandbox-scoped setting. Sandbox-scoped deletes are rejected + // by the gateway, so there is no sandbox-scoped delete on this surface. + async setSetting( + name: string, + key: string, + value: MessageInitShape, + ): Promise { + try { + const resp = await this.grpc.updateConfig({ + name, + settingKey: key, + settingValue: value, + global: false, + }); + return updateConfigResult(resp); + } catch (e) { + throw fromConnect(e); + } + } + + // Poll getConfig until the applied policy hash is observed. Each poll RPC is + // bounded by the remaining deadline (deadlineOptions), so a stalled getConfig + // cannot make the returned promise outlive timeoutSecs. + private async waitForPolicyHash(name: string, policyHash: string, timeoutSecs = 60): Promise { + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 100; + for (;;) { + let config: SandboxConfig; + try { + config = await this.getConfig(name, deadlineOptions(deadline - Date.now())); + } catch (e) { + if (Date.now() >= deadline) { + throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`); + } + throw e instanceof SdkError ? e : fromConnect(e); + } + if (config.policyHash === policyHash) return; + if (Date.now() >= deadline) { + throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`); + } + await waitSleep(delay, deadline); + delay = Math.min(delay * 2, 2000); + } + } +} + +// ---- The client ------------------------------------------------------------ + +export class OpenShellClient { + /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ + readonly sandbox: SandboxClient; + + /** + * Advanced escape hatch: a generated client for every gateway RPC, including + * surface the curated sub-clients do not wrap yet (gateway config, provider + * CRUD, policy status, watch, logs, and the full observed Sandbox). See + * '@nvidia/openshell-sdk/raw' for the generated request/response types. + */ + readonly raw: Client; + /** The shared Connect transport, for building extra clients over the same connection. */ + readonly transport: Transport; + + private readonly grpc: Client; + + private constructor(transport: Transport) { + // One transport (one connection) shared across every scoped client. + this.transport = transport; + this.grpc = createClient(OpenShell, transport); + this.raw = this.grpc; + this.sandbox = new SandboxClient(transport, this.grpc); + } + + /** + * Constructs a lazy Connect client. No network request is made until the + * first RPC; call health() when startup must verify gateway reachability. + */ + static async connect(options: ConnectOptions): Promise { + return new OpenShellClient(buildTransport(options)); + } + + // Gateway-scoped, so it stays top-level rather than under a namespace. + async health(): Promise { + try { + const resp = await this.grpc.health({}); + return { status: statusName(resp.status), version: resp.version }; + } catch (e) { + throw fromConnect(e); + } + } +} diff --git a/sdk/typescript/src/errors.test.ts b/sdk/typescript/src/errors.test.ts new file mode 100644 index 0000000000..2e525d280b --- /dev/null +++ b/sdk/typescript/src/errors.test.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Unit tests for the error taxonomy: fromConnect() status mapping, the +// preserved ConnectError cause/connectCode, and the errorCode() prefix parser. + +import { Code, ConnectError } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { errorCode, fromConnect, SdkError, type SdkErrorCode } from './errors.js'; + +describe('fromConnect', () => { + const cases: Array<[Code, SdkErrorCode]> = [ + [Code.NotFound, 'not_found'], + [Code.AlreadyExists, 'already_exists'], + [Code.Aborted, 'aborted'], + [Code.Canceled, 'canceled'], + [Code.DeadlineExceeded, 'canceled'], + [Code.InvalidArgument, 'invalid_config'], + [Code.Unauthenticated, 'auth'], + [Code.PermissionDenied, 'auth'], + [Code.Internal, 'rpc'], + ]; + + for (const [code, expected] of cases) { + it(`maps Connect ${Code[code]} to '${expected}'`, () => { + const ce = new ConnectError('boom', code); + const err = fromConnect(ce); + expect(err).toBeInstanceOf(SdkError); + expect(err.code).toBe(expected); + // The originating ConnectError is preserved for inspection. + expect(err.cause).toBe(ce); + expect(err.connectCode).toBe(code); + // errorCode() still recovers the prefix from the message. + expect(errorCode(err)).toBe(expected); + }); + } + + it('distinguishes optimistic-concurrency conflicts from generic rpc failures', () => { + const aborted = fromConnect(new ConnectError('version mismatch', Code.Aborted)); + const generic = fromConnect(new ConnectError('boom', Code.Internal)); + expect(aborted.code).toBe('aborted'); + expect(generic.code).toBe('rpc'); + expect(aborted.code).not.toBe(generic.code); + }); +}); + +describe('SdkError', () => { + it('prefixes the message with [code] and exposes the union member', () => { + const err = new SdkError('invalid_config', 'bad value'); + expect(err.message).toBe('[invalid_config] bad value'); + expect(err.code).toBe('invalid_config'); + expect(errorCode(err)).toBe('invalid_config'); + }); + + it('preserves the cause and connectCode when provided', () => { + const ce = new ConnectError('missing', Code.NotFound); + const err = new SdkError('not_found', ce.rawMessage, { cause: ce, connectCode: ce.code }); + expect(err.cause).toBe(ce); + expect(err.connectCode).toBe(Code.NotFound); + }); +}); diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000000..7eddcb4aa2 --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Error taxonomy — every thrown error message is prefixed with `[code] ` so +// callers can discriminate with errorCode(). This mirrors the shape the (now +// retired) napi binding exposed, kept stable so consumers migrating off it see +// an identical contract. + +import { Code, ConnectError } from '@connectrpc/connect'; + +export type SdkErrorCode = + | 'invalid_config' + | 'tls' + | 'connect' + | 'auth' + | 'io' + | 'not_found' + | 'already_exists' + | 'aborted' + | 'canceled' + | 'rpc'; + +/** Extra context attached to an SdkError raised from a Connect RPC. */ +export interface SdkErrorOptions { + /** The original error, preserved so callers can inspect the underlying cause. */ + cause?: unknown; + /** The Connect status code, so callers can inspect it without parsing text. */ + connectCode?: Code; +} + +export class SdkError extends Error { + readonly code: SdkErrorCode; + /** The Connect status code when this error originated from an RPC. */ + readonly connectCode?: Code; + constructor(code: SdkErrorCode, message: string, options?: SdkErrorOptions) { + // Format `[code] message` so errorCode() can recover the code from any Error. + super(`[${code}] ${message}`, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'SdkError'; + this.code = code; + if (options?.connectCode !== undefined) this.connectCode = options.connectCode; + } +} + +// Map a gRPC status (surfaced by connect-es as ConnectError) onto our codes. +// The originating ConnectError is kept as `cause` and its status as +// `connectCode` so callers can inspect the Connect status directly. +export function fromConnect(err: unknown): SdkError { + // Curated response validation also runs inside RPC try/catch blocks. Preserve + // those SDK errors instead of remapping them to a generic Connect status. + if (err instanceof SdkError) return err; + const ce = ConnectError.from(err); + const options: SdkErrorOptions = { cause: ce, connectCode: ce.code }; + switch (ce.code) { + case Code.NotFound: + return new SdkError('not_found', ce.rawMessage, options); + case Code.AlreadyExists: + return new SdkError('already_exists', ce.rawMessage, options); + case Code.Aborted: + return new SdkError('aborted', ce.rawMessage, options); + case Code.Canceled: + case Code.DeadlineExceeded: + return new SdkError('canceled', ce.rawMessage, options); + case Code.InvalidArgument: + return new SdkError('invalid_config', ce.rawMessage, options); + case Code.Unauthenticated: + case Code.PermissionDenied: + return new SdkError('auth', ce.rawMessage, options); + default: + return new SdkError('rpc', ce.rawMessage, options); + } +} + +// Extract the `[code]` prefix from any error message. +export function errorCode(err: unknown): string | null { + const msg = err instanceof Error ? err.message : String(err); + const m = /^\[([a-z_]+)\]/.exec(msg); + return m ? m[1] : null; +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000000..6dae221887 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Public API surface for @nvidia/openshell-sdk. +// +// OidcRefresher (single-flight OIDC refresh) is intentionally not yet exported. +// It is the one piece of genuinely shared, cross-language behavior; it will be +// added alongside a conformance suite that pins it byte-identical across the +// TypeScript, Python, and Go SDKs. + +export type { + ConnectOptions, + EffectiveSettingView, + ExecExitEvent, + ExecInteractiveOptions, + ExecInteractiveSession, + ExecOptions, + ExecResult, + ExecStreamChunk, + ExecStreamEvent, + ForwardHandle, + ForwardOptions, + Health, + HealthStatus, + ListOptions, + PolicySourceName, + ProviderChange, + ProviderChangeOptions, + ProviderRef, + SandboxConfig, + SandboxPhaseName, + SandboxPolicy, + SandboxRef, + SandboxSpec, + SetPolicyOptions, + SettingScopeName, + SettingValue, + SshSession, + UpdateConfigResult, + WaitOptions, +} from './client.js'; +export { errorCode, OpenShellClient, SandboxClient } from './client.js'; +export type { SdkErrorCode } from './errors.js'; +export { SdkError } from './errors.js'; diff --git a/sdk/typescript/src/raw.ts b/sdk/typescript/src/raw.ts new file mode 100644 index 0000000000..b0be2cd9bf --- /dev/null +++ b/sdk/typescript/src/raw.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './gen/datamodel_pb.js'; +// Advanced surface: the full generated protobuf types (messages, enums, and the +// OpenShell service descriptor) for callers using the raw escape hatch on +// OpenShellClient / SandboxClient (`.raw` and `.transport`). These are the +// uncurated wire types; import them from '@nvidia/openshell-sdk/raw'. The +// curated entry point stays free of generated types so its surface does not +// shift when the proto regenerates. The four generated modules export disjoint +// symbol names, so a flat re-export is unambiguous. +export * from './gen/openshell_pb.js'; +export * from './gen/options_pb.js'; +export * from './gen/sandbox_pb.js'; diff --git a/sdk/typescript/src/ssh-validate.ts b/sdk/typescript/src/ssh-validate.ts new file mode 100644 index 0000000000..2a62498a6b --- /dev/null +++ b/sdk/typescript/src/ssh-validate.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Trust-boundary validation for CreateSshSession responses. The gateway's +// values are interpolated into an OpenSSH `ProxyCommand` that OpenSSH runs +// through `/bin/sh -c` on the caller's workstation, so proto/openshell.proto +// (CreateSshSessionResponse) says clients MUST reject responses outside the +// specified character sets and ranges. This enforces exactly that contract at +// the SDK edge so no consumer has to rediscover the invariant. + +import { isIP } from 'node:net'; +import { SdkError } from './errors.js'; + +// Charsets and bounds mirror the proto CreateSshSessionResponse field comments. +const SANDBOX_ID = /^[A-Za-z0-9._-]{1,128}$/; +const TOKEN = /^[A-Za-z0-9._~+/=-]+$/; +const FINGERPRINT = /^[A-Za-z0-9:+/=-]+$/; + +/** The subset of the response the SDK validates and forwards. */ +export interface SshResponseFields { + sandboxId: string; + token: string; + gatewayHost: string; + gatewayPort: number; + gatewayScheme: string; + hostKeyFingerprint: string; +} + +function reject(field: string, detail: string): never { + throw new SdkError('invalid_config', `CreateSshSession response ${field} ${detail}`); +} + +function validGatewayHost(host: string): boolean { + if (isIP(host) === 4) return true; + if (host.startsWith('[') && host.endsWith(']')) { + return isIP(host.slice(1, -1)) === 6; + } + + const dns = host.endsWith('.') ? host.slice(0, -1) : host; + if (dns.length === 0) return false; + return dns.split('.').every((label) => { + return label.length >= 1 && label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label); + }); +} + +// Throw SdkError('invalid_config') if any field violates the proto contract. +export function validateSshResponse(resp: SshResponseFields, expectedSandboxId?: string): void { + if (!SANDBOX_ID.test(resp.sandboxId)) { + reject('sandbox_id', 'must match [A-Za-z0-9._-]{1,128}'); + } + if (expectedSandboxId !== undefined && resp.sandboxId !== expectedSandboxId) { + reject('sandbox_id', `must match requested sandbox '${expectedSandboxId}'`); + } + + const tokenBytes = Buffer.byteLength(resp.token, 'utf8'); + if (tokenBytes < 1 || tokenBytes > 4096 || !TOKEN.test(resp.token)) { + reject('token', 'must be 1..4096 bytes of [A-Za-z0-9._~+/=-]'); + } + + const hostBytes = Buffer.byteLength(resp.gatewayHost, 'utf8'); + if (hostBytes < 1 || hostBytes > 253 || !validGatewayHost(resp.gatewayHost)) { + reject('gateway_host', 'must be a valid DNS name, IPv4 address, or bracketed IPv6 address'); + } + + if (!Number.isInteger(resp.gatewayPort) || resp.gatewayPort < 1 || resp.gatewayPort > 65535) { + reject('gateway_port', 'must be an integer in 1..65535'); + } + + if (resp.gatewayScheme !== 'http' && resp.gatewayScheme !== 'https') { + reject('gateway_scheme', "must be exactly 'http' or 'https'"); + } + + const fingerprintBytes = Buffer.byteLength(resp.hostKeyFingerprint, 'utf8'); + if (resp.hostKeyFingerprint !== '' && (fingerprintBytes > 256 || !FINGERPRINT.test(resp.hostKeyFingerprint))) { + reject('host_key_fingerprint', 'must be at most 256 bytes of [A-Za-z0-9:+/=-] when non-empty'); + } +} diff --git a/sdk/typescript/src/transport.test.ts b/sdk/typescript/src/transport.test.ts new file mode 100644 index 0000000000..ff4469d893 --- /dev/null +++ b/sdk/typescript/src/transport.test.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Unit tests for buildTransport. These cover the mTLS client-material pairing +// contract without a live gateway: only PEM bytes are validated, no handshake +// is performed. + +import { describe, expect, it } from 'vitest'; +import { errorCode } from './errors.js'; +import { buildTransport } from './transport.js'; + +const pem = (label: string) => Buffer.from(`-----BEGIN ${label}-----\ntest\n-----END ${label}-----\n`); + +describe('buildTransport mTLS pairing', () => { + it('throws when only clientCert is provided', () => { + const fn = () => buildTransport({ gateway: 'https://gw.local', clientCert: pem('CERTIFICATE') }); + expect(fn).toThrow(/clientKey is missing/); + try { + fn(); + } catch (e) { + expect(errorCode(e)).toBe('invalid_config'); + } + }); + + it('throws when only clientKey is provided', () => { + const fn = () => buildTransport({ gateway: 'https://gw.local', clientKey: pem('PRIVATE KEY') }); + expect(fn).toThrow(/clientCert is missing/); + try { + fn(); + } catch (e) { + expect(errorCode(e)).toBe('invalid_config'); + } + }); + + it('accepts both clientCert and clientKey', () => { + const transport = buildTransport({ + gateway: 'https://gw.local', + clientCert: pem('CERTIFICATE'), + clientKey: pem('PRIVATE KEY'), + }); + expect(transport).toBeTruthy(); + }); + + it('accepts neither (server-only trust)', () => { + const transport = buildTransport({ gateway: 'https://gw.local', caCert: pem('CERTIFICATE') }); + expect(transport).toBeTruthy(); + }); + + it('accepts neither on an http gateway', () => { + const transport = buildTransport({ gateway: 'http://gw.local' }); + expect(transport).toBeTruthy(); + }); +}); + +describe('buildTransport token exclusivity', () => { + it('throws when both oidcToken and edgeToken are set', () => { + const fn = () => buildTransport({ gateway: 'https://gw.local', oidcToken: 'a', edgeToken: 'b' }); + expect(fn).toThrow(/mutually exclusive/); + try { + fn(); + } catch (e) { + expect(errorCode(e)).toBe('invalid_config'); + } + }); + + it('rejects edge tokens that could inject cookies or headers', () => { + for (const edgeToken of ['', 'jwt; other=value', 'jwt\r\nx-injected: yes', 'jwt with spaces']) { + const fn = () => buildTransport({ gateway: 'https://gw.local', edgeToken }); + expect(fn).toThrow(/cookie-safe JWT characters/); + try { + fn(); + } catch (e) { + expect(errorCode(e)).toBe('invalid_config'); + } + } + }); + + it('accepts a base64url JWT edge token', () => { + expect( + buildTransport({ gateway: 'https://gw.local', edgeToken: 'eyJhbGciOiJSUzI1NiJ9.payload_signature' }), + ).toBeTruthy(); + }); +}); + +describe('buildTransport plaintext auth guard', () => { + it('rejects a token over http:// to a non-loopback host', () => { + const fn = () => buildTransport({ gateway: 'http://gw.remote:8080', oidcToken: 'a' }); + expect(fn).toThrow(/non-loopback/); + try { + fn(); + } catch (e) { + expect(errorCode(e)).toBe('invalid_config'); + } + }); + + it('allows a token over http:// to loopback hosts', () => { + expect(buildTransport({ gateway: 'http://127.0.0.1:8080', oidcToken: 'a' })).toBeTruthy(); + expect(buildTransport({ gateway: 'http://[::1]:8080', edgeToken: 'a' })).toBeTruthy(); + expect(buildTransport({ gateway: 'http://localhost:8080', oidcToken: 'a' })).toBeTruthy(); + }); + + it('allows a token over http:// to a remote host when allowInsecureAuth is set', () => { + expect(buildTransport({ gateway: 'http://gw.remote:8080', oidcToken: 'a', allowInsecureAuth: true })).toBeTruthy(); + }); + + it('allows a token over https:// to any host', () => { + expect(buildTransport({ gateway: 'https://gw.remote', oidcToken: 'a' })).toBeTruthy(); + }); + + it('allows a tokenless http:// gateway to any host', () => { + expect(buildTransport({ gateway: 'http://gw.remote:8080' })).toBeTruthy(); + }); +}); diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts new file mode 100644 index 0000000000..2d5092b894 --- /dev/null +++ b/sdk/typescript/src/transport.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Transport + auth layer. h2c for `http://` (local dev), Node TLS passthrough +// for `https://` (CA pinning, insecure-skip-verify), and an interceptor that +// attaches the OIDC bearer or Cloudflare Access headers. +// +// Not covered here: the Cloudflare-Access WebSocket tunnel (the gateway's edge +// proxy). That ships as a language-agnostic sidecar bound to 127.0.0.1 — point +// `gateway` at it. When the edge passes gRPC POST directly, the header mode +// below suffices. + +import type { Interceptor, Transport } from '@connectrpc/connect'; +import { createGrpcTransport } from '@connectrpc/connect-node'; +import { SdkError } from './errors.js'; + +export interface ConnectOptions { + /** Gateway URL (`http://...` or `https://...`). */ + gateway: string; + /** CA certificate (PEM). Omit to use system roots. */ + caCert?: Buffer; + /** + * Client certificate (PEM) for mTLS. Authenticates the CALLER, not just the + * server. The default local OpenShell gateway (Docker, VM, Homebrew, Linux + * package) requires this. Must be paired with clientKey. + */ + clientCert?: Buffer; + /** Client private key (PEM) for mTLS. Must be paired with clientCert. */ + clientKey?: Buffer; + /** Bearer token for direct OIDC auth. Mutually exclusive with edgeToken. */ + oidcToken?: string; + /** Cloudflare Access token. See the sidecar note above for CF-fronted gateways. */ + edgeToken?: string; + /** Disable TLS verification (dev/debug only). */ + insecureSkipVerify?: boolean; + /** + * Permit sending an auth token (oidcToken/edgeToken) over plaintext `http://` + * to a non-loopback host. Off by default: tokens over cleartext to a remote + * host leak credentials on the wire. Loopback hosts are always allowed. + */ + allowInsecureAuth?: boolean; +} + +// OIDC bearer takes precedence; otherwise attach the Cloudflare Access header + +// cookie. No-op when neither token is set. +function authInterceptor(opts: ConnectOptions): Interceptor { + return (next) => async (req) => { + if (opts.oidcToken) { + req.header.set('authorization', `Bearer ${opts.oidcToken}`); + } else if (opts.edgeToken) { + req.header.set('cf-access-jwt-assertion', opts.edgeToken); + req.header.set('cookie', `CF_Authorization=${opts.edgeToken}`); + } + return next(req); + }; +} + +// The client certificate and key are an all-or-nothing pair: a cert without a +// key (or a key without a cert) cannot complete an mTLS handshake, so reject it +// up front rather than surfacing an opaque TLS failure at connect time. +function assertMtlsPair(opts: ConnectOptions): void { + const hasCert = opts.clientCert !== undefined; + const hasKey = opts.clientKey !== undefined; + if (hasCert !== hasKey) { + const missing = hasCert ? 'clientKey' : 'clientCert'; + throw new SdkError('invalid_config', `mTLS requires both clientCert and clientKey; ${missing} is missing`); + } +} + +// oidcToken and edgeToken are documented as mutually exclusive; the interceptor +// silently prefers OIDC when both are set. Reject that ambiguity up front so a +// caller does not think an edge token is in effect when it is being ignored. +function assertTokenExclusivity(opts: ConnectOptions): void { + if (opts.oidcToken !== undefined && opts.edgeToken !== undefined) { + throw new SdkError('invalid_config', 'oidcToken and edgeToken are mutually exclusive'); + } +} + +// edgeToken is also interpolated into a Cookie header. Restrict it to the +// cookie-safe base64url/JWT character set so a caller cannot inject a second +// cookie or a new header through an untrusted token value. +function assertEdgeToken(opts: ConnectOptions): void { + if (opts.edgeToken !== undefined && !/^[A-Za-z0-9._~-]+$/.test(opts.edgeToken)) { + throw new SdkError('invalid_config', 'edgeToken must contain only cookie-safe JWT characters'); + } +} + +function isLoopbackHost(host: string): boolean { + // URL.hostname keeps the brackets on IPv6 literals (e.g. `[::1]`); strip them. + const h = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + if (h === 'localhost' || h === '::1') return true; + return /^127(?:\.\d{1,3}){3}$/.test(h); +} + +// Attaching a bearer/CF token to a plaintext `http://` request to a non-loopback +// host puts the credential on the wire in the clear. Refuse it unless the caller +// explicitly opts in. Loopback (local-dev / edge-sidecar) is always fine. +function assertTokenTransportSecurity(opts: ConnectOptions): void { + const hasToken = opts.oidcToken !== undefined || opts.edgeToken !== undefined; + if (!hasToken || opts.allowInsecureAuth || opts.gateway.startsWith('https://')) return; + let host: string; + try { + host = new URL(opts.gateway).hostname; + } catch { + return; // A malformed gateway URL surfaces from the transport itself. + } + if (!isLoopbackHost(host)) { + throw new SdkError( + 'invalid_config', + `refusing to send an auth token over plaintext http:// to non-loopback host '${host}'; use https:// or set allowInsecureAuth`, + ); + } +} + +export function buildTransport(opts: ConnectOptions): Transport { + assertMtlsPair(opts); + assertTokenExclusivity(opts); + assertEdgeToken(opts); + assertTokenTransportSecurity(opts); + const isTls = opts.gateway.startsWith('https://'); + return createGrpcTransport({ + baseUrl: opts.gateway, + interceptors: [authInterceptor(opts)], + // For https:// gateways, pass Node TLS options straight through. For + // http:// (local dev) these are ignored and the client speaks h2c. + nodeOptions: isTls + ? { + ca: opts.caCert, + cert: opts.clientCert, + key: opts.clientKey, + rejectUnauthorized: opts.insecureSkipVerify ? false : undefined, + } + : undefined, + }); +} diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json new file mode 100644 index 0000000000..f9e1a948c2 --- /dev/null +++ b/sdk/typescript/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000000..9fc969d5d5 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/sdk/typescript/vitest.config.ts b/sdk/typescript/vitest.config.ts new file mode 100644 index 0000000000..823e6ad505 --- /dev/null +++ b/sdk/typescript/vitest.config.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/gen/**'], + reporter: ['text'], + thresholds: { + lines: 80, + }, + }, + }, +}); diff --git a/tasks/ci.toml b/tasks/ci.toml index 38e428cf48..4c0b5f8ea7 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -37,7 +37,7 @@ run = [ [check] description = "Run fast compile and type checks" -depends = ["rust:check", "python:typecheck"] +depends = ["rust:check", "python:typecheck", "sdk:ts:typecheck"] hide = true [clean] @@ -46,12 +46,23 @@ run = "cargo clean" [fmt] description = "Format code" -depends = ["rust:format", "python:format", "markdown:format"] +depends = ["rust:format", "python:format", "markdown:format", "sdk:ts:format"] hide = true [lint] description = "Run repository lint checks" -depends = ["license:check", "rust:format:check", "rust:lint", "python:format:check", "python:lint", "helm:lint", "helm:docs:check", "markdown:lint"] +depends = [ + "license:check", + "rust:format:check", + "rust:lint", + "python:format:check", + "python:lint", + "helm:lint", + "helm:docs:check", + "markdown:lint", + "proto:lint", + "sdk:ts:lint", +] hide = true [ci] diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index 1996cf6f84..243c72e8ee 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -20,6 +20,7 @@ class Versions: python: str cargo: str + npm: str docker: str deb: str snap: str @@ -106,6 +107,12 @@ def _versions_from_parts( # 0.1.0.dev3+gabcdef -> 0.1.0-dev.3+gabcdef cargo_version = re.sub(r"\.dev(\d+)", r"-dev.\1", python_version) + # npm follows SemVer 2.0 like Cargo, but fold the '+g' build metadata + # into the prerelease (npm/registries treat build metadata as insignificant + # for version identity, so each dev build must differ in the prerelease). + # 0.1.0-dev.3+gabcdef -> 0.1.0-dev.3.gabcdef ; a tagged release stays 0.1.0. + npm_version = cargo_version.replace("+", ".") + # Docker tags can't contain '+'. docker_version = cargo_version.replace("+", "-") @@ -123,6 +130,7 @@ def _versions_from_parts( return Versions( python=python_version, cargo=cargo_version, + npm=npm_version, docker=docker_version, deb=deb_version, snap=snap_version, @@ -154,6 +162,7 @@ def _compute_versions() -> Versions: def _print_env(versions: Versions) -> None: print(f"VERSION_PY={versions.python}") print(f"VERSION_CARGO={versions.cargo}") + print(f"VERSION_NPM={versions.npm}") print(f"VERSION_DOCKER={versions.docker}") print(f"VERSION_DEB={versions.deb}") print(f"VERSION_SNAP={versions.snap}") @@ -170,6 +179,8 @@ def get_version(format: str) -> None: print(versions.python) elif format == "cargo": print(versions.cargo) + elif format == "npm": + print(versions.npm) elif format == "docker": print(versions.docker) elif format == "deb": @@ -419,6 +430,9 @@ def build_parser() -> argparse.ArgumentParser: get_version_parser.add_argument( "--cargo", action="store_true", help="Print Cargo version only." ) + get_version_parser.add_argument( + "--npm", action="store_true", help="Print npm version only." + ) get_version_parser.add_argument( "--docker", action="store_true", help="Print Docker version only." ) @@ -472,6 +486,8 @@ def main() -> None: get_version("python") elif args.cargo: get_version("cargo") + elif args.npm: + get_version("npm") elif args.docker: get_version("docker") elif args.deb: diff --git a/tasks/test.toml b/tasks/test.toml index 7792b7f7c6..d50750e9cb 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -1,11 +1,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Test tasks (Rust + Python) +# Test tasks (Rust + Python + TypeScript SDK) [test] -description = "Run all tests (Rust + Python)" -depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "test:build-env", "test:packaging-assets", "test:docs-website"] +description = "Run all tests (Rust + Python + TypeScript SDK)" +depends = [ + "test:rust", + "test:python", + "sdk:ts:test", + "test:sbom", + "test:install-sh", + "test:build-env", + "test:packaging-assets", + "test:docs-website", +] ["test:docs-website"] description = "Test the docs-website sync script" diff --git a/tasks/typescript.toml b/tasks/typescript.toml new file mode 100644 index 0000000000..823a881359 --- /dev/null +++ b/tasks/typescript.toml @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# TypeScript SDK tasks (sdk/typescript). Codegen runs `buf generate` (buf and +# the connect-es plugin come from the package's own devDependencies); buf +# self-compiles proto/, so no protoc is required. + +["sdk:ts:install"] +description = "Install TypeScript SDK dependencies" +dir = "sdk/typescript" +run = "npm ci" +hide = true + +["sdk:ts:proto"] +description = "Generate TypeScript protobuf stubs for the SDK" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run gen" + +# Lints the repo-level proto module (buf.yaml at the root) against STANDARD. +# buf ships only in the SDK's devDependencies today, so this depends on the +# SDK install and runs buf from there; the target is all of proto/, not just +# the SDK's client-surface subset. +["proto:lint"] +description = "Lint proto/ with buf (repo-level buf.yaml)" +depends = ["sdk:ts:install"] +run = "./sdk/typescript/node_modules/.bin/buf lint" + +["sdk:ts:typecheck"] +description = "Type-check the TypeScript SDK" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run typecheck" + +["sdk:ts:lint"] +description = "Lint + format-check the TypeScript SDK (Biome, read-only)" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run lint" + +["sdk:ts:format"] +description = "Format the TypeScript SDK and apply safe fixes (Biome, writes)" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run format" + +["sdk:ts:build"] +description = "Build the TypeScript SDK (emit dist/)" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run build" + +["sdk:ts:test"] +description = "Run TypeScript SDK unit tests (Vitest, in-memory transport)" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm test" + +["sdk:ts:ci"] +description = "TypeScript SDK checks (proto lint, Biome lint, codegen, typecheck, test, build)" +depends = [ + "proto:lint", + "sdk:ts:lint", + "sdk:ts:typecheck", + "sdk:ts:test", + "sdk:ts:build", +] +hide = true + +# Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION +# to stamp the version from the release tag (release.py get-version --npm); the +# package.json placeholder 0.0.0 is restored afterward, mirroring the Cargo +# version stamping in tasks/python.toml. Auth is expected via a .npmrc the caller +# writes (CI) or the user's own npm login. +# +# Prerelease versions (e.g. 0.0.37-dev.N.gSHA from an off-tag build) must not +# claim the `latest` dist-tag, so they publish under `next` instead — npm also +# refuses a bare `npm publish` for a prerelease. Set OPENSHELL_NPM_PUBLISH_ARGS +# (e.g. `--dry-run`) to pass extra flags through; CI uses this to validate the +# publishable artifact on PRs without uploading. +["sdk:ts:publish"] +description = "Publish the TypeScript SDK to its configured registry" +depends = ["sdk:ts:build"] +dir = "sdk/typescript" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +ORIGINAL_PKG="" +cleanup() { + if [ -n "$ORIGINAL_PKG" ] && [ -f "$ORIGINAL_PKG" ]; then + cp "$ORIGINAL_PKG" package.json + rm -f "$ORIGINAL_PKG" + fi +} +trap cleanup EXIT + +DIST_TAG="latest" +if [ -n "${OPENSHELL_NPM_VERSION:-}" ]; then + ORIGINAL_PKG=$(mktemp) + cp package.json "$ORIGINAL_PKG" + npm pkg set version="$OPENSHELL_NPM_VERSION" + # A hyphen means a SemVer prerelease (X.Y.Z-dev.N...) -> publish off `latest`. + case "$OPENSHELL_NPM_VERSION" in + *-*) DIST_TAG="next" ;; + esac +fi + +npm publish --tag "$DIST_TAG" ${OPENSHELL_NPM_PUBLISH_ARGS:-} +""" +hide = true