Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OpenShell Go SDK

The OpenShell Go SDK provides an idiomatic Go client for the OpenShell gateway API. It wraps the underlying gRPC protocol behind typed interfaces, making it straightforward to manage sandboxes, execute commands, handle providers, and more.

Key Features

  • Sub-client pattern: A single Client provides typed accessors for each API domain (Sandboxes, Exec, Providers, Files, Health, SSH, TCP, Config, Policy, Services)
  • Clean types: SDK types are self-contained, so your code works with idiomatic Go types without extra dependencies
  • Fake client for testing: An in-memory implementation of the full ClientInterface for testing without a live gateway
  • Watch and streaming: First-class support for watching sandbox state changes and streaming command output
  • Typed error handling: Functions like IsNotFound, IsAlreadyExists, and IsConflict for precise error classification

Where to Start

If you are new to the SDK, the Quick Start guide walks you through installation, connecting to a gateway, creating your first sandbox, running a command, and cleaning up.

For a deeper understanding of how the SDK is structured, see the Architecture overview.

API Reference

Every SDK interface has a dedicated reference page with method signatures and code examples.

Browse the full API Overview to see all 13 interfaces at a glance.

Guides

  • Error Handling: StatusError, typed error checks, retry patterns
  • Testing: Fake client usage, fixture seeding, watch event testing
  • OpenShell (by NVIDIA): The upstream project that defines the gateway API and sandbox runtime this SDK wraps.
  • openshell-sdk-go: This SDK’s source repository on GitHub.
  • pkg.go.dev: Go package documentation with type signatures and godoc.

Quick Start

This guide walks you through installing the OpenShell Go SDK, connecting to a gateway, creating a sandbox, running a command, and cleaning up. You should be up and running in under 5 minutes.

Prerequisites

  • Go 1.23 or later
  • Access to an OpenShell gateway (address and authentication token)

Installation

Add the SDK to your Go module:

go get github.com/rhuss/openshell-sdk-go@latest

Connect to the Gateway

Create a client by providing the gateway address and authentication credentials:

package main

import (
    "context"
    "fmt"
    "log"

    v1 "github.com/rhuss/openshell-sdk-go/openshell/v1"
)

func main() {
    client, err := v1.NewClient(v1.Config{
        Address: "gateway.example.com:443",
        Auth:    v1.StaticToken("my-token"),
    })
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

For production use, load the token from an environment variable instead of hardcoding it: v1.StaticToken(os.Getenv("OPENSHELL_TOKEN"))

The Config struct accepts optional fields for TLS configuration and retry policies. For development against a local gateway without TLS, use v1.NoAuth() and set TLS to skip verification.

Check Gateway Health

Verify the gateway is reachable:

    ctx := context.Background()

    health, err := client.Health().Check(ctx)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Gateway healthy: %v\n", health.Healthy)

Create a Sandbox

Create a sandbox with a Python image:

    sandbox, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{
        Template: &v1.SandboxTemplate{Image: "python:3.12"},
        Environment: map[string]string{"LANG": "en_US.UTF-8"},
    }, nil)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Created sandbox: %s\n", sandbox.Name)

Wait for the Sandbox to be Ready

Sandboxes take a moment to provision. Use WaitReady to block until the sandbox is ready to accept commands:

    sandbox, err = client.Sandboxes().WaitReady(ctx, sandbox.Name)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Sandbox is ready (phase: %s)\n", sandbox.Status.Phase)

Run a Command

Execute a command inside the sandbox:

    result, err := client.Exec().Run(ctx, sandbox.Name, []string{"echo", "hello from OpenShell"})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Output: %s", result.Stdout)
    fmt.Printf("Exit code: %d\n", result.ExitCode)

For long-running commands, use Stream to receive output incrementally, or Interactive for terminal-like sessions.

Clean Up

Delete the sandbox when you are done:

    err = client.Sandboxes().Delete(ctx, sandbox.Name)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Sandbox deleted")
}

Next Steps

Architecture

This page explains how the OpenShell Go SDK is structured internally. Understanding the design helps you navigate the API surface and write idiomatic code.

Client Hierarchy

The SDK follows the Kubernetes client-go sub-client pattern. A single Client provides typed accessors for each API domain:

Client
├── Sandboxes()   → SandboxInterface
├── Exec()        → ExecInterface
├── Providers()   → ProviderInterface
│   ├── Profiles()  → ProfileInterface
│   └── Refresh()   → RefreshInterface
├── Services()    → ServiceInterface
├── Files()       → FileInterface
├── Health()      → HealthInterface
├── SSH()         → SSHInterface
├── TCP()         → TCPInterface
├── Config()      → ConfigInterface
└── Policy()      → PolicyInterface

Each accessor returns an interface. You work with the interface, not the concrete implementation. This makes the sub-clients easy to mock and test.

Creating a Client

All interaction starts with NewClient:

client, err := v1.NewClient(v1.Config{
    Address: "gateway.example.com:443",
    Auth:    v1.StaticToken("my-token"),
})
if err != nil {
    log.Fatal(err)
}
defer client.Close()

The Config struct controls:

FieldPurpose
AddressGateway host and port
AuthAuthentication provider (StaticToken, NoAuth, or custom)
TLSTLS settings (CA cert, skip verify, client certs)
RetryRetry policy for transient failures

Proto Isolation

The SDK never exposes protobuf-generated types in its public API. Instead, it defines its own Go types (in openshell/v1/) and converts to/from proto at the gRPC boundary.

This means:

  • Your code imports openshell/v1, not proto/openshellv1
  • You work with plain Go structs, not proto messages
  • Proto schema changes in upstream OpenShell do not break your code (the SDK adapts internally)
  • You can use standard Go patterns (json.Marshal, fmt.Sprintf, reflect) on SDK types without proto constraints

The conversion layer lives in openshell/v1/internal/converter/ and is not part of the public API.

Sub-Client Pattern

Each sub-client groups methods for a specific API domain. For example, SandboxInterface provides Create, Get, List, Delete, WaitReady, Watch, and more.

Sub-clients are cheap to access. They are created once when the Client is initialized and reuse the same underlying gRPC connection:

// These return the same sub-client instance every time
sandboxes := client.Sandboxes()
exec := client.Exec()

Some sub-clients have their own sub-clients. ProviderInterface exposes Profiles() and Refresh():

profiles, err := client.Providers().Profiles().List(ctx)
status, err := client.Providers().Refresh().GetStatus(ctx, "openai", "api-key")

gRPC Layer

Underneath, the SDK communicates with the OpenShell gateway over gRPC. The single OpenShell service in proto/openshell.proto defines all RPCs. The SDK maps each interface method to one or more RPCs:

PatternExample
Unary RPCCreate, Get, Delete
Server-streaming RPCWatch, Stream, GetLogs
Client-streaming RPCFile uploads
Bidirectional streamingInteractive, Forward

