Files
OpenShell/crates/openshell-sdk/README.md
Max Dubrinsky e6f319c76d feat(sdk): add openshell-sdk crate (#1862)
* feat(sdk): add openshell-sdk crate

Additive extraction of the shared async gRPC client core (transport, TLS,
OIDC single-flight refresh, edge tunnel, high-level sandbox surface, raw
escape hatch) as a new workspace crate. No existing consumers yet; CLI/TUI
migration follows in a separate PR.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* refactor(sdk,core): reuse shared JWT exp decoder for refresh deadlines

Extract the signature-unverified JWT exp decode out of
openshell-core/grpc_client.rs into openshell_core::jwt::parse_exp_secs,
and have the openshell-sdk refresh path reuse it to derive a proactive
refresh deadline from a bearer JWT when the caller does not advertise
expires_at. Addresses review feedback to reuse pre-existing logic rather
than reimplement JWT expiry handling per client.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* fix(sdk): harden refresh single-flight and redact tokens in Debug

Addresses review feedback on the openshell-sdk refresh path.

- Make the single-flight cleanup cancellation-safe. The in-flight slot is
  now cleared by the shared refresh computation itself (epoch-guarded)
  rather than the leader's post-await code. Previously, if the leader future
  was dropped (e.g. an FFI caller cancelling its promise) after a follower
  drove the refresh to completion, the completed future was stranded in the
  slot and later refresh_now() calls re-joined it, pinning the client to a
  stale or already-rejected token. Adds a regression test that cancels the
  leader and asserts the next refresh starts a fresh attempt.

- Redact bearer secrets from Debug. RefreshedToken and the oidc
  RefreshTokenInput/RefreshTokenOutput now use manual Debug impls that omit
  the access/refresh token fields via finish_non_exhaustive, matching the
  house style (e.g. SecretResolver, SandboxJwtIssuer). Prevents a stray
  {:?} or a containing struct's derived Debug from writing tokens to logs.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* fix(sdk): fail refresh when the new token can't be encoded as metadata

store_bearer now returns an error instead of silently keeping the previous
bearer value. The TokenSource commits the refreshed token to its state
before the client writes it into the interceptor slot, so a silent drop
left the interceptor on the old (expiring) token with no path back to a
refresh. Surfacing the error fails the call loudly instead. Adds a unit
test covering a token that can't be encoded as gRPC metadata.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* fix(sdk): refresh OIDC tokens on raw routes and harden rotation

Raw gRPC access never triggered OIDC refresh: a client that only used
raw_grpc/raw_inference kept sending the initial bearer until it expired,
with no proactive or reactive refresh. Add raw_grpc_fresh and
raw_inference_fresh accessors that refresh before returning the client,
plus force_refresh for reactive recovery after an Unauthenticated raw
RPC.

Guard the single-flight refresh commit against a concurrent replace().
The in-flight attempt now records the generation it started from and
skips its write when an external replace() has advanced it, so timer or
callback driven rotation is no longer clobbered by a slower refresh.

Remove TokenSource::snapshot(): it returned an empty string under write
contention and had no consumer on the CLI/TUI path. Tests read committed
state directly instead.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* docs(sdk): rewrite crate README for consumers

Recast the openshell-sdk README as a usable crate README rather than an
RFC excerpt. Drop the Responsibilities/Non-responsibilities/Consumers
scope-boundary sections and the mTLS migration rationale, folding the
useful facts (explicit token, no disk/name resolution, Refresh trait,
SdkError mapping) into the intro, a new Auth and refresh section, and
Public surface. Remove the dead relative RFC link and status-label
prose so the doc renders cleanly wherever it is published.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* fix(sdk): address review feedback on OIDC refresh and transport

Apply Drew's review notes on PR #1862:

- Drop unused `rustls-pemfile` dependency and move `tokio-stream` to
  dev-dependencies (only used by tests).
- Guard OIDC `expires_at` against u64 overflow with `saturating_add`.
- Fix stale `#[non_exhaustive]` rationale in `AuthConfig` (the struct
  `Oidc` variant it described as future already ships).
- Stop double-wrapping refresh errors: store the bare refresh-error text
  so the single `SdkError::auth` wrap happens once at await.
- Strip stale CLI porting breadcrumbs from `build_channel` docs, keeping
  the branch table.
- Treat proactive token refresh as best-effort: a transient failure falls
  through to the request instead of failing an RPC whose current token is
  still valid, with a regression test.
- Collapse `exec`'s inline auth retry into the shared `unary` helper.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

* fix(sdk): preserve transient/terminal distinction in refresh errors

The refresh single-flight collapsed both `RefreshError::Transient` and
`RefreshError::Terminal` into a stringified `SdkError::Auth`, so consumers
(CLI, TUI, future language bindings) had no machine-readable way to tell a
retryable IdP blip from a dead session that needs re-authentication.

Carry the `RefreshError` through the shared outcome (kept `Clone` for
`Shared`) instead of its rendered text, and map it at the await site to a
new `retryable` flag on `SdkError::Auth`. Add `SdkError::auth_retryable`
and a `SdkError::retryable()` accessor; transient refresh failures report
`true`, every other error `false`. Add classification tests.

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>

---------

Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
2026-07-14 22:35:46 -07:00

2.8 KiB

openshell-sdk

openshell-sdk is the shared async Rust client for OpenShell gateways. It owns gRPC channel setup, TLS, OIDC refresh, and the Cloudflare Access tunnel so the CLI, the TUI, and language bindings share one client implementation. Callers pass an explicit bearer token; the SDK does no filesystem access and no gateway-name resolution.

Two layers

  • OpenShellClient — the curated, sandbox-focused surface: health, sandbox CRUD, readiness/deletion waits, and non-streaming exec.
  • raw — direct access to the generated tonic clients for RPCs the curated surface doesn't yet cover (inference, providers, policy, logs, settings, SSH, forwarding).

Auth and refresh

The curated surface drives OIDC refresh automatically: proactively before a request and reactively on Unauthenticated. Refreshes are single-flight, so only one is in flight at a time.

The plain raw_grpc/raw_inference accessors do not refresh; they return a client bound to the current token. When a refresher is wired, use raw_grpc_fresh/raw_inference_fresh to refresh before the call, and force_refresh to recover after a raw RPC returns Unauthenticated.

The SDK consumes a Refresh trait that the caller implements; it does not run the OIDC browser flow itself.

Transport modes

  • Plaintext (local development)
  • Server-authenticated TLS (system roots, or a pinned private CA via ca_cert)
  • OIDC bearer over HTTPS (gateways behind an OAuth2/OIDC IdP)
  • Cloudflare Access tunnel (hosted gateways)
  • Insecure TLS (development/debug; certificate verification disabled)

mTLS (client certificates) is not supported.

Public surface

OpenShellClient::connect(ClientConfig) returns a connected client exposing health, create_sandbox, get_sandbox, list_sandboxes, delete_sandbox, wait_ready, wait_deleted, and exec. Curated types (SandboxSpec, SandboxRef, Health, ListOptions, ExecOptions, SandboxPhase) use SDK-shaped enums rather than raw proto integers. Failures map to a typed SdkError with a discriminable kind.

Modules

Module Purpose
client High-level OpenShellClient and the curated sandbox surface.
config ClientConfig, AuthConfig.
transport Channel construction, TLS resolution, request interceptors.
auth EdgeAuthInterceptor for bearer-token attachment.
oidc OIDC token handling at the transport layer.
refresh Refresh trait and single-flight refresh coalescing.
edge_tunnel Cloudflare Access tunnel dialer.
error SdkError taxonomy.
types Curated request/response types and proto conversions.
raw Escape hatch re-exporting the generated tonic clients.

Notes

  • Async-only. Tonic is async-native; callers needing a blocking call can wrap with their own runtime.
  • The curated surface will grow as more RPCs graduate from raw.