From 0f48671c5bfa90a82580e8db5e7e0a49d71919d6 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 2 Jul 2026 14:15:55 -0400 Subject: [PATCH 01/14] feat(sdk): add TypeScript SDK (@nvidia/openshell-sdk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First native, per-language SDK for the OpenShell gateway: a thin, idiomatic TypeScript client over proto-generated gRPC stubs (connect-es), no FFI. Covers the v0.1 surface — sandbox lifecycle (create/get/list/delete + waitReady/ waitDeleted), health, and streamed exec. - sdk/typescript/: package, client/transport/errors, protoc + protoc-gen-es codegen (gen/ gitignored, absorbed into dist/ at build), committed lockfile. - tasks/typescript.toml: sdk:ts install/proto/typecheck/build/ci/publish; sdk:ts:typecheck wired into `check`; sdk-typescript job in branch-checks (typecheck, build, and a --dry-run publish that validates the release path). - Enforce SPDX headers on .ts/.tsx/.mts/.cts (skip node_modules and gen/); back-fill docs/_components/jsx.d.ts and fern/components/CustomFooter.tsx. - release.py gains an npm version format; release-tag.yml publishes to GitHub Packages on tag, stamping the version (0.0.0 placeholder in git); prerelease builds publish under the `next` dist-tag, not `latest`. Ships as @nvidia/openshell-sdk on GitHub Packages pre-GA; public npm (@openshell/sdk) follows at GA with an unchanged public API. Signed-off-by: Max Dubrinsky --- .github/workflows/branch-checks.yml | 29 ++ .github/workflows/release-tag.yml | 40 ++ docs/_components/jsx.d.ts | 3 + fern/components/CustomFooter.tsx | 3 + scripts/update_license_headers.py | 9 + sdk/typescript/.gitignore | 5 + sdk/typescript/README.md | 50 ++ sdk/typescript/gen.sh | 29 ++ sdk/typescript/package-lock.json | 693 ++++++++++++++++++++++++++++ sdk/typescript/package.json | 46 ++ sdk/typescript/src/client.ts | 217 +++++++++ sdk/typescript/src/demo.ts | 56 +++ sdk/typescript/src/errors.ts | 54 +++ sdk/typescript/src/index.ts | 20 + sdk/typescript/src/transport.ts | 57 +++ sdk/typescript/tsconfig.build.json | 13 + sdk/typescript/tsconfig.json | 15 + tasks/ci.toml | 2 +- tasks/scripts/release.py | 16 + tasks/typescript.toml | 77 ++++ 20 files changed, 1433 insertions(+), 1 deletion(-) create mode 100644 sdk/typescript/.gitignore create mode 100644 sdk/typescript/README.md create mode 100644 sdk/typescript/gen.sh create mode 100644 sdk/typescript/package-lock.json create mode 100644 sdk/typescript/package.json create mode 100644 sdk/typescript/src/client.ts create mode 100644 sdk/typescript/src/demo.ts create mode 100644 sdk/typescript/src/errors.ts create mode 100644 sdk/typescript/src/index.ts create mode 100644 sdk/typescript/src/transport.ts create mode 100644 sdk/typescript/tsconfig.build.json create mode 100644 sdk/typescript/tsconfig.json create mode 100644 tasks/typescript.toml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 7713febb6f..08ae357f96 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -190,3 +190,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 ec95ab5ea1..1485e62615 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" @@ -1067,6 +1069,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/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 f56dbc2935..55d009d956 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 = str(rel) + # 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..479147314a --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,50 @@ +# @nvidia/openshell-sdk + +TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. + +Distributed via GitHub Packages during pre-GA. Public npm (`@openshell/sdk`) follows at GA; the public API is unchanged across the move, so only the install specifier changes. + +## Install + +Published to GitHub Packages, so add 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.createSandbox({ + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', +}) +await client.waitReady(sandbox.name, 120) + +const result = await client.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +console.log(result.stdout.toString()) + +await client.deleteSandbox(sandbox.name) +``` + +## 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/ (protoc + protoc-gen-es) +mise run sdk:ts:typecheck # tsc --noEmit +mise run sdk:ts:build # emit dist/ +``` diff --git a/sdk/typescript/gen.sh b/sdk/typescript/gen.sh new file mode 100644 index 0000000000..a1f3b51565 --- /dev/null +++ b/sdk/typescript/gen.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Generate idiomatic TypeScript from the OpenShell protos using the pinned +# `protoc` toolchain (mise) + the connect-es plugin (@bufbuild/protoc-gen-es). +# No `buf` required. Well-known types (Struct, Timestamp, ...) resolve via +# protoc's bundled include path and render through @bufbuild/protobuf/wkt, so +# we do not generate them ourselves. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PROTO_DIR="$HERE/../../proto" +OUT="$HERE/src/gen" +PLUGIN="$HERE/node_modules/.bin/protoc-gen-es" + +rm -rf "$OUT" +mkdir -p "$OUT" + +# Only the files the client-facing surface needs (transitive imports included). +protoc \ + -I "$PROTO_DIR" \ + --plugin=protoc-gen-es="$PLUGIN" \ + --es_out="$OUT" \ + --es_opt=target=ts,import_extension=js \ + datamodel.proto sandbox.proto openshell.proto + +echo "generated into $OUT:" +ls -1 "$OUT" diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000000..227896dd68 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,693 @@ +{ + "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": { + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" + } + }, + "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/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/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/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/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/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "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" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..6fb1322232 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,46 @@ +{ + "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" + } + }, + "files": ["dist", "README.md"], + "engines": { + "node": ">=20" + }, + "scripts": { + "gen": "bash gen.sh", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "demo": "tsx src/demo.ts", + "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": { + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..96ee8b0d51 --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,217 @@ +// 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/). It owns proto request assembly, +// 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 { createClient, type Client } from '@connectrpc/connect' +import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' +import type { Sandbox } from './gen/openshell_pb.js' +import { buildTransport, type ConnectOptions } from './transport.js' +import { errorCode, fromConnect, SdkError } from './errors.js' + +export { errorCode } +export type { ConnectOptions } + +// ---- Curated public types -------------------------------------------------- + +export interface Health { + status: string + version: string +} + +export interface SandboxSpec { + name?: string + image?: string + labels?: Record + environment?: Record + providers?: string[] + gpu?: boolean +} + +export interface SandboxRef { + id: string + name: string + phase: string + 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 +} + +export interface ExecResult { + exitCode: number + stdout: Buffer + stderr: Buffer +} + +// ---- enum → lowercase string ----------------------------------------------- + +function phaseName(p: SandboxPhase): string { + return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase() +} +function statusName(s: ServiceStatus): string { + return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase() +} + +function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { + if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response') + const meta = sandbox.metadata + return { + id: meta?.id ?? '', + name: meta?.name ?? '', + phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED), + labels: meta?.labels ?? {}, + resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +// ---- The client ------------------------------------------------------------ + +export class OpenShellClient { + private constructor(private readonly grpc: Client) {} + + static async connect(options: ConnectOptions): Promise { + return new OpenShellClient(createClient(OpenShell, buildTransport(options))) + } + + async health(): Promise { + try { + const resp = await this.grpc.health({}) + return { status: statusName(resp.status), version: resp.version } + } catch (e) { + throw fromConnect(e) + } + } + + async createSandbox(spec: SandboxSpec): Promise { + try { + const resp = await this.grpc.createSandbox({ + name: spec.name ?? '', + labels: spec.labels ?? {}, + spec: { + environment: spec.environment ?? {}, + providers: spec.providers ?? [], + template: spec.image ? { image: spec.image } : undefined, + resourceRequirements: spec.gpu ? { gpu: {} } : undefined, + }, + }) + return sandboxRef(resp.sandbox) + } catch (e) { + throw fromConnect(e) + } + } + + async getSandbox(name: string): Promise { + try { + const resp = await this.grpc.getSandbox({ name }) + return sandboxRef(resp.sandbox) + } catch (e) { + throw fromConnect(e) + } + } + + async listSandboxes(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 deleteSandbox(name: string): Promise { + try { + const resp = await this.grpc.deleteSandbox({ name }) + return resp.deleted + } catch (e) { + throw fromConnect(e) + } + } + + async waitReady(name: string, timeoutSecs: number): Promise { + const deadline = Date.now() + timeoutSecs * 1000 + let delay = 250 + for (;;) { + const ref = await this.getSandbox(name) + 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 sleep(delay) + delay = Math.min(delay * 2, 2000) + } + } + + async waitDeleted(name: string, timeoutSecs: number): Promise { + const deadline = Date.now() + timeoutSecs * 1000 + let delay = 250 + for (;;) { + try { + await this.getSandbox(name) + } catch (e) { + if (e instanceof SdkError && e.code === 'not_found') return + throw e + } + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`) + await sleep(delay) + delay = Math.min(delay * 2, 2000) + } + } + + async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + try { + // Resolve the sandbox id first, exactly like the gateway client. + const sandbox = await this.getSandbox(name) + 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, + }) + + const stdout: Uint8Array[] = [] + const stderr: Uint8Array[] = [] + let exitCode = -1 + for await (const event of stream) { + switch (event.payload.case) { + case 'stdout': + stdout.push(event.payload.value.data) + break + case 'stderr': + stderr.push(event.payload.value.data) + break + case 'exit': + exitCode = event.payload.value.exitCode + break + } + } + return { exitCode, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) } + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e) + } + } +} diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts new file mode 100644 index 0000000000..cbe7540733 --- /dev/null +++ b/sdk/typescript/src/demo.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Runnable smoke test exercising the v0.1 surface against any OpenShell gateway: +// +// OPENSHELL_GATEWAY=http://127.0.0.1:8080 \ +// OPENSHELL_DEFAULT_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ +// npm run demo +// +// Auth: set OPENSHELL_OIDC_TOKEN / OPENSHELL_EDGE_TOKEN / OPENSHELL_CA_CERT / +// OPENSHELL_INSECURE as needed. With none set it assumes a plaintext local gateway. + +import { readFileSync } from 'node:fs' +import { OpenShellClient, errorCode, type ExecResult, type SandboxSpec } from './index.js' + +const env = process.env +const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080' +const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined + +async function main() { + const client = await OpenShellClient.connect({ + gateway, + caCert, + oidcToken: env.OPENSHELL_OIDC_TOKEN, + edgeToken: env.OPENSHELL_EDGE_TOKEN, + insecureSkipVerify: env.OPENSHELL_INSECURE === '1', + }) + + const health = await client.health() + console.log(`health: ${health.status} (v${health.version})`) + + const spec: SandboxSpec = { + image: env.OPENSHELL_DEFAULT_IMAGE, + labels: { 'openshell.dev/demo': 'sdk-ts' }, + } + const ref = await client.createSandbox(spec) + console.log(`created: ${ref.name} [${ref.phase}]`) + + await client.waitReady(ref.name, 120) + console.log(`ready: ${ref.name}`) + + const result: ExecResult = await client.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { + timeoutSecs: 30, + }) + console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) + + const all = await client.listSandboxes({ labelSelector: 'openshell.dev/demo=sdk-ts' }) + console.log(`listed ${all.length} demo sandbox(es)`) + + console.log(`deleted: ${await client.deleteSandbox(ref.name)}`) +} + +main().catch((e) => { + console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e) + process.exit(1) +}) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000000..20a72d816f --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,54 @@ +// 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' + | 'rpc' + +export class SdkError extends Error { + readonly code: SdkErrorCode + constructor(code: SdkErrorCode, message: string) { + // Format `[code] message` so errorCode() can recover the code from any Error. + super(`[${code}] ${message}`) + this.name = 'SdkError' + this.code = code + } +} + +// Map a gRPC status (surfaced by connect-es as ConnectError) onto our codes. +export function fromConnect(err: unknown): SdkError { + const ce = ConnectError.from(err) + switch (ce.code) { + case Code.NotFound: + return new SdkError('not_found', ce.rawMessage) + case Code.AlreadyExists: + return new SdkError('already_exists', ce.rawMessage) + case Code.InvalidArgument: + return new SdkError('invalid_config', ce.rawMessage) + case Code.Unauthenticated: + case Code.PermissionDenied: + return new SdkError('auth', ce.rawMessage) + default: + return new SdkError('rpc', ce.rawMessage) + } +} + +// 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..7939ee7492 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,20 @@ +// 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 { OpenShellClient, errorCode } from './client.js' +export type { + ConnectOptions, + ExecOptions, + ExecResult, + Health, + ListOptions, + SandboxRef, + SandboxSpec, +} from './client.js' diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts new file mode 100644 index 0000000000..4f40051572 --- /dev/null +++ b/sdk/typescript/src/transport.ts @@ -0,0 +1,57 @@ +// 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 { createGrpcTransport } from '@connectrpc/connect-node' +import type { Interceptor, Transport } from '@connectrpc/connect' + +export interface ConnectOptions { + /** Gateway URL (`http://...` or `https://...`). */ + gateway: string + /** CA certificate (PEM). Omit to use system roots. */ + caCert?: 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 +} + +// 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) + } +} + +export function buildTransport(opts: ConnectOptions): Transport { + 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, + 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..8875968cbd --- /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/demo.ts", "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/tasks/ci.toml b/tasks/ci.toml index 9696517af2..47c86b6e5d 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] diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index f00bd19d34..5ebe41c030 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": @@ -408,6 +419,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." ) @@ -461,6 +475,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/typescript.toml b/tasks/typescript.toml new file mode 100644 index 0000000000..6fee1439be --- /dev/null +++ b/tasks/typescript.toml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# TypeScript SDK tasks (sdk/typescript). Codegen uses the pinned protoc (mise) +# + the connect-es plugin from the package's own devDependencies. + +["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" + +["sdk:ts:typecheck"] +description = "Type-check the TypeScript SDK" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run typecheck" + +["sdk:ts:build"] +description = "Build the TypeScript SDK (emit dist/)" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run build" + +["sdk:ts:ci"] +description = "TypeScript SDK checks (codegen, typecheck, build)" +depends = ["sdk:ts:typecheck", "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 From 2142aa18e7b8f553f205bce87c049e85f81f980c Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 14:40:27 -0400 Subject: [PATCH 02/14] chore(sdk): adopt TypeScript 6, tidy @types/node range - typescript ^5.7.2 -> ^6.0.3 (6.0 is now `latest`; the old caret capped at 5.x) - @types/node ^24.0.0 -> ^24 (same range, tidier) No source changes; codegen, typecheck, and build pass on 6.0.3. Verified the emitted d.ts still type-check for downstream consumers on TypeScript 5.0.4 through 5.9.3, so this does not raise the SDK's consumer TS floor. Signed-off-by: Max Dubrinsky --- sdk/typescript/package-lock.json | 10 +++++----- sdk/typescript/package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 227896dd68..8fe4c20e92 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -15,9 +15,9 @@ }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.2.3", - "@types/node": "^24.0.0", + "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^6.0.3" }, "engines": { "node": ">=20" @@ -669,9 +669,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "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": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6fb1322232..6b09ad436b 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -39,8 +39,8 @@ }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.2.3", - "@types/node": "^24.0.0", + "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^6.0.3" } } From 0481aba1fa1eb67fda861905d6fd86b4838b5b0a Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 15:34:34 -0400 Subject: [PATCH 03/14] refactor(sdk): group operations under a composable SandboxClient Reshape the client from flat methods (createSandbox, listSandboxes, exec) to a scoped SandboxClient reached as `client.sandbox.create/get/list/delete/exec` (+ waitReady/waitDeleted), mirroring the CLI's noun-verb model and the Python SDK's SandboxClient. SandboxClient is also usable standalone via SandboxClient.connect(); OpenShellClient composes it over a single shared transport, so future service/provider clients reuse one connection. health() stays top-level as a gateway call. No behavior change; types are unchanged. Signed-off-by: Max Dubrinsky --- sdk/typescript/README.md | 20 +++++++-- sdk/typescript/src/client.ts | 84 +++++++++++++++++++++++++----------- sdk/typescript/src/demo.ts | 10 ++--- sdk/typescript/src/index.ts | 2 +- 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 479147314a..135446f65a 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -28,15 +28,27 @@ const client = await OpenShellClient.connect({ oidcToken: process.env.OPENSHELL_TOKEN, }) -const sandbox = await client.createSandbox({ +const sandbox = await client.sandbox.create({ image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', }) -await client.waitReady(sandbox.name, 120) +await client.sandbox.waitReady(sandbox.name, 120) -const result = await client.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) console.log(result.stdout.toString()) -await client.deleteSandbox(sandbox.name) +await client.sandbox.delete(sandbox.name) +``` + +### 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 }) ``` ## Development diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 96ee8b0d51..8d0a52ac6b 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -2,12 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 // The OpenShell gateway client: a thin, idiomatic ergonomics layer over the -// protobuf-generated gRPC stubs (src/gen/). It owns proto request assembly, -// 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 { createClient, type Client } from '@connectrpc/connect' +// 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 { createClient, type Client, type Transport } from '@connectrpc/connect' import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' import type { Sandbox } from './gen/openshell_pb.js' import { buildTransport, type ConnectOptions } from './transport.js' @@ -83,25 +87,26 @@ function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) -// ---- The client ------------------------------------------------------------ +// ---- sandbox client -------------------------------------------------------- -export class OpenShellClient { - private constructor(private readonly grpc: 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 - static async connect(options: ConnectOptions): Promise { - return new OpenShellClient(createClient(OpenShell, buildTransport(options))) + // 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) { + this.grpc = createClient(OpenShell, transport) } - async health(): Promise { - try { - const resp = await this.grpc.health({}) - return { status: statusName(resp.status), version: resp.version } - } catch (e) { - throw fromConnect(e) - } + static async connect(options: ConnectOptions): Promise { + return new SandboxClient(buildTransport(options)) } - async createSandbox(spec: SandboxSpec): Promise { + async create(spec: SandboxSpec): Promise { try { const resp = await this.grpc.createSandbox({ name: spec.name ?? '', @@ -119,7 +124,7 @@ export class OpenShellClient { } } - async getSandbox(name: string): Promise { + async get(name: string): Promise { try { const resp = await this.grpc.getSandbox({ name }) return sandboxRef(resp.sandbox) @@ -128,7 +133,7 @@ export class OpenShellClient { } } - async listSandboxes(options?: ListOptions | null): Promise { + async list(options?: ListOptions | null): Promise { try { const resp = await this.grpc.listSandboxes({ limit: options?.limit ?? 0, @@ -141,7 +146,7 @@ export class OpenShellClient { } } - async deleteSandbox(name: string): Promise { + async delete(name: string): Promise { try { const resp = await this.grpc.deleteSandbox({ name }) return resp.deleted @@ -154,7 +159,7 @@ export class OpenShellClient { const deadline = Date.now() + timeoutSecs * 1000 let delay = 250 for (;;) { - const ref = await this.getSandbox(name) + const ref = await this.get(name) 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}'`) @@ -168,7 +173,7 @@ export class OpenShellClient { let delay = 250 for (;;) { try { - await this.getSandbox(name) + await this.get(name) } catch (e) { if (e instanceof SdkError && e.code === 'not_found') return throw e @@ -182,7 +187,7 @@ export class OpenShellClient { async exec(name: string, command: string[], options?: ExecOptions | null): Promise { try { // Resolve the sandbox id first, exactly like the gateway client. - const sandbox = await this.getSandbox(name) + const sandbox = await this.get(name) const stream = this.grpc.execSandbox({ sandboxId: sandbox.id, command, @@ -215,3 +220,32 @@ export class OpenShellClient { } } } + +// ---- The client ------------------------------------------------------------ + +export class OpenShellClient { + /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ + readonly sandbox: SandboxClient + + private readonly grpc: Client + + private constructor(transport: Transport) { + // One transport (one connection) shared across every scoped client. + this.grpc = createClient(OpenShell, transport) + this.sandbox = new SandboxClient(transport) + } + + 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/demo.ts b/sdk/typescript/src/demo.ts index cbe7540733..028e1a0f54 100644 --- a/sdk/typescript/src/demo.ts +++ b/sdk/typescript/src/demo.ts @@ -33,21 +33,21 @@ async function main() { image: env.OPENSHELL_DEFAULT_IMAGE, labels: { 'openshell.dev/demo': 'sdk-ts' }, } - const ref = await client.createSandbox(spec) + const ref = await client.sandbox.create(spec) console.log(`created: ${ref.name} [${ref.phase}]`) - await client.waitReady(ref.name, 120) + await client.sandbox.waitReady(ref.name, 120) console.log(`ready: ${ref.name}`) - const result: ExecResult = await client.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { + const result: ExecResult = await client.sandbox.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { timeoutSecs: 30, }) console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) - const all = await client.listSandboxes({ labelSelector: 'openshell.dev/demo=sdk-ts' }) + const all = await client.sandbox.list({ labelSelector: 'openshell.dev/demo=sdk-ts' }) console.log(`listed ${all.length} demo sandbox(es)`) - console.log(`deleted: ${await client.deleteSandbox(ref.name)}`) + console.log(`deleted: ${await client.sandbox.delete(ref.name)}`) } main().catch((e) => { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 7939ee7492..dfa36beb7b 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -8,7 +8,7 @@ // added alongside a conformance suite that pins it byte-identical across the // TypeScript, Python, and Go SDKs. -export { OpenShellClient, errorCode } from './client.js' +export { OpenShellClient, SandboxClient, errorCode } from './client.js' export type { ConnectOptions, ExecOptions, From a44a79ac921d216b2199ef83e72324eb9b781b9b Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 20:35:47 -0400 Subject: [PATCH 04/14] chore(sdk): generate TypeScript SDK stubs with buf Replace the protoc gen.sh with `buf generate` + buf.gen.yaml. `buf` (@bufbuild/buf) is a package devDependency and self-compiles the protos, so the TS SDK no longer depends on the mise-pinned protoc; it drives the same connect-es plugin. Generation stays limited to the client-surface closure (openshell/sandbox/datamodel) via the input paths. Output is byte-identical to the previous protoc + protoc-gen-es pipeline. Lays the groundwork for a shared buf.yaml (lint/breaking/LSP) as a follow-up. Signed-off-by: Max Dubrinsky --- sdk/typescript/buf.gen.yaml | 22 +++++ sdk/typescript/gen.sh | 29 ------- sdk/typescript/package-lock.json | 145 +++++++++++++++++++++++++++++++ sdk/typescript/package.json | 8 +- tasks/typescript.toml | 5 +- 5 files changed, 176 insertions(+), 33 deletions(-) create mode 100644 sdk/typescript/buf.gen.yaml delete mode 100644 sdk/typescript/gen.sh diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml new file mode 100644 index 0000000000..339ccc8607 --- /dev/null +++ b/sdk/typescript/buf.gen.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Code generation for the TypeScript SDK. buf compiles proto/ with its own +# compiler (no protoc) and drives 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 +plugins: + - local: ./node_modules/.bin/protoc-gen-es + out: src/gen + opt: + - target=ts + - import_extension=js diff --git a/sdk/typescript/gen.sh b/sdk/typescript/gen.sh deleted file mode 100644 index a1f3b51565..0000000000 --- a/sdk/typescript/gen.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Generate idiomatic TypeScript from the OpenShell protos using the pinned -# `protoc` toolchain (mise) + the connect-es plugin (@bufbuild/protoc-gen-es). -# No `buf` required. Well-known types (Struct, Timestamp, ...) resolve via -# protoc's bundled include path and render through @bufbuild/protobuf/wkt, so -# we do not generate them ourselves. -set -euo pipefail - -HERE="$(cd "$(dirname "$0")" && pwd)" -PROTO_DIR="$HERE/../../proto" -OUT="$HERE/src/gen" -PLUGIN="$HERE/node_modules/.bin/protoc-gen-es" - -rm -rf "$OUT" -mkdir -p "$OUT" - -# Only the files the client-facing surface needs (transitive imports included). -protoc \ - -I "$PROTO_DIR" \ - --plugin=protoc-gen-es="$PLUGIN" \ - --es_out="$OUT" \ - --es_opt=target=ts,import_extension=js \ - datamodel.proto sandbox.proto openshell.proto - -echo "generated into $OUT:" -ls -1 "$OUT" diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 8fe4c20e92..24bde1ae08 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -14,6 +14,7 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", @@ -23,6 +24,150 @@ "node": ">=20" } }, + "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", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6b09ad436b..52d12db167 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -21,12 +21,15 @@ "import": "./dist/index.js" } }, - "files": ["dist", "README.md"], + "files": [ + "dist", + "README.md" + ], "engines": { "node": ">=20" }, "scripts": { - "gen": "bash gen.sh", + "gen": "buf generate", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "demo": "tsx src/demo.ts", @@ -38,6 +41,7 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 6fee1439be..c278c8640a 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -1,8 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# TypeScript SDK tasks (sdk/typescript). Codegen uses the pinned protoc (mise) -# + the connect-es plugin from the package's own devDependencies. +# 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" From 7ada490d9f21fa4602e3e9049b4e25e07ff303d5 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 7 Jul 2026 10:54:35 -0400 Subject: [PATCH 05/14] build(proto): add repo-level buf module with lint Declare proto/ as a single buf v2 module in a root buf.yaml so buf generate, lint, breaking, and the editor LSP resolve imports the same way. Lint uses STANDARD with six documented exceptions for deviations the current protos intentionally make: the flat proto/ layout with nested packages (DIRECTORY_SAME_PACKAGE, PACKAGE_DIRECTORY_MATCH) and the established API shape with unsuffixed services and reused request/response messages (RPC_REQUEST_RESPONSE_UNIQUE, RPC_REQUEST_STANDARD_NAME, RPC_RESPONSE_STANDARD_NAME, SERVICE_SUFFIX). Every other STANDARD rule now enforces on future protos. Breaking uses FILE. Code generation stays package-scoped in sdk/typescript/buf.gen.yaml since it binds to that package's connect-es plugin and output dir; its inputs are unchanged and regeneration is byte-identical. Wire the check in via a proto:lint mise task that runs buf from the SDK devDependencies. It is a dependency of both sdk:ts:ci (so the TypeScript SDK CI job enforces it) and the top-level lint aggregate (so local pre-commit covers it). Signed-off-by: Max Dubrinsky --- buf.yaml | 30 ++++++++++++++++++++++++++++++ sdk/typescript/buf.gen.yaml | 12 +++++++----- tasks/ci.toml | 2 +- tasks/typescript.toml | 13 +++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 buf.yaml diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000000..3da2e54e22 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Repo-level buf module. Declares proto/ as the single module so buf generate, +# buf lint, buf breaking, and the editor LSP all resolve imports the same way. +# Code generation lives with each consumer (see sdk/typescript/buf.gen.yaml); +# this file owns the module boundary and proto validation policy. +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + except: + # Flat proto/ layout: all files live in one directory with nested + # packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these + # would require restructuring the tree into openshell//v1/ and + # updating every Rust/Python/TS codegen path and import. + - DIRECTORY_SAME_PACKAGE + - PACKAGE_DIRECTORY_MATCH + # Established API shape: services are unsuffixed (OpenShell, not + # OpenShellService) and RPCs reuse shared request/response messages with + # short names. Renaming these is a breaking change across the codebase. + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - SERVICE_SUFFIX +breaking: + use: + - FILE diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml index 339ccc8607..9de46265fb 100644 --- a/sdk/typescript/buf.gen.yaml +++ b/sdk/typescript/buf.gen.yaml @@ -1,11 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Code generation for the TypeScript SDK. buf compiles proto/ with its own -# compiler (no protoc) and drives 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. +# 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: diff --git a/tasks/ci.toml b/tasks/ci.toml index 47c86b6e5d..cda4d04ef4 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -51,7 +51,7 @@ 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"] hide = true [ci] diff --git a/tasks/typescript.toml b/tasks/typescript.toml index c278c8640a..8a5bc2a879 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -17,6 +17,15 @@ 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"] @@ -30,8 +39,8 @@ dir = "sdk/typescript" run = "npm run build" ["sdk:ts:ci"] -description = "TypeScript SDK checks (codegen, typecheck, build)" -depends = ["sdk:ts:typecheck", "sdk:ts:build"] +description = "TypeScript SDK checks (proto lint, codegen, typecheck, build)" +depends = ["proto:lint", "sdk:ts:typecheck", "sdk:ts:build"] hide = true # Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION From 0f2c18c6ced704096a82750894dca83de2be8c19 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 9 Jul 2026 13:37:27 -0400 Subject: [PATCH 06/14] chore(sdk): publish as unscoped openshell-sdk on public npm Rename the package from @nvidia/openshell-sdk to the unscoped openshell-sdk and target public npm (registry.npmjs.org) instead of GitHub Packages. GitHub Packages requires a scope matching the owning org, and the @openshell scope is blocked by an unrelated existing package, so an unscoped name on public npm is the lowest-friction distribution path and needs no org approval. Rework the release-tag publish job to auth against registry.npmjs.org with NPM_TOKEN (the job now only needs packages: read to pull the CI image). Update the README install instructions and usage imports. Signed-off-by: Max Dubrinsky --- .github/workflows/release-tag.yml | 14 ++++++-------- sdk/typescript/README.md | 20 ++++++-------------- sdk/typescript/package-lock.json | 4 ++-- sdk/typescript/package.json | 4 ++-- sdk/typescript/src/index.ts | 2 +- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 1485e62615..5c93b6044b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1076,7 +1076,9 @@ jobs: timeout-minutes: 15 permissions: contents: read - packages: write + # read-only: needed to pull the private CI image from ghcr, not to + # publish. The SDK publishes to public npm (registry.npmjs.org). + packages: read container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -1093,18 +1095,14 @@ jobs: - name: Install tools run: mise install --locked - - name: Configure npm auth for GitHub Packages + - name: Configure npm auth for npmjs.org working-directory: ./sdk/typescript - run: | - { - echo "@nvidia:registry=https://npm.pkg.github.com" - echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' - } > .npmrc + run: echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > .npmrc - name: Publish env: OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: mise run sdk:ts:publish release-helm: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 135446f65a..a188492296 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,27 +1,19 @@ -# @nvidia/openshell-sdk +# openshell-sdk TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. -Distributed via GitHub Packages during pre-GA. Public npm (`@openshell/sdk`) follows at GA; the public API is unchanged across the move, so only the install specifier changes. +Published on public npm. ## Install -Published to GitHub Packages, so add 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 +npm install openshell-sdk ``` ## Usage ```ts -import { OpenShellClient } from '@nvidia/openshell-sdk' +import { OpenShellClient } from 'openshell-sdk' const client = await OpenShellClient.connect({ gateway: 'https://gateway.example.com', @@ -45,7 +37,7 @@ await client.sandbox.delete(sandbox.name) directly — same API, one less hop: ```ts -import { SandboxClient } from '@nvidia/openshell-sdk' +import { SandboxClient } from 'openshell-sdk' const sandbox = await SandboxClient.connect({ gateway, oidcToken }) await sandbox.create({ image }) @@ -56,7 +48,7 @@ await sandbox.create({ image }) 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/ (protoc + protoc-gen-es) +mise run sdk:ts:proto # generate stubs from proto/ with buf mise run sdk:ts:typecheck # tsc --noEmit mise run sdk:ts:build # emit dist/ ``` diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 24bde1ae08..428fe59625 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 52d12db167..93f204f7fc 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,5 +1,5 @@ { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "description": "Official TypeScript SDK for the OpenShell gateway.", "license": "Apache-2.0", @@ -11,7 +11,7 @@ "directory": "sdk/typescript" }, "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dfa36beb7b..af5d25de5a 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,7 +1,7 @@ // 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. +// Public API surface for 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 From 6908672e22a9a2fb23e43e94f4db8507c5595b39 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 9 Jul 2026 13:44:20 -0400 Subject: [PATCH 07/14] chore(sdk): publish @nvidia/openshell-sdk to GitHub Packages Revert the unscoped-name switch. GitHub Packages only accepts scoped names matching the owning org, so shipping there first (which needs no external npm org or NPM_TOKEN, just the repo's GITHUB_TOKEN) requires the @nvidia scope. Keeping the @nvidia/openshell-sdk name also lets a later public-npm release use the same install specifier, so adding public npm becomes a second publish step rather than a rename. Restore the GitHub Packages publish auth in the release-tag job and the scoped install instructions in the README (keeping the buf codegen note). Signed-off-by: Max Dubrinsky --- .github/workflows/release-tag.yml | 14 ++++++++------ sdk/typescript/README.md | 18 +++++++++++++----- sdk/typescript/package-lock.json | 4 ++-- sdk/typescript/package.json | 4 ++-- sdk/typescript/src/index.ts | 2 +- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 5c93b6044b..1485e62615 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1076,9 +1076,7 @@ jobs: timeout-minutes: 15 permissions: contents: read - # read-only: needed to pull the private CI image from ghcr, not to - # publish. The SDK publishes to public npm (registry.npmjs.org). - packages: read + packages: write container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -1095,14 +1093,18 @@ jobs: - name: Install tools run: mise install --locked - - name: Configure npm auth for npmjs.org + - name: Configure npm auth for GitHub Packages working-directory: ./sdk/typescript - run: echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > .npmrc + 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.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: mise run sdk:ts:publish release-helm: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index a188492296..5461e9565f 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,19 +1,27 @@ -# openshell-sdk +# @nvidia/openshell-sdk TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. -Published on public npm. +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. ## 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 openshell-sdk +npm install @nvidia/openshell-sdk ``` ## Usage ```ts -import { OpenShellClient } from 'openshell-sdk' +import { OpenShellClient } from '@nvidia/openshell-sdk' const client = await OpenShellClient.connect({ gateway: 'https://gateway.example.com', @@ -37,7 +45,7 @@ await client.sandbox.delete(sandbox.name) directly — same API, one less hop: ```ts -import { SandboxClient } from 'openshell-sdk' +import { SandboxClient } from '@nvidia/openshell-sdk' const sandbox = await SandboxClient.connect({ gateway, oidcToken }) await sandbox.create({ image }) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 428fe59625..24bde1ae08 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 93f204f7fc..52d12db167 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,5 +1,5 @@ { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "description": "Official TypeScript SDK for the OpenShell gateway.", "license": "Apache-2.0", @@ -11,7 +11,7 @@ "directory": "sdk/typescript" }, "publishConfig": { - "registry": "https://registry.npmjs.org" + "registry": "https://npm.pkg.github.com" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index af5d25de5a..dfa36beb7b 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Public API surface for openshell-sdk. +// 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 From ed32948263c5d90a37d1e9bb474a9317f0666e95 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:42 -0400 Subject: [PATCH 08/14] feat(sdk-ts): add streaming exec, forward, ssh, provider, and config methods Grow SandboxClient to the surface the first two consumers need. execStream yields stdout/stderr chunks as they arrive and exec now drains it, keeping its buffered ExecResult and signature unchanged. execInteractive is the TTY + stdin transport primitive (start-first framing, output/write/resize/close/done, no terminal glue). forward binds a local TCP listener that tunnels each accepted connection into the sandbox for the process lifetime, minting and revoking a per-socket SSH session token around a forwardTcp bidi. Adds createSshSession / revokeSshSession, attach/detach/listProviders, and getConfig / setPolicy / setSetting (sandbox-scoped, network-policy-only, with an optional wait poll). Signed-off-by: Max Dubrinsky --- sdk/typescript/src/client.ts | 816 +++++++++++++++++++++++++++++++---- sdk/typescript/src/demo.ts | 128 +++++- sdk/typescript/src/index.ts | 19 +- 3 files changed, 850 insertions(+), 113 deletions(-) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 8d0a52ac6b..6671ae325f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -11,81 +11,335 @@ // the waitReady/waitDeleted poll loops. Transport and auth live in // transport.ts; the error taxonomy in errors.ts. -import { createClient, type Client, type Transport } from '@connectrpc/connect' -import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' -import type { Sandbox } from './gen/openshell_pb.js' -import { buildTransport, type ConnectOptions } from './transport.js' -import { errorCode, fromConnect, SdkError } from './errors.js' - -export { errorCode } -export type { ConnectOptions } +import type { AddressInfo } from 'node:net'; +import * as net from 'node:net'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { 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, + 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 { 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 -------------------------------------------------- export interface Health { - status: string - version: string + status: string; + version: string; } export interface SandboxSpec { - name?: string - image?: string - labels?: Record - environment?: Record - providers?: string[] - gpu?: boolean + name?: string; + image?: string; + labels?: Record; + environment?: Record; + providers?: string[]; + gpu?: boolean; } export interface SandboxRef { - id: string - name: string - phase: string - labels: Record + id: string; + name: string; + phase: string; + labels: Record; /** u64 rendered as a string — JS numbers can't hold it safely. */ - resourceVersion: string + resourceVersion: string; } export interface ListOptions { - limit?: number - offset?: number - labelSelector?: string + limit?: number; + offset?: number; + labelSelector?: string; } export interface ExecOptions { - workdir?: string - environment?: Record - timeoutSecs?: number - stdin?: Buffer + workdir?: string; + environment?: Record; + timeoutSecs?: number; + stdin?: Buffer; } export interface ExecResult { - exitCode: number - stdout: Buffer - stderr: Buffer + exitCode: number; + stdout: Buffer; + stderr: Buffer; +} + +/** One stdout/stderr chunk yielded by `execStream`/`execInteractive`. */ +export interface ExecStreamChunk { + stream: 'stdout' | 'stderr'; + data: Buffer; +} + +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; +} + +// The transport half of an interactive exec: raw stdin/stdout/stderr plus +// resize, with no terminal glue. Drive it by consuming `output`; `done` +// resolves with the exit code once the stream reaches its exit event. +export interface ExecInteractiveSession { + output: AsyncIterable; + write(data: Buffer): void; + resize(cols: number, rows: number): void; + close(): void; + done: Promise; +} + +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; +} + +// 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: string; +} + +export interface SandboxConfig { + policy?: SandboxPolicy; + version: number; + policyHash: string; + settings: Record; + /** u64 rendered as a string. */ + configRevision: string; + /** 'unspecified' | 'sandbox' | 'global'. */ + policySource: string; + 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; +} + +export interface UpdateConfigResult { + version: number; + policyHash: string; + /** u64 rendered as a string. */ + settingsRevision: string; + deleted: boolean; } // ---- enum → lowercase string ----------------------------------------------- function phaseName(p: SandboxPhase): string { - return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase() + return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase(); } function statusName(s: ServiceStatus): string { - return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase() + return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase(); +} +function scopeName(s: SettingScope): string { + return (SettingScope[s] ?? 'UNSPECIFIED').toLowerCase(); +} +function policySourceName(s: PolicySource): string { + return (PolicySource[s] ?? 'UNSPECIFIED').toLowerCase(); } function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { - if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response') - const meta = sandbox.metadata + if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response'); + const meta = sandbox.metadata; 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, + }; } -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +// Optimistic-concurrency version pin: absent/empty means 0n (server uses the +// current version, backward-compatible). A mismatch surfaces as Aborted → rpc. +function versionPin(value: string | undefined): bigint { + return value ? BigInt(value) : 0n; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const FORWARD_CHUNK = 64 * 1024; + +// 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. +class Pushable implements AsyncIterable { + private readonly queue: T[] = []; + private readonly waiting: ((r: IteratorResult) => 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 resolve = this.waiting.shift(); + if (resolve) { + resolve({ value, done: false }); + } else { + this.queue.push(value); + } + } + + end(error?: unknown): void { + if (this.ended) return; + this.ended = true; + this.error = error; + let resolve = this.waiting.shift(); + while (resolve) { + resolve({ value: undefined as never, done: true }); + resolve = 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) => { + this.waiting.push(resolve); + }); + if (next.done) { + if (this.error !== undefined) throw this.error; + return; + } + yield next.value; + } + } +} // ---- sandbox client -------------------------------------------------------- @@ -93,17 +347,17 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // 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 + private readonly grpc: Client; // 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) { - this.grpc = createClient(OpenShell, transport) + this.grpc = createClient(OpenShell, transport); } static async connect(options: ConnectOptions): Promise { - return new SandboxClient(buildTransport(options)) + return new SandboxClient(buildTransport(options)); } async create(spec: SandboxSpec): Promise { @@ -117,19 +371,19 @@ export class SandboxClient { template: spec.image ? { image: spec.image } : undefined, resourceRequirements: spec.gpu ? { gpu: {} } : undefined, }, - }) - return sandboxRef(resp.sandbox) + }); + return sandboxRef(resp.sandbox); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async get(name: string): Promise { try { - const resp = await this.grpc.getSandbox({ name }) - return sandboxRef(resp.sandbox) + const resp = await this.grpc.getSandbox({ name }); + return sandboxRef(resp.sandbox); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } @@ -139,55 +393,63 @@ export class SandboxClient { limit: options?.limit ?? 0, offset: options?.offset ?? 0, labelSelector: options?.labelSelector ?? '', - }) - return resp.sandboxes.map((s) => sandboxRef(s)) + }); + return resp.sandboxes.map((s) => sandboxRef(s)); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async delete(name: string): Promise { try { - const resp = await this.grpc.deleteSandbox({ name }) - return resp.deleted + const resp = await this.grpc.deleteSandbox({ name }); + return resp.deleted; } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async waitReady(name: string, timeoutSecs: number): Promise { - const deadline = Date.now() + timeoutSecs * 1000 - let delay = 250 + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 250; for (;;) { - const ref = await this.get(name) - 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 sleep(delay) - delay = Math.min(delay * 2, 2000) + const ref = await this.get(name); + 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 sleep(delay); + delay = Math.min(delay * 2, 2000); } } async waitDeleted(name: string, timeoutSecs: number): Promise { - const deadline = Date.now() + timeoutSecs * 1000 - let delay = 250 + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 250; for (;;) { try { - await this.get(name) + await this.get(name); } catch (e) { - if (e instanceof SdkError && e.code === 'not_found') return - throw e + if (e instanceof SdkError && e.code === 'not_found') return; + throw e; } - if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`) - await sleep(delay) - delay = Math.min(delay * 2, 2000) + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); + await sleep(delay); + delay = Math.min(delay * 2, 2000); } } - async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + // Stream stdout/stderr as they arrive. The terminal ExecResult carries the + // exit code; its stdout/stderr are empty — the yielded chunks are the data. + // `exec()` drains this to reconstruct the buffered result, so both share one + // path. + 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) + const sandbox = await this.get(name); const stream = this.grpc.execSandbox({ sandboxId: sandbox.id, command, @@ -196,27 +458,403 @@ export class SandboxClient { timeoutSeconds: options?.timeoutSecs ?? 0, stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), tty: false, - }) + }); - const stdout: Uint8Array[] = [] - const stderr: Uint8Array[] = [] - let exitCode = -1 + let exitCode = -1; for await (const event of stream) { switch (event.payload.case) { case 'stdout': - stdout.push(event.payload.value.data) - break + yield { + stream: 'stdout', + data: Buffer.from(event.payload.value.data), + }; + break; case 'stderr': - stderr.push(event.payload.value.data) - break + yield { + stream: 'stderr', + data: Buffer.from(event.payload.value.data), + }; + break; case 'exit': - exitCode = event.payload.value.exitCode - break + exitCode = event.payload.value.exitCode; + break; + } + } + return { exitCode, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + } 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[] = []; + const stream = this.execStream(name, command, options); + let next = await stream.next(); + for (; next.done !== true; next = await stream.next()) { + const chunk = next.value; + if (chunk.stream === 'stdout') stdout.push(chunk.data); + else stderr.push(chunk.data); + } + return { + exitCode: next.value.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)).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); + let resolveDone!: (code: number) => void; + let rejectDone!: (err: unknown) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + + async function* output(): AsyncGenerator { + let exitCode = -1; + 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': + exitCode = event.payload.value.exitCode; + break; + } + } + resolveDone(exitCode); + } catch (e) { + const err = e instanceof SdkError ? e : fromConnect(e); + rejectDone(err); + throw err; + } finally { + input.end(); + } + } + + 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); + 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 server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + void this.forwardConnection(socket, sandboxId, name, targetHost, targetPort); + }); + + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + server.on('close', () => resolveClosed()); + + 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(); + }); + }); + + const addr = server.address() as AddressInfo | null; + return { + localHost, + localPort: addr ? addr.port : localPort, + targetHost, + targetPort, + close: async (): Promise => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + closed, + }; + } + + private async forwardConnection( + socket: net.Socket, + sandboxId: string, + name: string, + targetHost: string, + targetPort: number, + ): Promise { + let token: string | undefined; + const input = new Pushable>(); + input.onDrain = () => socket.resume(); + try { + const session = await this.grpc.createSshSession({ 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', () => input.end()); + socket.on('close', () => input.end()); + + for await (const frame of this.grpc.forwardTcp(input)) { + if (frame.payload.case !== 'data') continue; + const data = frame.payload.value; + if (data.length > 0) socket.write(Buffer.from(data)); + } + socket.end(); + } catch { + socket.destroy(); + } finally { + input.end(); + if (token !== undefined) { + try { + await this.grpc.revokeSshSession({ token }); + } catch { + // Best-effort revoke; the token expires on its own regardless. } } - return { exitCode, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) } + } + } + + // 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 }); + 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): Promise { + try { + const sandbox = await this.get(name); + const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }); + 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); + 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 e instanceof SdkError ? e : fromConnect(e) + throw fromConnect(e); + } + } + + private async waitForPolicyHash(name: string, policyHash: string, timeoutSecs = 60): Promise { + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 100; + for (;;) { + const config = await this.getConfig(name); + if (config.policyHash === policyHash) return; + if (Date.now() >= deadline) { + throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`); + } + await sleep(delay); + delay = Math.min(delay * 2, 2000); } } } @@ -225,27 +863,27 @@ export class SandboxClient { export class OpenShellClient { /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ - readonly sandbox: SandboxClient + readonly sandbox: SandboxClient; - private readonly grpc: Client + private readonly grpc: Client; private constructor(transport: Transport) { // One transport (one connection) shared across every scoped client. - this.grpc = createClient(OpenShell, transport) - this.sandbox = new SandboxClient(transport) + this.grpc = createClient(OpenShell, transport); + this.sandbox = new SandboxClient(transport); } static async connect(options: ConnectOptions): Promise { - return new OpenShellClient(buildTransport(options)) + 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 } + const resp = await this.grpc.health({}); + return { status: statusName(resp.status), version: resp.version }; } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } } diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts index 028e1a0f54..4ed2b8157b 100644 --- a/sdk/typescript/src/demo.ts +++ b/sdk/typescript/src/demo.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Runnable smoke test exercising the v0.1 surface against any OpenShell gateway: +// Runnable smoke test exercising the SDK surface against any OpenShell gateway: // // OPENSHELL_GATEWAY=http://127.0.0.1:8080 \ // OPENSHELL_DEFAULT_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ @@ -9,13 +9,28 @@ // // Auth: set OPENSHELL_OIDC_TOKEN / OPENSHELL_EDGE_TOKEN / OPENSHELL_CA_CERT / // OPENSHELL_INSECURE as needed. With none set it assumes a plaintext local gateway. +// Set OPENSHELL_DEMO_PROVIDER= to exercise the provider attach/detach path +// against an existing gateway provider. -import { readFileSync } from 'node:fs' -import { OpenShellClient, errorCode, type ExecResult, type SandboxSpec } from './index.js' +import { readFileSync } from 'node:fs'; +import * as http from 'node:http'; +import { type ExecResult, errorCode, OpenShellClient, type SandboxSpec } from './index.js'; -const env = process.env -const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080' -const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined +const env = process.env; +const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080'; +const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + }); +} async function main() { const client = await OpenShellClient.connect({ @@ -24,33 +39,102 @@ async function main() { oidcToken: env.OPENSHELL_OIDC_TOKEN, edgeToken: env.OPENSHELL_EDGE_TOKEN, insecureSkipVerify: env.OPENSHELL_INSECURE === '1', - }) + }); - const health = await client.health() - console.log(`health: ${health.status} (v${health.version})`) + const health = await client.health(); + console.log(`health: ${health.status} (v${health.version})`); const spec: SandboxSpec = { image: env.OPENSHELL_DEFAULT_IMAGE, labels: { 'openshell.dev/demo': 'sdk-ts' }, - } - const ref = await client.sandbox.create(spec) - console.log(`created: ${ref.name} [${ref.phase}]`) + }; + const ref = await client.sandbox.create(spec); + console.log(`created: ${ref.name} [${ref.phase}]`); - await client.sandbox.waitReady(ref.name, 120) - console.log(`ready: ${ref.name}`) + await client.sandbox.waitReady(ref.name, 120); + console.log(`ready: ${ref.name}`); const result: ExecResult = await client.sandbox.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { timeoutSecs: 30, - }) - console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) + }); + console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`); + + // execStream: incremental chunks as they arrive. + process.stdout.write('execStream: '); + for await (const chunk of client.sandbox.execStream(ref.name, [ + '/bin/sh', + '-c', + 'for i in 1 2 3; do echo line $i; done', + ])) { + process.stdout.write(chunk.data); + } + + // execInteractive: scripted (non-TTY) bidi round-trip through `cat`. + const session = await client.sandbox.execInteractive(ref.name, ['cat'], { + tty: false, + }); + let echoed = ''; + const collect = (async () => { + for await (const chunk of session.output) echoed += chunk.data.toString(); + })(); + session.write(Buffer.from('interactive hello\n')); + await sleep(300); + session.close(); + await collect; + console.log(`execInteractive: echo=${echoed.trim()} exit=${await session.done}`); + + // SSH session mint + revoke. + const ssh = await client.sandbox.createSshSession(ref.name); + console.log( + `ssh session: token=${ssh.token.slice(0, 8)}… gateway=${ssh.gatewayScheme}://${ssh.gatewayHost}:${ssh.gatewayPort}`, + ); + console.log(`ssh revoked: ${await client.sandbox.revokeSshSession(ssh.token)}`); + + // Config + policy round-trip (network-policy-safe: re-apply the current policy). + const config = await client.sandbox.getConfig(ref.name); + console.log( + `config: policyHash=${config.policyHash} version=${config.version} settings=${Object.keys(config.settings).length}`, + ); + if (config.policy) { + const update = await client.sandbox.setPolicy(ref.name, config.policy, { + wait: true, + }); + const after = await client.sandbox.getConfig(ref.name); + console.log(`setPolicy: version=${update.version} hashMatches=${after.policyHash === update.policyHash}`); + } + + // Providers (optional: requires an existing gateway provider). + const providerName = env.OPENSHELL_DEMO_PROVIDER; + if (providerName) { + const attach = await client.sandbox.attachProvider(ref.name, providerName); + console.log(`attach ${providerName}: changed=${attach.changed}`); + const providers = await client.sandbox.listProviders(ref.name); + console.log(`providers: ${providers.map((p) => `${p.name}(${p.type})`).join(', ')}`); + const detach = await client.sandbox.detachProvider(ref.name, providerName); + console.log(`detach ${providerName}: changed=${detach.changed}`); + } + + // Forward: start an in-sandbox listener and drive a request through the tunnel. + await client.sandbox.exec(ref.name, [ + '/bin/sh', + '-c', + 'nohup python3 -m http.server 8111 >/tmp/http.log 2>&1 & sleep 1', + ]); + const forward = await client.sandbox.forward(ref.name, { targetPort: 8111 }); + console.log(`forward: 127.0.0.1:${forward.localPort} -> sandbox:8111`); + const status = await httpGet(`http://127.0.0.1:${forward.localPort}/`); + console.log(`forward GET status: ${status}`); + await forward.close(); - const all = await client.sandbox.list({ labelSelector: 'openshell.dev/demo=sdk-ts' }) - console.log(`listed ${all.length} demo sandbox(es)`) + const all = await client.sandbox.list({ + labelSelector: 'openshell.dev/demo=sdk-ts', + }); + console.log(`listed ${all.length} demo sandbox(es)`); - console.log(`deleted: ${await client.sandbox.delete(ref.name)}`) + console.log(`deleted: ${await client.sandbox.delete(ref.name)}`); } main().catch((e) => { - console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e) - process.exit(1) -}) + console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dfa36beb7b..bb12231127 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -8,13 +8,28 @@ // added alongside a conformance suite that pins it byte-identical across the // TypeScript, Python, and Go SDKs. -export { OpenShellClient, SandboxClient, errorCode } from './client.js' export type { ConnectOptions, + EffectiveSettingView, + ExecInteractiveOptions, + ExecInteractiveSession, ExecOptions, ExecResult, + ExecStreamChunk, + ForwardHandle, + ForwardOptions, Health, ListOptions, + ProviderChange, + ProviderChangeOptions, + ProviderRef, + SandboxConfig, + SandboxPolicy, SandboxRef, SandboxSpec, -} from './client.js' + SetPolicyOptions, + SettingValue, + SshSession, + UpdateConfigResult, +} from './client.js'; +export { errorCode, OpenShellClient, SandboxClient } from './client.js'; From 427d5bd4c7dba194d65a8af9ba00265ce27a1acc Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:48 -0400 Subject: [PATCH 09/14] build(sdk-ts): add Biome and Vitest tooling The TypeScript SDK had no formatter or linter and no test runner. Add Biome (format + lint, generated src/gen excluded) enforcing 2-space indent, single quotes, semicolons, and a 120-column width, and reformat the existing hand-written sources accordingly. Add Vitest for unit tests. Wire sdk:ts:format, sdk:ts:lint, and sdk:ts:test mise tasks into the fmt/lint aggregates, the root test suite, and sdk:ts:ci so they run in CI. Signed-off-by: Max Dubrinsky --- sdk/typescript/biome.json | 37 + sdk/typescript/package-lock.json | 1655 ++++++++++++++++++++++++++++-- sdk/typescript/package.json | 7 +- sdk/typescript/src/errors.ts | 30 +- sdk/typescript/src/transport.ts | 28 +- sdk/typescript/vitest.config.ts | 11 + tasks/ci.toml | 15 +- tasks/test.toml | 12 +- tasks/typescript.toml | 28 +- 9 files changed, 1680 insertions(+), 143 deletions(-) create mode 100644 sdk/typescript/biome.json create mode 100644 sdk/typescript/vitest.config.ts diff --git a/sdk/typescript/biome.json b/sdk/typescript/biome.json new file mode 100644 index 0000000000..37fefbbf74 --- /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"] + }, + "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/package-lock.json b/sdk/typescript/package-lock.json index 24bde1ae08..eec8842000 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -14,16 +14,193 @@ "@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", "tsx": "^4.19.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "engines": { "node": ">=20" } }, + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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", @@ -247,6 +424,40 @@ "@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/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -689,150 +900,1380 @@ "node": ">=18" } }, - "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==", + "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", - "dependencies": { - "undici-types": "~7.18.0" - } + "license": "MIT" }, - "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==", + "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": { - "debug": "^4.4.3" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "typescript": "*" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "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, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "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", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "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": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14.17" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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/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/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/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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/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/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/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/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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 index 52d12db167..572b723e90 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -32,6 +32,9 @@ "gen": "buf generate", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", + "format": "biome check --write .", + "lint": "biome ci .", + "test": "vitest run", "demo": "tsx src/demo.ts", "prepublishOnly": "npm run gen && npm run build" }, @@ -41,10 +44,12 @@ "@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", "tsx": "^4.19.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 20a72d816f..71c3ac9890 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -6,7 +6,7 @@ // retired) napi binding exposed, kept stable so consumers migrating off it see // an identical contract. -import { Code, ConnectError } from '@connectrpc/connect' +import { Code, ConnectError } from '@connectrpc/connect'; export type SdkErrorCode = | 'invalid_config' @@ -16,39 +16,39 @@ export type SdkErrorCode = | 'io' | 'not_found' | 'already_exists' - | 'rpc' + | 'rpc'; export class SdkError extends Error { - readonly code: SdkErrorCode + readonly code: SdkErrorCode; constructor(code: SdkErrorCode, message: string) { // Format `[code] message` so errorCode() can recover the code from any Error. - super(`[${code}] ${message}`) - this.name = 'SdkError' - this.code = code + super(`[${code}] ${message}`); + this.name = 'SdkError'; + this.code = code; } } // Map a gRPC status (surfaced by connect-es as ConnectError) onto our codes. export function fromConnect(err: unknown): SdkError { - const ce = ConnectError.from(err) + const ce = ConnectError.from(err); switch (ce.code) { case Code.NotFound: - return new SdkError('not_found', ce.rawMessage) + return new SdkError('not_found', ce.rawMessage); case Code.AlreadyExists: - return new SdkError('already_exists', ce.rawMessage) + return new SdkError('already_exists', ce.rawMessage); case Code.InvalidArgument: - return new SdkError('invalid_config', ce.rawMessage) + return new SdkError('invalid_config', ce.rawMessage); case Code.Unauthenticated: case Code.PermissionDenied: - return new SdkError('auth', ce.rawMessage) + return new SdkError('auth', ce.rawMessage); default: - return new SdkError('rpc', ce.rawMessage) + return new SdkError('rpc', ce.rawMessage); } } // 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 + 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/transport.ts b/sdk/typescript/src/transport.ts index 4f40051572..6d6f1396d7 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -10,20 +10,20 @@ // `gateway` at it. When the edge passes gRPC POST directly, the header mode // below suffices. -import { createGrpcTransport } from '@connectrpc/connect-node' -import type { Interceptor, Transport } from '@connectrpc/connect' +import type { Interceptor, Transport } from '@connectrpc/connect'; +import { createGrpcTransport } from '@connectrpc/connect-node'; export interface ConnectOptions { /** Gateway URL (`http://...` or `https://...`). */ - gateway: string + gateway: string; /** CA certificate (PEM). Omit to use system roots. */ - caCert?: Buffer + caCert?: Buffer; /** Bearer token for direct OIDC auth. Mutually exclusive with edgeToken. */ - oidcToken?: string + oidcToken?: string; /** Cloudflare Access token. See the sidecar note above for CF-fronted gateways. */ - edgeToken?: string + edgeToken?: string; /** Disable TLS verification (dev/debug only). */ - insecureSkipVerify?: boolean + insecureSkipVerify?: boolean; } // OIDC bearer takes precedence; otherwise attach the Cloudflare Access header + @@ -31,17 +31,17 @@ export interface ConnectOptions { function authInterceptor(opts: ConnectOptions): Interceptor { return (next) => async (req) => { if (opts.oidcToken) { - req.header.set('authorization', `Bearer ${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}`) + req.header.set('cf-access-jwt-assertion', opts.edgeToken); + req.header.set('cookie', `CF_Authorization=${opts.edgeToken}`); } - return next(req) - } + return next(req); + }; } export function buildTransport(opts: ConnectOptions): Transport { - const isTls = opts.gateway.startsWith('https://') + const isTls = opts.gateway.startsWith('https://'); return createGrpcTransport({ baseUrl: opts.gateway, interceptors: [authInterceptor(opts)], @@ -53,5 +53,5 @@ export function buildTransport(opts: ConnectOptions): Transport { rejectUnauthorized: opts.insecureSkipVerify ? false : undefined, } : undefined, - }) + }); } diff --git a/sdk/typescript/vitest.config.ts b/sdk/typescript/vitest.config.ts new file mode 100644 index 0000000000..82fd245477 --- /dev/null +++ b/sdk/typescript/vitest.config.ts @@ -0,0 +1,11 @@ +// 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', + }, +}); diff --git a/tasks/ci.toml b/tasks/ci.toml index cda4d04ef4..045fbdbd59 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -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", "proto: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/test.toml b/tasks/test.toml index c08fcc5a04..6ab23fc69d 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -4,8 +4,16 @@ # Test tasks (Rust + Python) [test] -description = "Run all tests (Rust + Python)" -depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "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: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 index 8a5bc2a879..823a881359 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -32,15 +32,39 @@ 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, codegen, typecheck, build)" -depends = ["proto:lint", "sdk:ts:typecheck", "sdk:ts:build"] +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 From 4b98970693e898cfd6da4340313f994b6a302642 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:53 -0400 Subject: [PATCH 10/14] test(sdk-ts): cover the sandbox surface with in-memory transport tests Exercise SandboxClient against an in-memory OpenShell service built with createRouterTransport: request assembly and id resolution, u64/int64 rendered as strings, enum lowercasing, fromConnect code mapping, the exec/execStream drain plus a backward-compat check on exec, execInteractive start-first ordering and done resolution, and a forward() byte relay against a loopback echo with close() teardown. Signed-off-by: Max Dubrinsky --- sdk/typescript/src/client.test.ts | 469 ++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 sdk/typescript/src/client.test.ts diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts new file mode 100644 index 0000000000..d7d2f911ae --- /dev/null +++ b/sdk/typescript/src/client.test.ts @@ -0,0 +1,469 @@ +// 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, SandboxClient } from './client.js'; +import { OpenShell, SandboxPhase } 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 and returns an exit-only ExecResult', 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 }> = []; + const gen = sandbox.execStream('sb', ['x']); + let next = await gen.next(); + for (; next.done !== true; next = await gen.next()) { + chunks.push({ + stream: next.value.stream, + data: next.value.data.toString(), + }); + } + expect(chunks).toEqual([ + { stream: 'stdout', data: 'a' }, + { stream: 'stderr', data: 'b' }, + ]); + expect(next.value.exitCode).toBe(0); + expect(next.value.stdout.length).toBe(0); + expect(next.value.stderr.length).toBe(0); + }); + + 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('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 chunk of session.output) out.push(chunk.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('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); + }); + + 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'); + }); +}); + +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); + }); +}); + +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' }); + }); +}); From 114eed67db0ddbd361f27055589e7cbf45d7726c Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:03:01 -0400 Subject: [PATCH 11/14] docs(sdk-ts): document the new surface and connect/upload/download boundaries Document execStream, execInteractive, forward, ssh sessions, providers, and config/policy in the SDK README, and record the intentional boundaries: interactive connect / PTY ownership, upload/download (no file-transfer RPC), and detached forwards stay out of scope. Note the Biome/Vitest dev commands. Signed-off-by: Max Dubrinsky --- sdk/typescript/README.md | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 5461e9565f..20f7250cfd 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -51,12 +51,73 @@ 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 terminal value carries the exit code; the chunks carry the bytes. `exec` drains `execStream` internally, so its buffered `ExecResult` is unchanged. + +```ts +for await (const chunk of client.sandbox.execStream(name, ['pytest', '-q'])) { + process[chunk.stream].write(chunk.data) // 'stdout' | 'stderr' +} +``` + +`execInteractive` is the TTY + stdin transport primitive. Drive it by consuming `output`; `done` resolves with the exit code once the stream ends. 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 chunk of session.output) process.stdout.write(chunk.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 }) +// ... reach the sandbox service at 127.0.0.1:fwd.localPort ... +await fwd.close() +``` + +## 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. + +## 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 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`. From a24edd2bff45be088d4a91b304eac4daa3f20e2d Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 20 Jul 2026 12:34:07 -0400 Subject: [PATCH 12/14] feat(sdk-ts): support mTLS client authentication Add clientCert and clientKey to ConnectOptions so the SDK can authenticate to the default local gateway, which uses mTLS user authentication. Without a client certificate and key the SDK could verify the server but never authenticate the caller, so it could not connect to the standard Docker, VM, Homebrew, or Linux-package gateway. Validate the pair as both-or-neither and pass cert and key through to the Node TLS options for https gateways. The h2c path is unchanged. Signed-off-by: Max Dubrinsky --- sdk/typescript/src/transport.test.ts | 53 ++++++++++++++++++++++++++++ sdk/typescript/src/transport.ts | 24 +++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 sdk/typescript/src/transport.test.ts diff --git a/sdk/typescript/src/transport.test.ts b/sdk/typescript/src/transport.test.ts new file mode 100644 index 0000000000..e980eb2a31 --- /dev/null +++ b/sdk/typescript/src/transport.test.ts @@ -0,0 +1,53 @@ +// 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(); + }); +}); diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts index 6d6f1396d7..b0ea287986 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -12,12 +12,21 @@ 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. */ @@ -40,7 +49,20 @@ function authInterceptor(opts: ConnectOptions): Interceptor { }; } +// 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`); + } +} + export function buildTransport(opts: ConnectOptions): Transport { + assertMtlsPair(opts); const isTls = opts.gateway.startsWith('https://'); return createGrpcTransport({ baseUrl: opts.gateway, @@ -50,6 +72,8 @@ export function buildTransport(opts: ConnectOptions): Transport { nodeOptions: isTls ? { ca: opts.caCert, + cert: opts.clientCert, + key: opts.clientKey, rejectUnauthorized: opts.insecureSkipVerify ? false : undefined, } : undefined, From 500561bc69526501b948145dd176876dc609584e Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 20 Jul 2026 12:34:07 -0400 Subject: [PATCH 13/14] chore(sdk-ts): drop the demo script and its tsx dependency Remove src/demo.ts, the demo npm script, the tsx devDependency, and the tsconfig build exclude for the demo. The demo was never part of the published package, and dropping it also removes the only place that logged part of an SSH session token. Signed-off-by: Max Dubrinsky --- sdk/typescript/package-lock.json | 504 ----------------------------- sdk/typescript/package.json | 2 - sdk/typescript/src/demo.ts | 140 -------- sdk/typescript/tsconfig.build.json | 2 +- 4 files changed, 1 insertion(+), 647 deletions(-) delete mode 100644 sdk/typescript/src/demo.ts diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index eec8842000..bebd687779 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -18,7 +18,6 @@ "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", - "tsx": "^4.19.2", "typescript": "^6.0.3", "vitest": "^4.1.10" }, @@ -458,448 +457,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1459,48 +1016,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2050,25 +1565,6 @@ "license": "0BSD", "optional": true }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 572b723e90..484cfd7ac7 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -35,7 +35,6 @@ "format": "biome check --write .", "lint": "biome ci .", "test": "vitest run", - "demo": "tsx src/demo.ts", "prepublishOnly": "npm run gen && npm run build" }, "dependencies": { @@ -48,7 +47,6 @@ "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", - "tsx": "^4.19.2", "typescript": "^6.0.3", "vitest": "^4.1.10" } diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts deleted file mode 100644 index 4ed2b8157b..0000000000 --- a/sdk/typescript/src/demo.ts +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Runnable smoke test exercising the SDK surface against any OpenShell gateway: -// -// OPENSHELL_GATEWAY=http://127.0.0.1:8080 \ -// OPENSHELL_DEFAULT_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ -// npm run demo -// -// Auth: set OPENSHELL_OIDC_TOKEN / OPENSHELL_EDGE_TOKEN / OPENSHELL_CA_CERT / -// OPENSHELL_INSECURE as needed. With none set it assumes a plaintext local gateway. -// Set OPENSHELL_DEMO_PROVIDER= to exercise the provider attach/detach path -// against an existing gateway provider. - -import { readFileSync } from 'node:fs'; -import * as http from 'node:http'; -import { type ExecResult, errorCode, OpenShellClient, type SandboxSpec } from './index.js'; - -const env = process.env; -const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080'; -const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -function httpGet(url: string): Promise { - return new Promise((resolve, reject) => { - const req = http.get(url, (res) => { - res.resume(); - resolve(res.statusCode ?? 0); - }); - req.on('error', reject); - }); -} - -async function main() { - const client = await OpenShellClient.connect({ - gateway, - caCert, - oidcToken: env.OPENSHELL_OIDC_TOKEN, - edgeToken: env.OPENSHELL_EDGE_TOKEN, - insecureSkipVerify: env.OPENSHELL_INSECURE === '1', - }); - - const health = await client.health(); - console.log(`health: ${health.status} (v${health.version})`); - - const spec: SandboxSpec = { - image: env.OPENSHELL_DEFAULT_IMAGE, - labels: { 'openshell.dev/demo': 'sdk-ts' }, - }; - const ref = await client.sandbox.create(spec); - console.log(`created: ${ref.name} [${ref.phase}]`); - - await client.sandbox.waitReady(ref.name, 120); - console.log(`ready: ${ref.name}`); - - const result: ExecResult = await client.sandbox.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { - timeoutSecs: 30, - }); - console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`); - - // execStream: incremental chunks as they arrive. - process.stdout.write('execStream: '); - for await (const chunk of client.sandbox.execStream(ref.name, [ - '/bin/sh', - '-c', - 'for i in 1 2 3; do echo line $i; done', - ])) { - process.stdout.write(chunk.data); - } - - // execInteractive: scripted (non-TTY) bidi round-trip through `cat`. - const session = await client.sandbox.execInteractive(ref.name, ['cat'], { - tty: false, - }); - let echoed = ''; - const collect = (async () => { - for await (const chunk of session.output) echoed += chunk.data.toString(); - })(); - session.write(Buffer.from('interactive hello\n')); - await sleep(300); - session.close(); - await collect; - console.log(`execInteractive: echo=${echoed.trim()} exit=${await session.done}`); - - // SSH session mint + revoke. - const ssh = await client.sandbox.createSshSession(ref.name); - console.log( - `ssh session: token=${ssh.token.slice(0, 8)}… gateway=${ssh.gatewayScheme}://${ssh.gatewayHost}:${ssh.gatewayPort}`, - ); - console.log(`ssh revoked: ${await client.sandbox.revokeSshSession(ssh.token)}`); - - // Config + policy round-trip (network-policy-safe: re-apply the current policy). - const config = await client.sandbox.getConfig(ref.name); - console.log( - `config: policyHash=${config.policyHash} version=${config.version} settings=${Object.keys(config.settings).length}`, - ); - if (config.policy) { - const update = await client.sandbox.setPolicy(ref.name, config.policy, { - wait: true, - }); - const after = await client.sandbox.getConfig(ref.name); - console.log(`setPolicy: version=${update.version} hashMatches=${after.policyHash === update.policyHash}`); - } - - // Providers (optional: requires an existing gateway provider). - const providerName = env.OPENSHELL_DEMO_PROVIDER; - if (providerName) { - const attach = await client.sandbox.attachProvider(ref.name, providerName); - console.log(`attach ${providerName}: changed=${attach.changed}`); - const providers = await client.sandbox.listProviders(ref.name); - console.log(`providers: ${providers.map((p) => `${p.name}(${p.type})`).join(', ')}`); - const detach = await client.sandbox.detachProvider(ref.name, providerName); - console.log(`detach ${providerName}: changed=${detach.changed}`); - } - - // Forward: start an in-sandbox listener and drive a request through the tunnel. - await client.sandbox.exec(ref.name, [ - '/bin/sh', - '-c', - 'nohup python3 -m http.server 8111 >/tmp/http.log 2>&1 & sleep 1', - ]); - const forward = await client.sandbox.forward(ref.name, { targetPort: 8111 }); - console.log(`forward: 127.0.0.1:${forward.localPort} -> sandbox:8111`); - const status = await httpGet(`http://127.0.0.1:${forward.localPort}/`); - console.log(`forward GET status: ${status}`); - await forward.close(); - - const all = await client.sandbox.list({ - labelSelector: 'openshell.dev/demo=sdk-ts', - }); - console.log(`listed ${all.length} demo sandbox(es)`); - - console.log(`deleted: ${await client.sandbox.delete(ref.name)}`); -} - -main().catch((e) => { - console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e); - process.exit(1); -}); diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json index 8875968cbd..f9e1a948c2 100644 --- a/sdk/typescript/tsconfig.build.json +++ b/sdk/typescript/tsconfig.build.json @@ -9,5 +9,5 @@ "sourceMap": true }, "include": ["src/**/*.ts"], - "exclude": ["src/demo.ts", "src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts"] } From 7193806c1d6ba42dd1eada09ee3c5e4d56965bd4 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 20 Jul 2026 12:34:07 -0400 Subject: [PATCH 14/14] fix(sdk-ts)!: harden exec streaming, waits, SSH, and forwarding Address review feedback on the sandbox surface. - Make the streamed command exit code observable from idiomatic for-await: the terminal exit is now an in-band ExecStreamEvent ({ type: 'exit', exitCode }) rather than the async generator return value, which for-await discards. A stream that ends without an exit event now throws instead of reporting success. - Bound waitReady, waitDeleted, and the setPolicy wait by their timeout: each poll RPC carries a per-iteration deadline and the waits accept an AbortSignal, so a stalled call can no longer leave a wait pending forever. Add waitTimeoutSecs to SetPolicyOptions. - Validate the CreateSshSession response against the proto charset and range contract before returning it or using its token, since the values feed an OpenSSH ProxyCommand. - Respect socket backpressure when relaying forwarded responses: pause reading the gRPC stream when the local socket buffer is full and resume on drain so memory stays bounded. - Expose create-time sandbox policy: add policy and an advanced rawSpec passthrough to SandboxSpec so the safety boundary is expressible at creation and new spec fields do not require an SDK change. BREAKING CHANGE: execStream and the interactive exec output now yield a terminal { type: 'exit', exitCode } event; consumers iterating the stream must handle that arm. The exit code is no longer the async generator return value. Signed-off-by: Max Dubrinsky --- sdk/typescript/README.md | 27 ++- sdk/typescript/src/client.test.ts | 253 +++++++++++++++++++++++++++-- sdk/typescript/src/client.ts | 235 +++++++++++++++++++++------ sdk/typescript/src/index.ts | 3 + sdk/typescript/src/ssh-validate.ts | 60 +++++++ 5 files changed, 514 insertions(+), 64 deletions(-) create mode 100644 sdk/typescript/src/ssh-validate.ts diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 20f7250cfd..4ebe33b8a4 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -39,6 +39,20 @@ console.log(result.stdout.toString()) await client.sandbox.delete(sandbox.name) ``` +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 @@ -53,21 +67,24 @@ 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 terminal value carries the exit code; the chunks carry the bytes. `exec` drains `execStream` internally, so its buffered `ExecResult` is unchanged. +`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 chunk of client.sandbox.execStream(name, ['pytest', '-q'])) { - process[chunk.stream].write(chunk.data) // 'stdout' | 'stderr' +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`; `done` resolves with the exit code once the stream ends. It ships raw bytes only — raw mode, signal forwarding, and SIGWINCH stay with the caller. +`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 chunk of session.output) process.stdout.write(chunk.data) +for await (const event of session.output) { + if (!('type' in event)) process.stdout.write(event.data) +} const code = await session.done ``` diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index d7d2f911ae..51d63555c7 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -62,7 +62,7 @@ describe('exec / execStream', () => { expect(Buffer.isBuffer(result.stdout)).toBe(true); }); - it('execStream yields incremental chunks and returns an exit-only ExecResult', async () => { + 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 @@ -74,21 +74,75 @@ describe('exec / execStream', () => { }); const chunks: Array<{ stream: string; data: string }> = []; - const gen = sandbox.execStream('sb', ['x']); - let next = await gen.next(); - for (; next.done !== true; next = await gen.next()) { - chunks.push({ - stream: next.value.stream, - data: next.value.data.toString(), - }); + 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(next.value.exitCode).toBe(0); - expect(next.value.stdout.length).toBe(0); - expect(next.value.stderr.length).toBe(0); + 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('maps a NotFound from get() to an SdkError not_found', async () => { @@ -104,6 +158,74 @@ describe('exec / execStream', () => { }); }); +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']); + }); +}); + +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', + }); + }); +}); + describe('execInteractive', () => { it('sends start first with tty/cols/rows, streams output, and resolves done', async () => { const cases: string[] = []; @@ -134,7 +256,9 @@ describe('execInteractive', () => { }); const out: string[] = []; const collector = (async () => { - for await (const chunk of session.output) out.push(chunk.data.toString()); + for await (const event of session.output) { + if (!('type' in event)) out.push(event.data.toString()); + } })(); session.write(Buffer.from('echo hi')); @@ -306,6 +430,27 @@ describe('config / policy', () => { 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; @@ -383,6 +528,32 @@ describe('ssh sessions', () => { 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, gatewayScheme: 'ftp' }, + { ...base, token: 'tok; rm -rf /' }, + { ...base, gatewayPort: 70000 }, + ]; + 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', + }); + } + }); }); describe('forward', () => { @@ -466,4 +637,62 @@ describe('forward', () => { }); 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; + }); }); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 6671ae325f..929769761f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -14,7 +14,7 @@ import type { AddressInfo } from 'node:net'; import * as net from 'node:net'; import type { MessageInitShape } from '@bufbuild/protobuf'; -import { type Client, createClient, type Transport } from '@connectrpc/connect'; +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'; @@ -22,11 +22,13 @@ 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; @@ -50,6 +52,20 @@ export interface SandboxSpec { 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 { @@ -86,6 +102,17 @@ export interface ExecStreamChunk { 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; @@ -99,16 +126,23 @@ export interface ExecInteractiveOptions { } // The transport half of an interactive exec: raw stdin/stdout/stderr plus -// resize, with no terminal glue. Drive it by consuming `output`; `done` -// resolves with the exit code once the stream reaches its exit event. +// 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; + 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; @@ -189,6 +223,8 @@ export interface SetPolicyOptions { 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 { @@ -276,10 +312,60 @@ function versionPin(value: string | undefined): bigint { return value ? BigInt(value) : 0n; } -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - 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 @@ -362,15 +448,23 @@ export class SandboxClient { 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: { - environment: spec.environment ?? {}, - providers: spec.providers ?? [], - template: spec.image ? { image: spec.image } : undefined, - resourceRequirements: spec.gpu ? { gpu: {} } : undefined, - }, + spec: specInit, }); return sandboxRef(resp.sandbox); } catch (e) { @@ -378,9 +472,9 @@ export class SandboxClient { } } - async get(name: string): Promise { + async get(name: string, callOptions?: CallOptions): Promise { try { - const resp = await this.grpc.getSandbox({ name }); + const resp = await this.grpc.getSandbox({ name }, callOptions); return sandboxRef(resp.sandbox); } catch (e) { throw fromConnect(e); @@ -409,44 +503,61 @@ export class SandboxClient { } } - async waitReady(name: string, timeoutSecs: number): Promise { + // 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 (;;) { - const ref = await this.get(name); + 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 sleep(delay); + await waitSleep(delay, deadline, signal); delay = Math.min(delay * 2, 2000); } } - async waitDeleted(name: string, timeoutSecs: number): Promise { + // 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); + await this.get(name, deadlineOptions(deadline - Date.now(), signal)); } catch (e) { if (e instanceof SdkError && e.code === 'not_found') return; - throw e; + throw mapWaitError(e, name, deadline, signal); } if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); - await sleep(delay); + await waitSleep(delay, deadline, signal); delay = Math.min(delay * 2, 2000); } } - // Stream stdout/stderr as they arrive. The terminal ExecResult carries the - // exit code; its stdout/stderr are empty — the yielded chunks are the data. - // `exec()` drains this to reconstruct the buffered result, so both share one - // path. + // 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 { + ): AsyncGenerator { try { // Resolve the sandbox id first, exactly like the gateway client. const sandbox = await this.get(name); @@ -460,7 +571,7 @@ export class SandboxClient { tty: false, }); - let exitCode = -1; + let sawExit = false; for await (const event of stream) { switch (event.payload.case) { case 'stdout': @@ -476,11 +587,12 @@ export class SandboxClient { }; break; case 'exit': - exitCode = event.payload.value.exitCode; + sawExit = true; + yield { type: 'exit', exitCode: event.payload.value.exitCode }; break; } } - return { exitCode, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + if (!sawExit) throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event'); } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); } @@ -489,15 +601,19 @@ export class SandboxClient { async exec(name: string, command: string[], options?: ExecOptions | null): Promise { const stdout: Buffer[] = []; const stderr: Buffer[] = []; - const stream = this.execStream(name, command, options); - let next = await stream.next(); - for (; next.done !== true; next = await stream.next()) { - const chunk = next.value; - if (chunk.stream === 'stdout') stdout.push(chunk.data); - else stderr.push(chunk.data); + 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: next.value.exitCode, + exitCode, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), }; @@ -545,8 +661,8 @@ export class SandboxClient { rejectDone = reject; }); - async function* output(): AsyncGenerator { - let exitCode = -1; + async function* output(): AsyncGenerator { + let sawExit = false; try { for await (const event of stream) { switch (event.payload.case) { @@ -563,11 +679,15 @@ export class SandboxClient { }; break; case 'exit': - exitCode = event.payload.value.exitCode; + sawExit = true; + yield { type: 'exit', exitCode: event.payload.value.exitCode }; + resolveDone(event.payload.value.exitCode); break; } } - resolveDone(exitCode); + if (!sawExit) { + throw new SdkError('rpc', 'ExecSandboxInteractive stream ended without an exit event'); + } } catch (e) { const err = e instanceof SdkError ? e : fromConnect(e); rejectDone(err); @@ -669,6 +789,10 @@ export class SandboxClient { input.onDrain = () => socket.resume(); try { const session = await this.grpc.createSshSession({ sandboxId }); + // 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); token = session.token; input.push({ payload: { @@ -701,7 +825,10 @@ export class SandboxClient { for await (const frame of this.grpc.forwardTcp(input)) { if (frame.payload.case !== 'data') continue; const data = frame.payload.value; - if (data.length > 0) socket.write(Buffer.from(data)); + 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); } socket.end(); } catch { @@ -724,6 +851,9 @@ export class SandboxClient { 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); return { sandboxId: resp.sandboxId, token: resp.token, @@ -790,10 +920,10 @@ export class SandboxClient { } } - async getConfig(name: string): Promise { + async getConfig(name: string, callOptions?: CallOptions): Promise { try { - const sandbox = await this.get(name); - const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }); + 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); @@ -817,7 +947,7 @@ export class SandboxClient { expectedResourceVersion: versionPin(options?.expectedResourceVersion), }); const result = updateConfigResult(resp); - if (options?.wait) await this.waitForPolicyHash(name, result.policyHash); + if (options?.wait) await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs); return result; } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); @@ -844,16 +974,27 @@ export class SandboxClient { } } + // 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 (;;) { - const config = await this.getConfig(name); + 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 sleep(delay); + await waitSleep(delay, deadline); delay = Math.min(delay * 2, 2000); } } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index bb12231127..0bbe0ccfe2 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -11,11 +11,13 @@ export type { ConnectOptions, EffectiveSettingView, + ExecExitEvent, ExecInteractiveOptions, ExecInteractiveSession, ExecOptions, ExecResult, ExecStreamChunk, + ExecStreamEvent, ForwardHandle, ForwardOptions, Health, @@ -31,5 +33,6 @@ export type { SettingValue, SshSession, UpdateConfigResult, + WaitOptions, } from './client.js'; export { errorCode, OpenShellClient, SandboxClient } from './client.js'; diff --git a/sdk/typescript/src/ssh-validate.ts b/sdk/typescript/src/ssh-validate.ts new file mode 100644 index 0000000000..1d679193c2 --- /dev/null +++ b/sdk/typescript/src/ssh-validate.ts @@ -0,0 +1,60 @@ +// 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 { 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 GATEWAY_HOST = /^[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}`); +} + +// Throw SdkError('invalid_config') if any field violates the proto contract. +export function validateSshResponse(resp: SshResponseFields): void { + if (!SANDBOX_ID.test(resp.sandboxId)) { + reject('sandbox_id', 'must match [A-Za-z0-9._-]{1,128}'); + } + + 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 || !GATEWAY_HOST.test(resp.gatewayHost)) { + reject('gateway_host', 'must be 1..253 bytes of [A-Za-z0-9.-:[]]'); + } + + 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'"); + } + + if (resp.hostKeyFingerprint !== '' && !FINGERPRINT.test(resp.hostKeyFingerprint)) { + reject('host_key_fingerprint', 'must match [A-Za-z0-9:+/=-] when non-empty'); + } +}