The gRPC connection is managed by the Client. Calling client.Close() cleanly shuts down all active streams and the underlying connection.

Error Model

All SDK methods return standard Go errors. Errors from the gateway carry a StatusError with a typed error code. Use the Is* functions to classify errors:

_, err := client.Sandboxes().Get(ctx, "missing")
if v1.IsNotFound(err) {
    // sandbox does not exist
}

See the Error Handling guide for the complete list of error checks and retry patterns.

Fake Client

For testing, the SDK provides openshell/v1/fake with an in-memory implementation of ClientInterface. The fake client supports fixture seeding, watch events, and health simulation:

fc := fake.NewClient()
fc.AddSandbox(&v1.Sandbox{Name: "test-sb", Status: v1.SandboxStatus{Phase: v1.SandboxReady}})

See the Testing guide for complete examples.

API Overview

The OpenShell Go SDK exposes 12 interfaces through the sub-client pattern. You access each interface through a typed accessor on the Client.

Interface Summary

Top-Level Interfaces

InterfaceAccessorDescription
SandboxInterfaceclient.Sandboxes()Create, manage, and watch sandbox lifecycle
ExecInterfaceclient.Exec()Run commands, stream output, interactive sessions
ProviderInterfaceclient.Providers()Manage compute providers and their lifecycle
ServiceInterfaceclient.Services()Expose and manage HTTP services inside sandboxes
FileInterfaceclient.Files()Upload and download files to/from sandboxes
HealthInterfaceclient.Health()Check gateway health status
SSHInterfaceclient.SSH()Create SSH sessions and tunnels to sandboxes
TCPInterfaceclient.TCP()Forward TCP connections to sandbox ports
ConfigInterfaceclient.Config()Read and update sandbox and gateway configuration
PolicyInterfaceclient.Policy()Manage draft policy recommendations

Convenience Packages

PackageEntry PointDescription
gatewaygateway.NewClient(name)Read CLI gateway configs and auto-wire clients

Provider Sub-Interfaces

These are accessed through client.Providers():

InterfaceAccessorDescription
ProfileInterfaceclient.Providers().Profiles()Manage provider type profiles
RefreshInterfaceclient.Providers().Refresh()Configure credential refresh strategies

Interfaces

Each interface has a reference page with method signatures and usage examples:

  • Sandboxes: Create sandboxes, wait for readiness, watch state changes, manage providers, retrieve logs.
  • Exec: Execute commands with one-shot, streaming, or interactive modes.
  • Providers: Register and manage compute providers. Includes sub-clients for profiles and credential refresh.
  • Services: Expose and manage HTTP services inside sandboxes.
  • Files: Upload and download files to/from sandboxes.
  • Health: Check gateway health status.
  • SSH: Create SSH sessions and tunnels to sandboxes.
  • TCP: Forward TCP connections to sandbox ports.
  • Config: Read and update sandbox and gateway configuration.
  • Policy: Manage draft policy recommendations.
  • Profiles: Manage provider type profiles (via client.Providers().Profiles()).
  • Refresh: Configure credential refresh strategies (via client.Providers().Refresh()).

Common Patterns

All SDK methods follow these conventions:

  • Every method takes context.Context as its first argument
  • Methods that can fail return (result, error)
  • List methods accept variadic option arguments
  • Errors from the gateway carry a StatusError with a typed code (see Error Handling)

Client

Constructor: v1.NewClient(config)

The ClientInterface is the root entry point for all SDK operations. It provides typed accessors for each resource domain and manages the underlying gRPC connection.

Methods

AccessorReturnsDescription
Sandboxes()SandboxInterfaceSandbox lifecycle management
Providers()ProviderInterfaceProvider CRUD and idempotent ensure
Services()ServiceInterfaceService exposure and management
Exec()ExecInterfaceCommand execution (run, stream, interactive)
Files()FileInterfaceFile upload and download
Health()HealthInterfaceGateway health checking
SSH()SSHInterfaceSSH session and tunnel management
TCP()TCPInterfaceTCP port forwarding
Config()ConfigInterfaceSandbox and gateway configuration
Policy()PolicyInterfaceDraft policy review workflow
Close()errorClose the gRPC connection

Sub-client hierarchy: Providers() has two nested accessors:

  • client.Providers().Profiles() returns ProfileInterface
  • client.Providers().Refresh() returns RefreshInterface

Creating a Client

import v1 "github.com/rhuss/openshell-sdk-go/openshell/v1"

client, err := v1.NewClient(v1.Config{
    Address: "gateway.example.com:443",
    Auth:    v1.StaticToken("my-token"),
})
if err != nil {
    log.Fatal(err)
}
defer client.Close()

Configuration

The Config struct controls connection behavior:

type Config struct {
    Address     string         // Gateway address (host:port)
    TLS         *TLSConfig     // TLS settings (nil uses system defaults)
    Auth        AuthProvider   // Authentication provider
    Timeout     time.Duration  // Default timeout for all operations (0 = no timeout)
    RetryPolicy *RetryPolicy   // Retry configuration (nil = no automatic retries)
    Logger      Logger         // Custom logger (nil = no logging)
}

Authentication providers:

  • v1.StaticToken(token) provides a fixed bearer token
  • v1.NoAuth() skips authentication (for local development)

Testing

For unit tests, use the fake client instead of a real connection:

import "github.com/rhuss/openshell-sdk-go/openshell/v1/fake"

client := fake.NewClient()
defer client.Close()

The fake client implements the full ClientInterface with in-memory stores. See Testing for details.

See also: Getting Started, Architecture

Sandboxes

Accessor: client.Sandboxes()

Manage sandbox lifecycle: create, inspect, delete, attach/detach providers, wait for readiness, watch state changes, and retrieve logs.

Create

Creates a new sandbox with the given name, spec, and labels.

sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{
    Template: &v1.SandboxTemplate{
        Image: "nvcr.io/nvidia/openshell:latest",
    },
    Providers: []string{"openai"},
}, map[string]string{
    "team": "platform",
})

Get

Retrieves a sandbox by name.

sb, err := client.Sandboxes().Get(ctx, "my-sandbox")
fmt.Println(sb.Status.Phase) // "Ready", "Provisioning", etc.

List

Lists sandboxes with optional pagination and label filtering.

// List all sandboxes
sandboxes, err := client.Sandboxes().List(ctx)

// With pagination and label filtering
sandboxes, err := client.Sandboxes().List(ctx, v1.ListOptions{
    Limit:         10,
    Offset:        0,
    LabelSelector: "team=platform",
})

Delete

Deletes a sandbox by name.

err := client.Sandboxes().Delete(ctx, "my-sandbox")

AttachProvider

Attaches a provider to a sandbox. The expectedResourceVersion enables optimistic concurrency control: pass the sandbox’s current ResourceVersion to ensure no other client has modified it since your last read.

