* feat(sdk): add TypeScript SDK (@nvidia/openshell-sdk) 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * 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 <mdubrinsky@nvidia.com> * feat(sdk-ts): export error contract, enum unions, and caller cancellation Address Tier-1 review feedback on the TypeScript SDK public surface (PR #2122). - Errors: export SdkError and SdkErrorCode so callers can use instanceof and exhaustively switch on .code. fromConnect preserves the originating ConnectError as .cause and its status as .connectCode, maps Aborted to a new 'aborted' code for optimistic-concurrency conflicts, and maps Canceled and DeadlineExceeded to 'canceled'. errorCode() behavior is unchanged. - Enums: replace the string-typed phase, status, scope, and policySource fields with lowercase literal unions (SandboxPhaseName, HealthStatus, SettingScopeName, PolicySourceName) backed by exhaustive Record maps. The unions are a hand-maintained mirror of the generated proto enums; a new drift test pins each literal to its generated member name. - Cancellation: accept an optional AbortSignal on exec, execInteractive, and forward, threaded into both sandbox resolution and the streaming RPC. forward tears down its local listener on abort. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * feat(sdk-ts): add raw escape hatch for uncurated gateway RPCs The curated sub-clients reduce proto messages to ergonomic subsets (for example get() drops created_at_ms, the full spec, conditions, runtime endpoints, and current_policy_version), and not every gateway RPC has a typed helper yet. Rather than ship methods that exist but throw, expose a generated client for the full surface. OpenShellClient.raw and SandboxClient.raw are generated clients covering every gateway RPC, returning the verbatim wire messages so proto distinctions the curated types smooth over are preserved. .transport exposes the shared connection for building extra clients over one socket. Generated request/response types are published at the new @nvidia/openshell-sdk/raw subpath. Curated methods stay the default; raw is the always-available floor. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk-ts): address review feedback on exec, forward, and auth transport Settle exec's `done` promise before yielding the exit event so a consumer that breaks on exit no longer leaves it pending forever, and give it a lone rejection handler plus a finally-settle so a stream error or early abandon can never surface as an unhandled rejection or a hang. Attach an 'error' listener to each accepted forward socket synchronously, before forwardConnection awaits CreateSshSession; a peer reset in that window previously emitted an unhandled 'error' and crashed the process. Reject ambiguous or unsafe transport configs at buildTransport: oidcToken and edgeToken together (silently OIDC-only), and any auth token sent over plaintext http:// to a non-loopback host unless allowInsecureAuth is set. Wrap versionPin so a non-u64 expectedResourceVersion raises SdkError('invalid_config') instead of a raw BigInt SyntaxError, and raise the Node engine floor to >=20.3 for AbortSignal.any(). Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk-ts): address review feedback Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(sdk-ts): defer published sdk guide Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> Signed-off-by: Drew Newberry <anewberry@nvidia.com> Co-authored-by: Drew Newberry <anewberry@nvidia.com>
@nvidia/openshell-sdk
TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs.
Distributed via GitHub Packages. A public npm release under the same name follows once the npm org is in place; the install specifier and API are unchanged across that move.
Use the SDK and gateway from the same OpenShell release when possible. The raw types and RPC descriptors are generated from the protobuf definitions in that release; curated methods remain compatible while those RPC contracts remain compatible.
Install
Published to GitHub Packages, so point the @nvidia scope at it with a project .npmrc:
@nvidia:registry=https://npm.pkg.github.com
Authenticate with a GitHub token that has read:packages, then:
npm install @nvidia/openshell-sdk
Usage
import { OpenShellClient } from '@nvidia/openshell-sdk'
const client = await OpenShellClient.connect({
gateway: 'https://gateway.example.com',
oidcToken: process.env.OPENSHELL_TOKEN,
})
const sandbox = await client.sandbox.create({
image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest',
})
await client.sandbox.waitReady(sandbox.name, 120)
const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello'])
console.log(result.stdout.toString())
await client.sandbox.delete(sandbox.name)
connect() constructs a lazy client; call health() when startup must verify
gateway reachability. Authentication material is static for the client's
lifetime, so create a new client after refreshing an OIDC or edge token. The
root client has no explicit close method because Connect does not retain a
dedicated session. Close operation-scoped streams and forward handles instead.
Express the create-time safety boundary with policy. Sandbox-scoped setPolicy
cannot introduce static policy fields later, so set filesystem, landlock,
process, and initial network policy at creation. For proto spec fields the
curated shape does not surface, rawSpec is an escape hatch that shallow-
overrides the assembled spec at the top level (any field it sets wins):
await client.sandbox.create({
image,
policy: { version: 1, networkPolicies: {} },
rawSpec: { logLevel: 'debug', template: { runtimeClassName: 'gvisor' } },
})
Scoped clients
client.sandbox is a SandboxClient. If you only need sandboxes, connect one
directly — same API, one less hop:
import { SandboxClient } from '@nvidia/openshell-sdk'
const sandbox = await SandboxClient.connect({ gateway, oidcToken })
await sandbox.create({ image })
Streaming and interactive exec
execStream yields stdout/stderr chunks as they arrive, so long or chatty commands surface output incrementally instead of buffering until exit. The stream ends with a terminal { type: 'exit', exitCode } event, yielded in-band so a failing command cannot look successful under for await. Discriminate it with 'type' in event. If the gateway closes the stream without an exit event, execStream throws. exec drains execStream internally, so its buffered ExecResult is unchanged.
for await (const event of client.sandbox.execStream(name, ['pytest', '-q'])) {
if ('type' in event) console.log(`exit ${event.exitCode}`)
else process[event.stream].write(event.data) // 'stdout' | 'stderr'
}
execInteractive is the TTY + stdin transport primitive. Drive it by consuming output, which yields the same chunk/exit events; done resolves with the exit code once the stream reaches its exit event and rejects if it ends without one. It ships raw bytes only; raw mode, signal forwarding, and SIGWINCH stay with the caller.
const session = await client.sandbox.execInteractive(name, ['bash'])
session.write(Buffer.from('echo hi\n'))
session.resize(120, 40)
for await (const event of session.output) {
if (!('type' in event)) process.stdout.write(event.data)
}
const code = await session.done
Port forwarding
forward binds a local TCP listener and tunnels each accepted connection into the sandbox for the lifetime of the Node process. Call close() on teardown.
const fwd = await client.sandbox.forward(name, {
targetPort: 8000,
onConnectionError: (error) => console.error(error),
})
// ... reach the sandbox service at 127.0.0.1:fwd.localPort ...
await fwd.close()
close() is idempotent. It cancels active forwarding RPCs, destroys accepted
sockets, and waits for their cleanup.
SSH sessions, providers, config and policy
const ssh = await client.sandbox.createSshSession(name)
await client.sandbox.revokeSshSession(ssh.token)
await client.sandbox.attachProvider(name, 'claude')
await client.sandbox.listProviders(name)
await client.sandbox.detachProvider(name, 'claude')
const config = await client.sandbox.getConfig(name)
config.policy!.networkPolicies['web'] = { name: 'web', endpoints: [], binaries: [] }
await client.sandbox.setPolicy(name, config.policy!, { wait: true })
await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolValue', value: true } })
Sandbox-scoped setPolicy may only change networkPolicies; static fields (filesystem, landlock, process) must match the create-time policy. Sandbox-scoped setting deletes are rejected by the gateway, so only upsert (setSetting) is exposed here.
Surface and roadmap
The SDK's goal is agent parity: anything the OpenShell gateway can do should be reachable from typed code, not only the CLI. The API is organized as scoped sub-clients over one shared connection, mirroring the CLI's verbs.
client.sandbox(SandboxClient) is available today: sandbox lifecycle, exec, forward, SSH, sandbox-scoped providers, config, and policy.client.gateway(GatewayClient) is planned: gateway-scoped config and settings, health, and cluster status.client.providers(ProviderClient) is planned: gateway-scoped provider CRUD and profiles.
health() lives at the root today and will move under client.gateway (with a root alias) when that lands.
Curated methods are added deliberately, so some gateway RPCs are not yet wrapped in a typed helper. Rather than ship methods that exist but throw, the SDK omits what it has not curated and gives you the raw escape hatch below to reach the full gateway surface today. Omission means "not yet ergonomic," never "impossible."
Advanced: raw escape hatch
client.raw is a generated client for every gateway RPC, including surface the curated sub-clients do not wrap yet (gateway config, provider CRUD, policy status, watch, logs, and the full observed Sandbox). client.transport is the shared connection, so extra clients reuse one socket. Generated request and response types live at @nvidia/openshell-sdk/raw.
import { OpenShellClient } from '@nvidia/openshell-sdk'
import type { GetGatewayConfigResponse } from '@nvidia/openshell-sdk/raw'
const client = await OpenShellClient.connect({ gateway, oidcToken })
// Reach RPCs the curated surface does not wrap yet:
const cfg: GetGatewayConfigResponse = await client.raw.getGatewayConfig({})
const status = await client.raw.getSandboxPolicyStatus({ name: 'my-sandbox', version: 0, global: false })
The raw layer returns the generated wire messages verbatim, preserving proto distinctions (an omitted optional versus an explicitly empty map) that the curated types may smooth over. As curated sub-clients land, prefer them; raw stays as the always-available floor.
Boundaries
The SDK ships primitives, not the CLI's terminal experience. Some things are intentionally out of scope:
- Interactive
connect()/ PTY ownership.execInteractive,createSshSession, andforwardare the transport primitives; raw mode, OpenSSHProxyCommand, 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/execStreamwithstdincovers it. A first-class gateway file-transfer RPC is a follow-up.- Detached / background forwards. An in-process forward cannot outlive its caller;
forwardis 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.
mise run sdk:ts:proto # generate stubs from proto/ with buf
mise run sdk:ts:format # Biome: format + safe fixes (writes)
mise run sdk:ts:lint # Biome: lint + format check (read-only)
mise run sdk:ts:typecheck # tsc --noEmit
mise run sdk:ts:test # Vitest unit tests with an 80% line-coverage gate
mise run sdk:ts:build # emit dist/
Formatting and linting are handled by Biome (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.