Files
OpenShell/sdk/typescript
grsandsjenning 37712756de feat(sandbox): support storage-preserving sandbox configuration updates (#4873)
* feat(gateway): add storage-preserving sandbox image updates

Add image-only UpdateSandbox with conditional versions, durable replay, fenced pending intent, and Kubernetes storage retention. Enable non-destructive image repair after completed timeout cleanup and exercise both Kubernetes controller API lanes.

Closes #4623

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* feat(gateway): add storage-preserving sandbox image updates

OpenClaw-Publication: b2036b09-b697-4d65-aba9-5c96a3198f9c
Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* fix(gateway): preserve image update patch-release compatibility

Move image updates to a separate authenticated service and retain the baseline public sandbox status. Persist image intent and revisions independently with atomic baseline-known lifecycle fencing and explicit post-upgrade activation.

Preserve concurrent provisioning clocks, fence replaced attempts and delayed callbacks, and verify private-state transactions plus both Kubernetes controller API lanes.

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* fix(gateway): preserve optional driver compatibility and repair CI

Move image updates to a separate optional compute-driver service, preserving the baseline generated trait and streaming associated type. Add source and TCP/UDS transport regressions, capability negotiation coverage, and a default-unsupported in-process extension.

Keep Go compatibility tests outside generated output and update production reflection expectations for the separate sandbox update service.

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* fix(gateway): preserve baseline factory and descriptor APIs

Restore the original in-process driver trait object and existing instance enum. Register optional image updates through a default factory hook without requiring changes to existing drivers or factories, and require registration plus capability before mutation.

Accept OpenShell-only descriptors while retaining strict validation of present update services. Cover baseline registration, missing-updater no-write behavior, descriptor validation, and both live Kubernetes retention lanes.

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* chore(ci): use upstream Kubernetes E2E defaults

Drop the task-level fallback superseded by main's runner defaults and explicit e2e-conformance-only selector. Preserve current upstream task definitions and recovery waiver unchanged.

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* fix(gateway): integrate image updates with backend protocol

Adapt non-launch image updates to the upstream remote admission API while retaining resource-policy verification before forwarding. Cover both negotiated extensions and preservation of backend pins and persisted launch identity.

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>

* fix(gateway): remove sandbox image state on deletion

Signed-off-by: Gordon Sim <gsim@redhat.com>

* feat(gateway): expand UpdateSandbox configuration updates

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(sandbox): recover pending updates and address review feedback

Signed-off-by: Gordon Sim <gsim@redhat.com>

* perf(server): index pending sandbox updates for recovery

Signed-off-by: Gordon Sim <gsim@redhat.com>

* docs(sandbox): clarify stop behavior when updates are disabled

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(server): scope deletion bypass to the matching sandbox update

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(server): back off pending sandbox update recovery

Signed-off-by: Gordon Sim <gsim@redhat.com>

* ci: restore combined-chart Kubernetes e2e coverage

Signed-off-by: Gordon Sim <gsim@redhat.com>

* test(kubernetes): use uuid for update request IDs

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(kubernetes): reject oversized annotations before accepting sandbox updates

Signed-off-by: Gordon Sim <gsim@redhat.com>

* docs(skills): correct pending sandbox update recovery guidance

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(sandbox): preserve terminal results when stopping missing compute

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(server): bound sandbox update attempts to unblock recovery and deletion

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(server): remove unintended sandbox label count limit

Signed-off-by: Gordon Sim <gsim@redhat.com>

* fix(kubernetes): check known pod names during sandbox update preflight

Signed-off-by: Gordon Sim <gsim@redhat.com>

* docs: clarify sandbox update permissions and driver support

Signed-off-by: Gordon Sim <gsim@redhat.com>

---------

Signed-off-by: sjenning <493891+sjenning@users.noreply.github.com>
Signed-off-by: Gordon Sim <gsim@redhat.com>
Co-authored-by: sjenning <493891+sjenning@users.noreply.github.com>
2026-10-30 17:01:49 +00:00
..

@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: 'registry.example.com/agents/python:latest',
  serviceExposures: [{ targetPort: 8080 }],
})
console.log(sandbox.serviceUrls[''])
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())