sb, _ := client.Sandboxes().Get(ctx, "my-sandbox")

result, err := client.Sandboxes().AttachProvider(ctx,
    "my-sandbox",
    "openai",
    sb.ResourceVersion,
)
fmt.Println(result.Attached) // true if newly attached

DetachProvider

Detaches a provider from a sandbox. Uses the same optimistic concurrency pattern as AttachProvider.

sb, _ := client.Sandboxes().Get(ctx, "my-sandbox")

result, err := client.Sandboxes().DetachProvider(ctx,
    "my-sandbox",
    "openai",
    sb.ResourceVersion,
)
fmt.Println(result.Detached) // true if actually detached

ListProviders

Lists all providers currently attached to a sandbox.

providers, err := client.Sandboxes().ListProviders(ctx, "my-sandbox")
for _, p := range providers {
    fmt.Printf("provider: %s (type: %s)\n", p.Name, p.Type)
}

WaitReady

Blocks until the sandbox reaches the Ready phase, returning the final sandbox state. Under the hood, WaitReady polls via Get at a configurable interval (default 500ms). Use context cancellation or deadlines to set a timeout.

If the sandbox enters the Error phase, WaitReady returns immediately with a StatusError.

// Wait with a 30-second timeout
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

sb, err := client.Sandboxes().WaitReady(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
fmt.Println(sb.Status.Phase) // "Ready"

// Custom poll interval
sb, err := client.Sandboxes().WaitReady(ctx, "my-sandbox", v1.WaitOptions{
    PollInterval: 2 * time.Second,
})

There is no dedicated WaitReady RPC. The SDK implements this by polling GetSandbox until the sandbox phase is Ready or Error.

Watch

Opens a server-streaming connection to observe sandbox state changes in real time. Returns a WatchInterface[*Sandbox] that delivers events through a channel.

The WatchInterface[T] provides:

  • ResultChan() <-chan Event[T] returns the channel of events
  • Stop() closes the stream and the channel

Each Event[T] carries:

  • Type: one of EventAdded, EventModified, EventDeleted, or EventError
  • Object: the *Sandbox at that point in time (nil for EventError)
watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
defer watcher.Stop()

for event := range watcher.ResultChan() {
    switch event.Type {
    case v1.EventModified:
        fmt.Printf("phase: %s\n", event.Object.Status.Phase)
    case v1.EventDeleted:
        fmt.Println("sandbox deleted")
        return
    case v1.EventError:
        fmt.Println("watch error")
        return
    }
}

Setting StopOnTerminal: true causes the watcher to close automatically once the sandbox reaches a terminal phase (Ready or Error). This is useful for provisioning flows where you only care about the outcome.

watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox", v1.WatchOptions{
    StopOnTerminal: true,
})
if err != nil {
    log.Fatal(err)
}

for event := range watcher.ResultChan() {
    fmt.Printf("phase: %s\n", event.Object.Status.Phase)
}
// Channel closes after Ready or Error

GetLogs

Retrieves log entries from a sandbox. The sandbox is looked up by name (the SDK resolves the name to an internal ID automatically). Use functional options to filter results.

Available options:

OptionDescription
WithLogLines(n uint32)Maximum number of log lines to return
WithLogSince(t time.Time)Only include entries at or after this time
WithLogSources(sources ...string)Filter by source (e.g., "gateway", "sandbox")
WithLogMinLevel(level string)Minimum log level (e.g., "WARN", "ERROR")
// Get the last 50 log lines
result, err := client.Sandboxes().GetLogs(ctx, "my-sandbox",
    v1.WithLogLines(50),
)
for _, line := range result.Lines {
    fmt.Printf("[%s] %s: %s\n", line.Level, line.Source, line.Message)
}

// Filter by source and level since a specific time
result, err := client.Sandboxes().GetLogs(ctx, "my-sandbox",
    v1.WithLogSources("gateway"),
    v1.WithLogMinLevel("WARN"),
    v1.WithLogSince(time.Now().Add(-1*time.Hour)),
)

The LogResult contains:

  • Lines []LogLine: log entries in chronological order
  • BufferTotal uint32: total number of lines available in the server’s buffer

Each LogLine has Timestamp, Level, Target, Message, Source, and Fields (structured key-value data).

See also: Error Handling, Testing

Exec

Accessor: client.Exec()

Execute commands in a sandbox with three modes: one-shot (Run), streaming (Stream), or interactive terminal (Interactive).

Run

Execute a command and collect all output into a single result.

result, err := client.Exec().Run(ctx, "sbx-123", []string{"ls", "-la"})
if err != nil {
    log.Fatal(err)
}

fmt.Println("Exit code:", result.ExitCode)
fmt.Println("Stdout:", result.Stdout)
fmt.Println("Stderr:", result.Stderr)

The SDK collects all streamed events, assembles stdout/stderr, and returns a single ExecResult.

ExecResult contains the complete output after the command finishes:

FieldTypeDescription
StdoutstringCaptured standard output
StderrstringCaptured standard error
ExitCodeintProcess exit code

Stream

Execute a command and process output chunks as they arrive.

stream, err := client.Exec().Stream(ctx, "sbx-123", []string{"tail", "-f", "/var/log/app.log"})
if err != nil {
    log.Fatal(err)
}
defer stream.Close()

for {
    chunk, err := stream.Next()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatal(err)
    }

    if chunk.Stream == v1.StreamStdout {
        fmt.Print(string(chunk.Data))
    }
}

exitCode, err := stream.ExitCode()
if err != nil {
    log.Fatal(err)
}
fmt.Println("Exited with:", exitCode)

Interactive

Open a bidirectional terminal session with a command.

session, err := client.Exec().Interactive(ctx, "sbx-123", []string{"/bin/bash"}, 80, 24)
if err != nil {
    log.Fatal(err)
}
defer session.Close()

// Send input
_, err = session.Write([]byte("echo hello\n"))
if err != nil {
    log.Fatal(err)
}

// Read output
buf := make([]byte, 4096)
n, err := session.Read(buf)
if err != nil {
    log.Fatal(err)
}
fmt.Print(string(buf[:n]))

// Handle terminal resize
if err := session.Resize(120, 40); err != nil {
    log.Fatal(err)
}

// Get exit code after session ends
exitCode, err := session.ExitCode()
if err != nil {
    log.Fatal(err)
}
fmt.Println("Exited with:", exitCode)

The SDK wraps the bidirectional stream as an InteractiveSession with Read/Write/Resize methods.

ExecStream

ExecStream provides an iterator interface over command output chunks. Call Next() repeatedly to receive output as it is produced. When the command finishes, Next() returns io.EOF.

type ExecStream interface {
    Next() (*ExecChunk, error)
    ExitCode() (int, error)
    Close() error
}
MethodDescription
NextReturns the next output chunk. Returns io.EOF when done.
ExitCodeReturns the process exit code. Call after Next returns io.EOF.
CloseReleases the underlying stream resources.

InteractiveSession

InteractiveSession implements io.Reader and io.Writer for bidirectional communication with a running process. Use it for terminal emulation, REPL interaction, or any command that requires ongoing input.

type InteractiveSession interface {
    Read(p []byte) (int, error)
    Write(p []byte) (int, error)
    Resize(cols, rows uint32) error
    ExitCode() (int, error)
    Close() error
}
MethodDescription
ReadReads output from the process into the provided buffer.
WriteSends input to the process.
ResizeUpdates the terminal dimensions (columns and rows).
ExitCodeReturns the process exit code after the session ends.
CloseCloses the session and releases resources.

ExecChunk

Each chunk from ExecStream.Next() carries a segment of process output along with which stream it came from.

type ExecChunk struct {
    Data   []byte
    Stream StreamType
}
FieldTypeDescription
Data[]byteRaw output bytes from the process.
StreamStreamTypeEither StreamStdout or StreamStderr.

See also: Error Handling, Testing

Providers

Accessor: client.Providers()

Register and manage compute providers (AI inference endpoints). Exposes sub-clients for Profiles and Refresh.

Create

Register a new provider with the gateway.

provider, err := client.Providers().Create(ctx, &v1.Provider{
    Name: "my-openai",
    Type: "openai",
    Spec: v1.ProviderSpec{
        Credentials: map[string]string{
            "api_key": "sk-...",
        },
        Config: map[string]string{
            "base_url": "https://api.openai.com/v1",
        },
    },
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("Created provider:", provider.Name)

Get

Fetch a provider by name.

provider, err := client.Providers().Get(ctx, "my-openai")
if err != nil {
    log.Fatal(err)
}
fmt.Println("Provider type:", provider.Type)

List

List all registered providers, with optional pagination.

// List all providers
providers, err := client.Providers().List(ctx)
if err != nil {
    log.Fatal(err)
}
for _, p := range providers {
    fmt.Println(p.Name, p.Type)
}

// With pagination
providers, err = client.Providers().List(ctx, v1.ListOptions{
    Limit:  10,
    Offset: 0,
})

Update

Update an existing provider’s configuration or credentials.

provider, err := client.Providers().Get(ctx, "my-openai")
if err != nil {
    log.Fatal(err)
}

provider.Spec.Credentials["api_key"] = "sk-new-key"
updated, err := client.Providers().Update(ctx, provider)
if err != nil {
    log.Fatal(err)
}
fmt.Println("Updated provider:", updated.Name)

Delete

Remove a provider by name.

err := client.Providers().Delete(ctx, "my-openai")
if err != nil {
    log.Fatal(err)
}

Ensure

Create or update a provider in a single idempotent call. If a provider with the given name exists, it is updated; otherwise a new one is created. This is the recommended way to register providers because it avoids “already exists” errors when re-registering.

provider, err := client.Providers().Ensure(ctx, &v1.Provider{
    Name: "my-openai",
    Type: "openai",
    Spec: v1.ProviderSpec{
        Credentials: map[string]string{
            "api_key": "sk-...",
        },
    },
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("Provider ready:", provider.Name)

Ensure is an SDK-level convenience. It calls GetProvider first, then either CreateProvider or UpdateProvider depending on whether the provider already exists.

Sub-Clients

The ProviderInterface exposes two sub-client accessors for related operations. These are pure client-side accessors with no corresponding gRPC call.

Profiles

client.Providers().Profiles() returns a ProfileInterface for managing provider type profiles. Profiles define templates and defaults for different provider types.

Refresh

client.Providers().Refresh() returns a RefreshInterface for configuring credential refresh strategies. Use it to set up automatic credential rotation for providers with expiring credentials.

Provider

The Provider type represents a registered compute provider.

FieldTypeDescription
IDstringServer-assigned unique identifier
NamestringUser-chosen name (unique per gateway)
TypestringProvider type (e.g., "openai", "azure")
CreatedAttime.TimeTimestamp of creation
Labelsmap[string]stringKey-value metadata labels
ResourceVersionuint64Optimistic concurrency version
SpecProviderSpecConfiguration and credentials

ProviderSpec

ProviderSpec holds provider-specific configuration and credentials.

FieldTypeDescription
Credentialsmap[string]stringAuthentication credentials (e.g., API keys)
Configmap[string]stringProvider-specific configuration values
CredentialExpiresAtmap[string]time.TimeExpiration timestamps for credentials

See also: Profiles, Refresh, Error Handling, Testing

Profiles

Accessor: client.Providers().Profiles()

Manage provider profiles for AI model providers. Profiles define connection details, credentials, and model mappings for providers like OpenAI, Anthropic, or custom endpoints.

List

List all provider profiles visible to the current user.

profiles, err := client.Providers().Profiles().List(ctx)
if err != nil {
    log.Fatal(err)
}
for _, p := range profiles {
    fmt.Printf("Profile: %s (%s)\n", p.ID, p.DisplayName)
}

Import

Import one or more provider profiles from configuration items.

result, err := client.Providers().Profiles().Import(ctx, []v1.ProfileImportItem{
    {
        Profile: v1.ProviderProfile{
            DisplayName: "OpenAI",
            Category:    v1.ProfileCategoryInference,
        },
        Source: "manual",
    },
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Imported: %v\n", result.Imported)

Update

Update a profile using optimistic concurrency control via the resource version.

profile, err := client.Providers().Profiles().Get(ctx, "profile-id")
if err != nil {
    log.Fatal(err)
}

profile.DisplayName = "Updated Provider"
result, err := client.Providers().Profiles().Update(
    ctx,
    profile.ID,
    profile.ResourceVersion,
    v1.ProfileImportItem{Profile: *profile, Source: "manual"},
)
if err != nil {
    // See [Error Handling](../error-handling.md) for conflict errors
    log.Fatal(err)
}

Lint

Validate profile configurations without persisting them. Useful for pre-flight checks.

items := []v1.ProfileImportItem{
    {
        Profile: v1.ProviderProfile{DisplayName: "Test"},
        Source:  "manual",
    },
}
result, err := client.Providers().Profiles().Lint(ctx, items)
if err != nil {
    log.Fatal(err)
}
for _, d := range result.Diagnostics {
    fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message)
}

See also: Error Handling, Testing

Refresh

Accessor: client.Providers().Refresh()

Manage credential refresh schedules for provider profiles. Configure automatic rotation of API keys and monitor refresh status.

GetStatus

Check the refresh status for a specific provider credential.

statuses, err := client.Providers().Refresh().GetStatus(ctx, "openai", "default")
if err != nil {
    log.Fatal(err)
}
for _, s := range statuses {
    fmt.Printf("Key: %s, Last refresh: %s, Next: %s\n",
        s.CredentialKey, s.LastRefreshAt, s.NextRefreshAt)
}

Configure

Set up automatic credential refresh with a defined strategy and material.

status, err := client.Providers().Refresh().Configure(ctx, &v1.RefreshConfig{
    Provider:      "openai",
    CredentialKey:  "default",
    Strategy:       v1.RefreshStrategyOAuth2ClientCredentials,
    Material: map[string]string{
        "client_id":     "my-client-id",
        "client_secret": "my-client-secret",
        "token_url":     "https://oauth.example.com/token",
    },
    SecretMaterialKeys: []string{"client_secret"},
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Refresh configured, next rotation: %s\n", status.NextRefreshAt)

Rotate

Manually trigger an immediate credential rotation.

status, err := client.Providers().Refresh().Rotate(ctx, "openai", "default")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Rotated successfully at %s\n", status.LastRefreshAt)

See also: Error Handling, Profiles

Services

Accessor: client.Services()

Expose, inspect, and manage network services attached to sandboxes. Services provide external access to ports running inside a sandbox via managed endpoints.

Expose

Expose a port from a sandbox as a named service endpoint. Set domain to true to assign a DNS-routable domain name to the service.

endpoint, err := client.Services().Expose(ctx, "my-sandbox", "web", 8080, true)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Service available at: %s\n", endpoint.URL)

List

List all exposed services for a sandbox.

services, err := client.Services().List(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
for _, svc := range services {
    fmt.Printf("  %s -> port %d (%s)\n", svc.ServiceName, svc.TargetPort, svc.URL)
}

Delete

Remove an exposed service. The underlying sandbox port remains accessible internally but is no longer reachable through the service endpoint.

err := client.Services().Delete(ctx, "my-sandbox", "web")
if err != nil {
    log.Fatal(err)
}

See also: Error Handling, Testing

Files

Accessor: client.Files()

Upload and download files to and from sandboxes. Uses gRPC streaming for efficient transfer of large files.

Upload

Upload a file from the local filesystem to a path inside a sandbox. The file is streamed in chunks using client-side gRPC streaming.

err := client.Files().Upload(ctx, "sandbox-123", "./data/config.yaml", "/app/config.yaml")
if err != nil {
    log.Fatal(err)
}
fmt.Println("File uploaded successfully")

Download

Download a file from a sandbox to the local filesystem. The file is received in chunks using server-side gRPC streaming.

err := client.Files().Download(ctx, "sandbox-123", "/app/output.log", "./output.log")
if err != nil {
    log.Fatal(err)
}
fmt.Println("File downloaded successfully")

See also: Error Handling, Testing

Health

Accessor: client.Health()

Check the health status of the connected OpenShell gateway.

Check

Perform a health check against the gateway. Returns the overall status and component-level details.

result, err := client.Health().Check(ctx)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Gateway healthy: %v\n", result.Healthy)

This is useful for readiness probes or verifying connectivity before executing other operations.

// Quick connectivity check before starting work
if result, err := client.Health().Check(ctx); err != nil {
    log.Fatalf("Gateway unreachable: %v", err)
} else if !result.Healthy {
    log.Fatal("Gateway is not healthy")
}

See also: Error Handling

SSH

Accessor: client.SSH()

Create and manage SSH sessions for sandboxes. Supports direct SSH access and TCP tunneling through SSH connections.

CreateSession

Create a new SSH session for a sandbox. Returns connection details including host, port, and authentication credentials.

session, err := client.SSH().CreateSession(ctx, "sandbox-123")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("SSH via %s://%s:%d\n", session.GatewayScheme, session.GatewayHost, session.GatewayPort)

RevokeSession

Revoke an active SSH session, immediately terminating any connections using it.

revoked, err := client.SSH().RevokeSession(ctx, session.Token)
if err != nil {
    log.Fatal(err)
}
if revoked {
    fmt.Println("Session revoked")
}

Tunnel

Create an SSH tunnel that provides a bidirectional stream to a port inside a sandbox. This combines SSH session creation with TCP forwarding into a single operation, returning an io.ReadWriteCloser for the tunnel.

tunnel, err := client.SSH().Tunnel(ctx, "my-sandbox", 8080)
if err != nil {
    log.Fatal(err)
}
defer tunnel.Close()

// Use the tunnel as a regular io.ReadWriteCloser
_, err = tunnel.Write([]byte("GET / HTTP/1.0\r\n\r\n"))
if err != nil {
    log.Fatal(err)
}

See also: TCP Forwarding, Error Handling

TCP

Accessor: client.TCP()

Forward TCP connections to sandbox ports using bidirectional gRPC streaming.

Forward

Open a bidirectional TCP forwarding stream to a specific port inside a sandbox. Returns an io.ReadWriteCloser that proxies data between the caller and the sandbox port over gRPC streaming.

conn, err := client.TCP().Forward(ctx, "sandbox-123", 5432)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

// Example: proxy a PostgreSQL connection through the tunnel
// Write and read data as with any net.Conn-like stream
_, err = conn.Write([]byte("SELECT 1;\n"))
if err != nil {
    log.Fatal(err)
}

buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Response: %s\n", buf[:n])

TCP forwarding is lower-level than SSH tunneling. Use TCP forwarding when you need direct port access without SSH session overhead.

See also: SSH Tunneling, Error Handling

Config

Accessor: client.Config()

Retrieve and update configuration for sandboxes and the gateway.

GetSandbox

Retrieve the current configuration for a specific sandbox.

config, err := client.Config().GetSandbox(ctx, "sandbox-123")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Sandbox config: policy_version=%d, revision=%d\n",
    config.PolicyVersion, config.ConfigRevision)

GetGateway

Retrieve the gateway-level configuration.

config, err := client.Config().GetGateway(ctx)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Gateway settings revision: %d\n", config.SettingsRevision)

Update

Apply a configuration update. The update is validated before being applied.

result, err := client.Config().Update(ctx, &v1.ConfigUpdate{
    Name:       "sandbox-123",
    SettingKey:  "idle_timeout",
    SettingValue: &v1.SettingValue{
        Type:      v1.SettingValueString,
        StringVal: "30m",
    },
})
if err != nil {
    // See [Error Handling](../error-handling.md) for validation errors
    log.Fatal(err)
}
fmt.Printf("Config updated: revision=%d\n", result.SettingsRevision)

See also: Error Handling

Policy

Accessor: client.Policy()

Manage network policies for sandboxes through a draft-based workflow. Policies go through a draft, review, and approval cycle before being applied.

GetDraft

Retrieve the current draft policy for a sandbox.

draft, err := client.Policy().GetDraft(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
for _, chunk := range draft.Chunks {
    fmt.Printf("Chunk %s: rule=%s, status=%s, confidence=%.1f\n",
        chunk.ID, chunk.RuleName, chunk.Status, chunk.Confidence)
}

ApproveAllDraftChunks

Approve all pending chunks in a single operation.

result, err := client.Policy().ApproveAllDraftChunks(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Approved %d chunks (skipped %d), policy version: %d\n",
    result.ChunksApproved, result.ChunksSkipped, result.PolicyVersion)

GetStatus

Check the current policy enforcement status for a sandbox.

status, err := client.Policy().GetStatus(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Active version: %d, revision status: %s\n",
    status.ActiveVersion, status.Revision.Status)

List

List all policy revisions for a sandbox.

revisions, err := client.Policy().List(ctx, "my-sandbox")
if err != nil {
    log.Fatal(err)
}
for _, rev := range revisions {
    fmt.Printf("Version %d: %s (status: %s)\n",
        rev.Version, rev.CreatedAt, rev.Status)
}

RejectDraftChunk

Reject a specific draft chunk, providing a reason.

err := client.Policy().RejectDraftChunk(ctx, "my-sandbox", "chunk-abc", "Too permissive")
if err != nil {
    log.Fatal(err)
}

EditDraftChunk

Modify the proposed rule in a draft chunk before approval.

err := client.Policy().EditDraftChunk(ctx, "my-sandbox", "chunk-abc", &v1.NetworkPolicyRule{
    Name: "allow-api",
    Endpoints: []v1.PolicyNetworkEndpoint{{
        Host:     "api.example.com",
        Port:     443,
        Protocol: "tcp",
    }},
})
if err != nil {
    log.Fatal(err)
}

See also: Error Handling, Testing

Gateway

Package: openshell/v1/gateway

The gateway package reads on-disk gateway configurations created by the OpenShell Rust CLI and constructs fully wired SDK clients. It eliminates the boilerplate of locating config files, parsing metadata, loading tokens, and wiring auth providers.

Quick Start

import "github.com/rhuss/openshell-sdk-go/openshell/v1/gateway"

// Connect to a named gateway
client, err := gateway.NewClient("prod")
if err != nil {
    log.Fatal(err)
}
defer client.Close()

Functions

FunctionDescription
NewClient(name, opts...)Create a fully wired SDK client from gateway config
LoadConfig(name)Parse gateway config without connecting
ListGateways()Enumerate all available gateways

NewClient

func NewClient(name string, opts ...ClientOption) (*v1.Client, error)

Creates a fully configured SDK client for the named gateway. If name is empty, the active gateway (set via openshell gateway use) is used.

The function resolves the gateway directory, parses metadata.json, loads tokens lazily, maps the auth mode to an SDK auth provider, and applies any ClientOption values before delegating to v1.NewClient.

// Named gateway
client, err := gateway.NewClient("prod")

// Active gateway
client, err := gateway.NewClient("")

// With options
client, err := gateway.NewClient("staging",
    gateway.WithTimeout(10 * time.Second),
    gateway.WithLogger(myLogger),
)

Errors: ErrGatewayNotFound, ErrConfigParse, ErrTokenLoad, ErrUnsupportedAuthMode, ErrInvalidGatewayName, ErrNoActiveGateway

LoadConfig

func LoadConfig(name string) (*Config, error)

Parses gateway configuration without creating a client connection. Returns a frozen snapshot; changes to on-disk files after the call are not reflected.

cfg, err := gateway.LoadConfig("staging")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode)

ListGateways

func ListGateways() ([]Info, error)

Enumerates all available gateways from user and system directories. User gateways appear first. Duplicate names resolve to user precedence. Returns an empty slice (not an error) when no gateways are configured.

gateways, err := gateway.ListGateways()
for _, gw := range gateways {
    fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source)
}

Types

Config

type Config struct {
    Name     string       // Validated gateway name
    Endpoint string       // Host:port of the gateway
    AuthMode AuthMode     // Resolved auth mode
    Source   ConfigSource // User or System origin
    Dir      string       // Absolute path to gateway config directory
}

Info

type Info struct {
    Name   string       // Gateway name from directory listing
    Active bool         // Whether this is the active gateway
    Source ConfigSource // User or System origin
}

AuthMode

ValueConstantSDK AuthProvider
"" or "none"AuthModeNonev1.NoAuth()
"plaintext"AuthModePlaintextv1.NoAuth() + insecure TLS
"cloudflare_jwt"AuthModeCloudflareJWTLazy edge token auth
"oidc"AuthModeOIDCv1.RefreshableToken with disk source
"mtls"AuthModeMTLSUnsupported (use WithAuth)

ClientOption

ConstructorEffect
WithLogger(l)Set logger on the SDK client
WithTimeout(d)Set connection timeout
WithTLS(cfg)Override TLS settings from gateway config
WithAuth(provider)Override auto-resolved auth provider
WithRetryPolicy(p)Set retry policy

Error Handling

All errors support errors.Is for classification:

client, err := gateway.NewClient("my-gateway")
if errors.Is(err, gateway.ErrGatewayNotFound) {
    fmt.Println("Gateway not configured. Run: openshell gateway add my-gateway")
}
if errors.Is(err, gateway.ErrTokenLoad) {
    fmt.Println("Token expired or missing. Run: openshell gateway login my-gateway")
}
ErrorMeaning
ErrGatewayNotFoundNo gateway directory in user or system paths
ErrConfigParsemetadata.json missing or malformed
ErrTokenLoadToken file missing or unreadable
ErrUnsupportedAuthModeUnrecognized auth_mode value
ErrInvalidGatewayNameName fails validation
ErrNoActiveGatewayNo active gateway configured

On-Disk Layout

The package reads gateway metadata from these locations:

$XDG_CONFIG_HOME/openshell/          (user, default: ~/.config/openshell/)
├── active_gateway                   # Plain text: active gateway name
└── gateways/
    └── <name>/
        ├── metadata.json            # {"endpoint":"...","auth_mode":"...","name":"..."}
        ├── edge_token               # Cloudflare edge JWT (plaintext)
        ├── cf_token                 # Legacy edge token (fallback)
        └── oidc_token.json          # OIDC token bundle

/etc/openshell/gateways/             (system, fallback)

User gateways take precedence over system gateways with the same name.

Thread Safety

All exported functions (NewClient, LoadConfig, ListGateways) are safe for concurrent use from multiple goroutines. Token loading uses internal synchronization.

OIDC Login

Package: openshell/v1/oidc

The oidc package provides OIDC authentication for OpenShell gateways. It supports four OAuth2 flows: browser-based authorization code with PKCE, keyboard fallback, device code (RFC 8628), and client credentials.

Quick Start

import "github.com/rhuss/openshell-sdk-go/openshell/v1/oidc"

// Gateway-aware login (reads OIDC config from gateway metadata)
token, err := oidc.Login(ctx, "my-gateway")
if err != nil {
    log.Fatal(err)
}

Functions

FunctionDescription
Login(ctx, gatewayName, opts...)Interactive login via browser or keyboard flow
DeviceLogin(ctx, opts...)Device authorization grant (RFC 8628)
ClientCredentials(ctx, opts...)Non-interactive client credentials grant

Login

func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error)

Performs an interactive OIDC login. When gatewayName is provided, the OIDC issuer and client ID are read from the gateway’s metadata.json. The default flow opens a browser for authorization code exchange with PKCE. If the provider does not support PKCE (S256), the flow proceeds without it.

Tokens are persisted to the gateway’s config directory as oidc_token.json. Subsequent calls reuse a valid cached token.

For standalone use (no gateway), pass an empty gatewayName with WithIssuer and WithClientID. Combine with WithInMemory to skip disk persistence.

DeviceLogin

func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error)

Performs an OAuth2 device authorization grant (RFC 8628). The flow requests a device code and user code from the provider, displays them via WithDisplayFunc (or stdout), and polls the token endpoint until the user completes authorization.

Requires WithIssuer and WithClientID, or WithGateway.

ClientCredentials

func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error)

Performs a non-interactive OAuth2 client credentials grant. Requires WithIssuer, WithClientID, and WithClientSecret (or WithGateway combined with WithClientSecret). The client secret is never included in error messages.

Options

OptionDescription
WithIssuer(url)OIDC provider issuer URL
WithClientID(id)OAuth2 client ID
WithClientSecret(secret)OAuth2 client secret (for client credentials)
WithScopes(scopes...)Custom scopes (default: openid, profile, email)
WithCallbackPort(port)Fixed port for localhost callback (default: tries 8000, then 18000)
WithTimeout(d)Auth flow timeout (default: 2 minutes)
WithKeyboardFlow()Use keyboard flow instead of browser
WithInMemory()Skip token persistence to disk
WithDisplayFunc(fn)Custom display for device code flow
WithGateway(name)Resolve OIDC config from gateway metadata

Error Handling

The package defines sentinel errors for errors.Is() matching:

ErrorDescription
ErrDiscoveryOIDC discovery document fetch failed
ErrAuthCodeAuthorization code exchange failed
ErrDeviceCodeDevice code flow failed
ErrClientCredentialsClient credentials exchange failed
ErrTimeoutAuthentication flow timed out
ErrCallbackServerLocalhost callback server failed
ErrTokenPersistToken read/write failed
ErrOIDCConfigMissing or invalid OIDC configuration
token, err := oidc.Login(ctx, "my-gateway")
if errors.Is(err, oidc.ErrDiscovery) {
    // Provider unreachable
}
if errors.Is(err, oidc.ErrTimeout) {
    // User did not complete login in time
}

Gateway Integration

When a gateway’s metadata.json contains oidc_issuer and oidc_client_id fields, Login and DeviceLogin can resolve configuration automatically:

// Login reads OIDC config from the gateway
token, err := oidc.Login(ctx, "my-gateway")

// Then use the token with the SDK client
client, err := v1.NewClient(v1.Config{
    Address: "gateway.example.com:443",
    Auth:    v1.StaticToken(token.AccessToken),
})

Flows

Browser Flow (default)

  1. Discovers OIDC endpoints from issuer
  2. Generates PKCE code verifier and S256 challenge
  3. Opens browser to authorization URL
  4. Starts localhost callback server to receive the auth code
  5. Exchanges code for tokens with PKCE verification
  6. Persists tokens to disk

Keyboard Flow

Same as browser flow, but prints the authorization URL for the user to copy manually. The user pastes the authorization code back into the terminal. Use WithKeyboardFlow() to enable.

Device Code Flow (RFC 8628)

  1. Requests a device code and user code from the provider
  2. Displays the verification URL and user code
  3. Polls the token endpoint at the provider’s requested interval
  4. Handles slow_down responses by increasing the poll interval

Client Credentials Flow

  1. Discovers OIDC endpoints from issuer
  2. Exchanges client ID and secret for an access token
  3. No user interaction required

Error Handling

All SDK operations return errors as *v1.StatusError, a typed error that carries a machine-readable Code and a human-readable Message. The SDK provides predicate functions to classify errors without importing internal packages.

StatusError

Every error returned by the SDK is a *StatusError (or wraps one). You can inspect it directly or use the convenience predicates below.

var se *v1.StatusError
if errors.As(err, &se) {
    fmt.Printf("code: %s, message: %s\n", se.Code, se.Message)
    // se.Details contains optional structured metadata
}
FieldTypeDescription
CodeErrorCodeMachine-readable error classification
MessagestringHuman-readable error description
Detailsmap[string]stringOptional structured metadata

Predicate Functions

Use these top-level functions to check error types. They work with wrapped errors via errors.As.

FunctionErrorCodeWhen it fires
v1.IsNotFoundErrorNotFoundResource does not exist (sandbox, provider, etc.)
v1.IsAlreadyExistsErrorAlreadyExistsResource with that name already exists
v1.IsConflictErrorConflictOptimistic concurrency violation or invalid state transition
v1.IsUnavailableErrorUnavailableGateway is unreachable or client is closed
v1.IsUnimplementedErrorUnimplementedOperation not supported by the gateway version
v1.IsPermissionDeniedErrorPermissionDeniedInsufficient permissions
v1.IsInvalidArgumentErrorInvalidArgumentInvalid request parameters
v1.IsDeadlineExceededErrorDeadlineExceededOperation timed out
v1.IsCancelledErrorCancelledOperation was cancelled (context cancellation)

For ErrorInternal (server-side errors), no convenience predicate exists. Match it directly via the Code field:

var se *v1.StatusError
if errors.As(err, &se) && se.Code == v1.ErrorInternal {
    fmt.Println("Internal server error:", se.Message)
}

Common Patterns

Not Found

Handle missing resources gracefully:

sb, err := client.Sandboxes().Get(ctx, "my-sandbox")
if v1.IsNotFound(err) {
    fmt.Println("Sandbox does not exist, creating...")
    sb, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil)
}
if err != nil {
    log.Fatal(err)
}

Already Exists

Guard against duplicate creation:

_, err := client.Providers().Create(ctx, &v1.Provider{
    Name: "openai",
    Type: "openai",
})
if v1.IsAlreadyExists(err) {
    fmt.Println("Provider already registered, skipping")
} else if err != nil {
    log.Fatal(err)
}

Alternatively, use Ensure for idempotent registration:

// Create-or-update in a single call
provider, err := client.Providers().Ensure(ctx, &v1.Provider{
    Name: "openai",
    Type: "openai",
})

Conflict (Optimistic Concurrency)

When two clients modify the same resource concurrently, the second write receives a conflict error. Retry by re-reading the resource:

sb, _ := client.Sandboxes().Get(ctx, "my-sandbox")

result, err := client.Sandboxes().AttachProvider(ctx,
    "my-sandbox", "openai", sb.ResourceVersion,
)
if v1.IsConflict(err) {
    // Another client modified the sandbox — re-read and retry
    sb, _ = client.Sandboxes().Get(ctx, "my-sandbox")
    result, err = client.Sandboxes().AttachProvider(ctx,
        "my-sandbox", "openai", sb.ResourceVersion,
    )
}
if err != nil {
    log.Fatal(err)
}

Unavailable

Handle gateway connectivity issues:

sb, err := client.Sandboxes().Get(ctx, "my-sandbox")
if v1.IsUnavailable(err) {
    fmt.Println("Gateway is not reachable, check connection")
    // Implement retry with backoff
}

Unimplemented

Detect unsupported operations gracefully:

_, err := client.Sandboxes().GetLogs(ctx, "my-sandbox")
if v1.IsUnimplemented(err) {
    fmt.Println("Log retrieval not supported by this gateway version")
}

Retry with Backoff

For transient errors like Unavailable or DeadlineExceeded, use exponential backoff:

func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    backoff := 100 * time.Millisecond

    for attempt := 0; attempt < maxAttempts; attempt++ {
        err := fn()
        if err == nil {
            return nil
        }

        // Only retry transient errors
        if !v1.IsUnavailable(err) && !v1.IsDeadlineExceeded(err) {
            return err
        }

        // Add jitter to prevent thundering herd after outages
        jitter := time.Duration(rand.Int63n(int64(backoff) / 2))

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff + jitter):
            backoff *= 2
        }
    }
    return fmt.Errorf("exhausted %d retry attempts", maxAttempts)
}