const deletion = await client.sandbox.delete(sandbox.name)
console.log(deletion.outcome) // completed, accepted, or already_absent
if (deletion.outcome === 'accepted') {
  await client.sandbox.waitDeleted(sandbox.name, 60, {
    expectedSandboxId: deletion.sandboxId,
  })
}

Deletion returns a typed result, not a boolean. accepted means cleanup is pending; unspecified and unknown outcomes do not establish completion. sandboxId identifies the original sandbox. Missing targets are errors unless you pass { allowMissing: true }; this does not suppress missing parents or make retries safe when names are reused. Upgrade gateway and SDK together for this pre-1.0 API change.

waitDeleted with expectedSandboxId completes when the name is absent or resolves to a different ID. Without that option, it waits for name absence, including any same-name replacement. Pass the same workspace to deletion and its wait when using a non-default workspace.

Sandbox refs from create, get, and list carry createdAtMs (epoch milliseconds), absent when the gateway omits it.

connect() constructs a lazy client; call health() when startup must verify gateway reachability. Static oidcToken and edgeToken values remain fixed for the client's lifetime. For long-running service automation, use the renewable client-credentials provider:

import { clientCredentials, OpenShellClient } from '@nvidia/openshell-sdk'

const client = await OpenShellClient.connect({
  gateway,
  oidcTokenProvider: clientCredentials({
    issuer: 'https://idp.example.com/realms/openshell',
    clientId: 'openshell-service',
    clientSecret: () => process.env.OPENSHELL_OIDC_CLIENT_SECRET!,
    scopes: ['sandbox:read', 'sandbox:write'],
    audience: 'openshell-gateway',
  }),
})

The provider discovers and validates the issuer, keeps credentials and tokens in memory, and renews before expiry. 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 })

Provider credential retrieval

client.providers.getCredentials(name, keys, options) exports selected runtime credentials using direct gateway mTLS with a trusted OU=operator certificate. Enable operator authentication at the gateway and omit OIDC and edge tokens. No sandbox or provider attachment is required.

import { ProviderClient } from '@nvidia/openshell-sdk'

const providers = await ProviderClient.connect({ gateway, caCert, clientCert, clientKey })
const credentials = await providers.getCredentials(
  'my-graph',
  ['MS_GRAPH_ACCESS_TOKEN'],
  { workspace: 'production', minimumRemainingLifetimeSecs: 300 },
)
const token = credentials.MS_GRAPH_ACCESS_TOKEN!.value

The workspace and a nonempty selection are required. The result maps keys to ProviderCredentialValue objects with value and optional expirationTime (Date, truncating sub-millisecond precision). Lifetime is in seconds, including fractions; omission or zero uses the gateway's five-minute margin, up to a 24-hour requested maximum. signal and timeoutMs bound the RPC, but canceling delivery does not roll back an already-started refresh. The helper does not retry or cache exports. Do not log the result: the values can be used outside sandbox policy enforcement. See the published SDK example and provider reference.

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. Consume output concurrently with awaiting done. The helper yields its terminal exit event and resolves done only after receiving an exit event and successful final gRPC status. If transport completion fails, done rejects and session.exitCode retains any observed process exit code. Duplicate exit events and output after exit are errors.

The RPC starts immediately when the session is created, even before you consume output. A background receiver observes transport errors and buffers up to 1 MiB of output in 64 KiB chunks. Once the queue fills, receiving waits for the caller to drain it; cancellation interrupts that wait. The bound excludes the transport's current response frame and its own buffers.

Call closeInput() (or its compatibility alias close()) to end stdin and resize input while continuing to receive output. Later writes and resizes throw. Call cancel() to abort the RPC. Both close and cancel are idempotent. The helper ships raw bytes only; raw mode, signal forwarding, and SIGWINCH stay with the caller. Input closure is not a terminal Ctrl-D keystroke.