Usage:

var sb *v1.Sandbox
err := withRetry(ctx, 3, func() error {
    var e error
    sb, e = client.Sandboxes().Get(ctx, "my-sandbox")
    return e
})

Context Cancellation

All SDK methods accept a context.Context. Use context deadlines and cancellation to control timeouts:

// 5-second timeout for a single call
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

sb, err := client.Sandboxes().Get(ctx, "my-sandbox")
if v1.IsDeadlineExceeded(err) {
    fmt.Println("Request timed out")
}

See also: Testing for how the fake client returns the same error codes.

Testing

The SDK ships a fake package that provides an in-memory implementation of all client interfaces. Use it in your test suites to exercise SDK interactions without a real gateway.

import "github.com/rhuss/openshell-sdk-go/openshell/v1/fake"

The fake client follows the same pattern as k8s.io/client-go/kubernetes/fake: it maintains in-memory stores, supports watch event broadcasting, and returns the same StatusError codes as the real client.

Creating a Fake Client

func TestMyOperator(t *testing.T) {
    client := fake.NewClient()
    defer client.Close()

    ctx := context.Background()

    // Use client exactly like the real SDK
    sb, err := client.Sandboxes().Create(ctx, "test-sandbox", &v1.SandboxSpec{}, nil)
    require.NoError(t, err)
    assert.Equal(t, "Provisioning", string(sb.Status.Phase))
}

The returned *fake.Client satisfies v1.ClientInterface, so you can pass it anywhere your code accepts the interface.

Fixture Seeding

Pre-populate the fake client with existing resources before your test runs. Seeded resources are available immediately via Get and List without going through Create.

AddSandbox

client := fake.NewClient()

// Pre-seed a sandbox that already exists
client.AddSandbox(&types.Sandbox{
    Name: "existing-sandbox",
    Status: types.SandboxStatus{
        Phase: types.SandboxReady,
    },
    ResourceVersion: 5,
})

// Now Get returns it immediately
sb, err := client.Sandboxes().Get(ctx, "existing-sandbox")
// sb.Status.Phase == "Ready"

AddProvider

client := fake.NewClient()

// Pre-seed a provider
client.AddProvider(&types.Provider{
    Name: "my-openai",
    Type: "openai",
    Spec: types.ProviderSpec{
        Credentials: map[string]string{
            "api_key": "sk-test-key",
        },
    },
})

// List returns the seeded provider
providers, _ := client.Providers().List(ctx)
// len(providers) == 1

Sandbox Lifecycle

The fake client implements the full sandbox lifecycle. Created sandboxes start in the Provisioning phase. Calling WaitReady transitions them to Ready synchronously.

client := fake.NewClient()
ctx := context.Background()