execInteractive() returns ExecInteractiveSessionControl, which extends the original ExecInteractiveSession with closeInput(), cancel(), and exitCode. Existing mocks and wrappers can keep implementing the original interface without adding those members. Use the extended type when a wrapper exposes the new controls.

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.

Sandbox templates

Sandbox workload templates are reusable, workspace-scoped runtime shapes. They own image, environment, resource, and driver-specific settings; sandbox creation from a template can still attach labels, providers, and create-time policy.

import { OpenShellClient, type SandboxWorkloadTemplate } from '@nvidia/openshell-sdk'

const client = await OpenShellClient.connect({ gateway, oidcToken })

const template: SandboxWorkloadTemplate = await client.sandboxTemplates.create(
  {
    metadata: { name: 'python', labels: { team: 'runtime' } },
    spec: {
      workload: {
        image: 'registry.example.com/agents/python:latest',
        environment: { FEATURE_FLAG: 'on' },
        resources: { cpu: '1', memory: '512Mi' },
      },
      driverConfig: { kubernetes: { pod: { runtime_class_name: 'kata-containers' } } },
    },
  },
  { workspace: 'default' },
)

const sandbox = await client.sandbox.createFromTemplate({
  workloadTemplate: template.metadata!.name,
  workspace: 'default',
  providers: ['github'],
  policy: { version: 1, networkPolicies: {} },
})

await client.sandboxTemplates.get('python', { workspace: 'default' })
await client.sandboxTemplates.listAll({ workspace: 'default', pageSize: 100 })
await client.sandboxTemplates.delete('python', { workspace: 'default' })

Use allWorkspaces: true on list() for a platform-admin view. The discriminated option type makes workspace and allWorkspaces mutually exclusive. Omitting both options explicitly selects the default workspace. List methods follow continuation tokens through a lazy Pager; each advance fetches one RPC page. Use listAll() only when you want to exhaust the collection. pageSize controls one request and pageToken resumes a saved traversal.

const pages = client.sandbox.list({ workspace: 'default', pageSize: 100 })
for await (const page of pages) {
  for (const sandbox of page.items) console.log(sandbox.name)
}

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.sandboxTemplates (SandboxTemplateClient) is available today: reusable sandbox workload template CRUD.
  • client.gateway (GatewayClient) is planned: gateway-scoped config and settings, health, and cluster status.
  • client.providers (ProviderClient) is available today for operator-only credential retrieval. Provider CRUD and profile helpers remain planned.

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 { create } from '@bufbuild/protobuf'
import { OpenShellClient } from '@nvidia/openshell-sdk'
import { WorkspaceSelectorSchema } from '@nvidia/openshell-sdk/raw'
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 defaultWorkspace = create(WorkspaceSelectorSchema, {
  selection: { case: 'workspace', value: 'default' },
})
const status = await client.raw.getSandboxPolicyStatus({
  sandbox: 'my-sandbox',
  version: 0,
  global: false,
  workspaceScope: defaultWorkspace,
})

The raw layer returns the generated wire messages verbatim, preserving proto distinctions (an omitted optional versus an explicitly empty map) that the curated types may smooth over. As curated sub-clients land, prefer them; raw stays as the always-available floor.

Boundaries

The SDK ships primitives, not the CLI's terminal experience. Some things are intentionally out of scope:

  • Interactive connect() / PTY ownership. execInteractive, createSshSession, and forward are the transport primitives; raw mode, OpenSSH ProxyCommand, and terminal glue stay in the CLI.
  • upload() / download(). There is no file-transfer RPC — the CLI does tar-over-SSH. For small payloads, exec/execStream with stdin covers it. A first-class gateway file-transfer RPC is a follow-up.
  • Detached / background forwards. An in-process forward cannot outlive its caller; forward is process-lifetime only.

Development

The version field is a 0.0.0 placeholder; CI stamps the real version from the git release tag at publish time, matching the Rust and Python packages.

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/
mise run e2e:sdk:ts:exec    # public interactive helper against an isolated Docker gateway

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.