// Create starts in Provisioning
sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil)
assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase)

// WaitReady transitions to Ready (synchronous in fake)
sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox")
assert.Equal(t, types.SandboxReady, sb.Status.Phase)

// Delete removes the sandbox
err = client.Sandboxes().Delete(ctx, "my-sandbox")
assert.NoError(t, err)

// Get after delete returns NotFound
_, err = client.Sandboxes().Get(ctx, "my-sandbox")
assert.True(t, v1.IsNotFound(err))

Watch Events

The fake client broadcasts watch events when resources change. Use watchers to test event-driven code.

client := fake.NewClient()
ctx := context.Background()

// Start watching before making changes
watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox")
require.NoError(t, err)
defer watcher.Stop()

// Create a sandbox — triggers an ADDED event
client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil)

// Read the event from the channel
event := <-watcher.ResultChan()
assert.Equal(t, types.EventAdded, event.Type)
assert.Equal(t, "my-sandbox", event.Object.Name)

StopOnTerminal

Setting StopOnTerminal: true causes the watcher to close automatically when the sandbox reaches a terminal phase (Ready or Error).

watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox", v1.WatchOptions{
    StopOnTerminal: true,
})
require.NoError(t, err)

// Create and transition to Ready
client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil)
client.Sandboxes().WaitReady(ctx, "my-sandbox")

// Drain events — channel closes after the Ready event
var events []types.Event[*types.Sandbox]
for ev := range watcher.ResultChan() {
    events = append(events, ev)
}
// Channel is now closed

Health Simulation

Use WithHealthResult to simulate an unhealthy or degraded gateway.

// Default: healthy gateway
client := fake.NewClient()
result, _ := client.Health().Check(ctx)
// result.Healthy == true, result.Version == "fake"

// Simulate unhealthy gateway
client = fake.NewClient(fake.WithHealthResult(&types.HealthResult{
    Healthy: false,
    Version: "1.2.3",
}))
result, _ = client.Health().Check(ctx)
// result.Healthy == false

Error Behavior

The fake client returns the same StatusError codes as the real client:

ScenarioError Code
Get for a non-existent resourceErrorNotFound
Create with a duplicate nameErrorAlreadyExists
Any call after Close()ErrorUnavailable
Unimplemented operations (e.g., GetLogs)ErrorUnimplemented
client := fake.NewClient()
client.Close()

_, err := client.Sandboxes().Get(ctx, "anything")
assert.True(t, v1.IsUnavailable(err))

Concurrency

All fake client operations are safe for concurrent use. The internal stores use mutex-based synchronization. This means you can safely use the fake client from multiple goroutines in parallel tests.

See also: Error Handling, API Reference