From bb5f5c6356c03b138497c69af240d229f9c7aa24 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 28 May 2026 10:59:16 -0400 Subject: [PATCH 1/2] docs(rfc): add RFC 0008 for shared SDK core and TypeScript binding Captures the design behind extracting the shared client core out of openshell-cli into a standalone openshell-sdk crate, plus the napi-rs TypeScript binding (openshell-sdk-node, published as @openshell/sdk). Covers motivation (CLI/TUI/embedders sharing one transport, OIDC, and edge-tunnel implementation), surface area, error model, and the path for future language bindings. Signed-off-by: Max Dubrinsky --- .../README.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 rfc/0008-shared-sdk-core-and-ts-binding/README.md diff --git a/rfc/0008-shared-sdk-core-and-ts-binding/README.md b/rfc/0008-shared-sdk-core-and-ts-binding/README.md new file mode 100644 index 0000000000..a0f104ecb3 --- /dev/null +++ b/rfc/0008-shared-sdk-core-and-ts-binding/README.md @@ -0,0 +1,179 @@ +--- +authors: + - "@mdubrinsky" +state: review +links: + - https://linear.app/nvidia/issue/OSGH-110/python-and-typescript-sdk-support +--- + +# RFC 0008 - Shared Rust SDK core and TypeScript binding + +## Summary + +Extract a new `openshell-sdk` Rust crate from gRPC client plumbing that today lives in `openshell-cli`, and ship a TypeScript SDK (`@openshell/sdk`) as a [napi-rs](https://napi.rs) wrapper over that crate. Refactor `openshell-cli` to consume `openshell-sdk` so the CLI and the TS SDK share a single transport, auth, and error implementation. The pure-Python SDK at `python/openshell/` stays as-is for this RFC; migrating it onto the shared core is deferred to a follow-up. + +## Motivation + +OpenShell currently has one programmable surface (Python) and a CLI. The Python SDK is a hand-written gRPC client in `python/openshell/sandbox.py` that duplicates concerns already implemented in Rust (`openshell-cli/src/tls.rs` and `openshell-cli/src/oidc_auth.rs`): + +- TLS material loading, mTLS channel setup +- Edge-auth bearer token attachment +- OIDC token refresh +- Plaintext vs TLS transport selection + +Adding a TypeScript SDK by hand-writing a third gRPC client would extend the duplication. Three reasons to share a Rust core instead: + +1. **Multi-language support without re-implementing the transport per language.** We expect TS/Node users (TS-authored agents, web tooling). A shared transport layer keeps retry, auth refresh, and streaming consistent across bindings. +2. **The Rust transport already runs in production.** The CLI exercises every auth mode today. +3. **Establishes the pattern for other-language bindings.** If this works for TS, the same crate can later back a PyO3 binding and replace the pure-Python SDK. + +## What exists today + +- **Python SDK.** `python/openshell/sandbox.py`, hand-written gRPC against the existing protos. +- **CLI transport stack.** Full set of transport/auth modes implemented in `openshell-cli/src/tls.rs`, `openshell-cli/src/oidc_auth.rs`, and `openshell-cli/src/edge_tunnel.rs`. Runs in production today. + +## Non-goals + +- **Replacing the pure-Python SDK.** That migration is a separate, larger decision (API parity, deprecation window, packaging). This RFC keeps Python on its current pure-Python stack and only ensures the shared core is shaped so a future PyO3 wrapper is feasible. +- **gRPC contract changes.** The SDK is a client of the existing `proto/openshell.proto`, `proto/sandbox.proto`, `proto/inference.proto`. No service or message changes. +- **Browser / WebAssembly support.** napi-rs targets Node only. A browser SDK is a separate future RFC. +- **Bundling the `openshell` CLI binary inside the npm package.** Unlike the Python wheel (which uses maturin's `bindings = "bin"` to bundle the CLI), the TS SDK is gRPC-only. CLI installation stays a separate concern. +- **Streaming `exec` in the initial slice.** Tracked separately. + +## Proposal + +### New and changed crates + +``` +crates/ + openshell-sdk/ NEW. Pure Rust async client library. No FFI, no CLI deps. + openshell-sdk-node/ NEW. napi-rs wrapper over openshell-sdk. Ships as @openshell/sdk. + openshell-cli/ REFACTORED. Channel/auth code moves out; CLI consumes openshell-sdk. + openshell-core/ UNCHANGED. Still owns proto codegen; openshell-sdk depends on it. +``` + +### `openshell-sdk` surface + +```rust +pub struct ClientConfig { + pub gateway: String, // "https://..." or "http://..." + pub tls: Option, // required for mTLS, ignored for plaintext + pub auth: Option, // bearer token or OIDC refresh closure + pub timeout: Option, // default: None (no client-side timeout) +} + +pub enum AuthConfig { + Bearer(String), + Oidc { token: String, refresh: Arc }, +} + +pub struct OpenShellClient { /* tonic Channel + interceptor */ } + +impl OpenShellClient { + pub async fn connect(config: ClientConfig) -> Result; + + pub async fn health(&self) -> Result; + pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result; + pub async fn get_sandbox(&self, name: &str) -> Result; + pub async fn list_sandboxes(&self, opts: ListOptions) -> Result, SdkError>; + pub async fn delete_sandbox(&self, name: &str) -> Result; + pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result; + pub async fn wait_deleted(&self, name: &str, timeout: Duration) -> Result<(), SdkError>; + pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result; +} +``` + +### `openshell-sdk-node` surface + +A thin napi-rs wrapper exposing the same surface as JS classes / objects. Idiomatic camelCase (`createSandbox`, `waitReady`) is generated automatically from snake_case Rust by napi-derive. + +### CLI refactor + +Transport mechanics move out of `openshell-cli` and into `openshell-sdk`: gRPC channel construction, TLS material handling, request interceptors, and the Cloudflare Access tunnel. The CLI keeps everything user-facing — gateway-name resolution, default-path lookups, and the OIDC browser flow. The SDK never sees a browser; it consumes a `Refresh` trait that the CLI implements. + +### Transport and auth modes + +MVP must support the same five transport/auth modes the CLI exercises today, so a CLI user can move to the SDK without losing connectivity options: + +- Plaintext (local development) +- mTLS (self-deployed gateways with client certs) +- OIDC bearer over HTTPS (gateways behind an OAuth2/OIDC IdP) +- Cloudflare Access tunnel (hosted gateways) +- Insecure TLS (development/debug; certificate verification disabled) + +### Current leanings + +| Decision | Choice | Rationale | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Binding tool for TS** | napi-rs v3 | Required for `ThreadsafeFunction>` + `call_async`. Considered: UniFFI (no stable TS target), Diplomat (smaller community, JS support nascent), wit-bindgen (Node packaging not yet ergonomic). | +| **Tokio runtime ownership at FFI boundary** | napi's ambient tokio runtime is available only inside `async fn` entry points. Every user-facing napi function that needs the runtime must be `async`. No `Handle` plumbing in `ClientConfig`. | Sync `#[napi] fn` runs on the JS thread with no reactor; `tokio::spawn` from sync napi context panics with "no reactor running". | +| **API shape** | Async-only, no blocking facade | Tonic is async-native; a blocking facade would require `block_on` plumbing and confuse the JS Promise contract. Callers needing sync can wrap with `tokio::runtime::Runtime::block_on` themselves. | +| **Error type** | `thiserror` enum in `openshell-sdk`, mapped to napi `Error` with a `code` field for TS discriminated-union ergonomics | Better than the Python SDK's single `SandboxError(RuntimeError)`. Lets TS consumers `switch` on error kind. | +| **Retry policy** | Per-call configurable; default = no retry | Matches the Python SDK. Advanced users opt in. | +| **OIDC refresh trait** | SDK accepts a `Refresh` trait with a domain error type, not `napi::Error`. CLI provides the browser-flow impl. Node binding wraps a JS callback as a `ThreadsafeFunction<(), Promise>`. | Keeps `openshell-sdk` napi-free. | +| **Single-flight refresh coalescing** | Lives in `openshell-sdk` core, not in the binding. | napi does not provide it; standard OIDC pattern needs it (one refresh in flight, all waiters share the result). | +| **OIDC refresh cancellation** | Rust-side future drop does not propagate to JS. In-flight JS refresher promise runs to completion; SDK ignores late-arriving values. | Trait does not need cooperative cancellation. | +| **Streaming pattern** | Iterator-style: a napi class with an async `next()` method, drop-based cancellation, and a thin TS shim layering `for await` on top. Not native AsyncGenerator. | napi-rs v3.8 has no native AsyncGenerator return type. | +| **Auth token file loading** | NOT in `openshell-sdk` directly. Callers pass an explicit token. A separate convenience helper in `openshell-cli` (or a thin helper crate) handles the `~/.config/openshell/gateways//edge_token` lookup. | Keeps `openshell-sdk` free of filesystem access. Usable as a library without CLI assumptions. | +| **SDK layering scope (MVP)** | Sandbox-focused. High-level methods cover health, sandbox CRUD, waits, and non-streaming exec. A `raw` module re-exports generated tonic clients as an escape hatch. Inference, providers, policy, logs, settings, SSH, forwarding, and completions are out of MVP and deferred. | The `raw` escape hatch lets callers reach RPCs the high-level surface doesn't yet cover. | +| **TypeScript API model** | Curated SDK types, not raw proto shapes. Enum-valued fields use string literals (e.g., `"Pending"`), not numeric proto enums. Captured high-level types: `SandboxSpec`, `SandboxRef`, `Health`, `ListOptions`, `ExecOptions`. | TS DX is better with discriminated string unions than with numeric proto enums. | + +## Implementation plan + +Phases ordered by dependency. No time estimates. This RFC establishes direction; detailed contracts (the `Refresh` trait shape, error codes, exec semantics) settle at implementation time. + +### Phase 1 — Refactor and extract `openshell-sdk` + +- Create `crates/openshell-sdk/` with transport, auth, error, and edge-tunnel modules. +- Execute the CLI refactor described above. +- Exit criteria: all existing `mise run test` and `mise run e2e` paths pass. No new SDK consumers yet. + +### Phase 2 — High-level SDK methods + +- Implement `health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, `wait_ready`, `wait_deleted`, non-streaming `exec`. +- Unit tests with a mock tonic server. +- Settle the `Refresh` trait contract: single-flight semantics, proactive vs reactive trigger, deadline, retry-after-refresh-failure, terminal-failure signalling. + +### Phase 3 — `openshell-sdk-node` napi binding + +- Build the JS-facing client surface over `openshell-sdk`. +- Wire the OIDC refresh callback path between Rust and JS. +- Map SDK errors to JS errors with a discriminable `code` field. +- Resolve the tunnel-vs-refresh interaction with one targeted test (does the CF tunnel re-handshake on bearer rotation, swap headers in place, or tear down and rebuild?). +- Smoke test against a plaintext local gateway. + +## Migration and compatibility + +- **CLI surface preserved.** The phase 1 refactor does not change `openshell-cli` flags, behavior, or output. Existing scripts continue to work. +- **gRPC contract unchanged** (see Non-goals). +- **Python SDK frozen.** The pure-Python SDK is unaffected by this RFC. +- **Alpha contract.** `@openshell/sdk` ships under `0.0.0-alpha.x` until the surface stabilizes; no semver guarantee before 1.0. + +## Risks + +- **CLI regression during phase 1.** Mitigation: extraction PR ships first with no SDK consumers, with the existing CLI tests as the regression surface. +- **napi-rs prebuilt binary CI complexity.** Six-target build matrices break in interesting ways (musl static linking, macOS codesigning, cross-compilation for aarch64). The v3 toolchain has only been exercised on darwin-arm64 so far; the full cross-platform matrix is unproven. Mitigation: lean on napi-rs's published workflow template; treat the first publish as the trigger for completing the build matrix. +- **Python/TS SDK behavior drift.** While Python stays pure-Python over gRPC, behavior (timeouts, retry, error mapping) may drift from the Rust SDK. Mitigation: keep the Python SDK frozen during this RFC's implementation; track parity as a precondition for a future Python-on-shared-core RFC. +- **Refresh contract details.** The FFI mechanism is settled. Still unspecified: proactive vs reactive trigger, deadline, retry-after-refresh-failure, terminal-failure signalling. Mitigation: design alongside phase 2. +- **Tunnel-vs-refresh interaction.** The CF tunnel captures bearer headers at connection time; bearer rotation mid-session is not yet specified. Mitigation: settle in phase 3 with one targeted test before npm alpha publish. + +## Alternatives + +- **Pure-TS gRPC client (e.g., `@connectrpc/connect`, `ts-proto`).** Cheaper and faster initially, no shared runtime. Loses all the shared-core benefits (auth refresh, retry, error taxonomy) and locks us into duplicating logic per language. Reasonable if the project decided the shared-core direction is overkill; this RFC argues it's not. +- **TS calls Python via subprocess or IPC.** Rejected — terrible DX, forces a Python runtime on Node consumers. +- **UniFFI for both Python and TS.** UniFFI's TS target is not yet stable. Re-evaluate once it lands. +- **Diplomat (Rust → JS/Dart/Kotlin).** Smaller community, JS support less proven. +- **`wit-bindgen` + WebAssembly component model.** The likely long-term target once Node packaging of wasm components matures. +- **Do nothing; tell TS users to use the gRPC stubs directly.** Possible, but leaves every TS consumer to roll their own wrapper. + +## Prior art + +- **Polars** — Rust core, PyO3 for Python, napi-rs for Node. Same pattern. +- **swc** and **Turbopack** — large napi-rs projects in the JS tooling ecosystem, demonstrate the publishing/CI patterns. +- **Bitwarden SDK** — Rust core with UniFFI bindings; useful reference for Refresh-trait-style auth design even though we're not using UniFFI. +- **1Password Connect SDK** — multi-language SDK over a shared gRPC contract, same design choice in a different domain. + +## Open questions + +- **Retry policy shape.** Builder on `ClientConfig` (declarative) or `tower::Layer` (composable)? Composable is more flexible; declarative is friendlier for napi/PyO3 consumers who can't construct a `Layer`. +- **Should `OpenShellClient::from_gateway_name(name)` exist in `openshell-sdk` at all,** or only in a CLI-config helper crate? Tradeoff between ergonomics and keeping `openshell-sdk` filesystem-free. From a3ae942e6d72243f297be62ca974011ae5e70786 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 13 Jul 2026 18:03:15 -0400 Subject: [PATCH 2/2] docs(rfc): reframe RFC 0008 around per-language SDK bindings Recast the SDK direction from a shared FFI core to native per-language clients over the shared proto contract, with the goal stated up front: an idiomatic, performant client in each language, mechanism immaterial. - Add a Principles section so the doc grounds future language SDKs. - Introduce a per-language binding strategy: native codegen by default (TypeScript, Go), FFI over openshell-sdk where the ecosystem already ships compiled extensions (Python via PyO3), with decision criteria. - Generalize the contract every SDK delivers (idiomatic surface, transport modes, single-flight refresh, curated types, error taxonomy) and clarify that FFI removes the need to rebuild core plumbing, not the need to hand-write a good surface. - Keep openshell-sdk as the shared Rust core for the CLI and TUI and the substrate FFI bindings wrap; pin refresh across native bindings with a conformance suite. - Move the shared-FFI-core-everywhere approach to Alternatives. Signed-off-by: Max Dubrinsky --- .../README.md | 191 +++++++++++------- 1 file changed, 120 insertions(+), 71 deletions(-) diff --git a/rfc/0008-shared-sdk-core-and-ts-binding/README.md b/rfc/0008-shared-sdk-core-and-ts-binding/README.md index a0f104ecb3..ad15de4723 100644 --- a/rfc/0008-shared-sdk-core-and-ts-binding/README.md +++ b/rfc/0008-shared-sdk-core-and-ts-binding/README.md @@ -4,55 +4,111 @@ authors: state: review links: - https://linear.app/nvidia/issue/OSGH-110/python-and-typescript-sdk-support + - https://github.com/NVIDIA/OpenShell/pull/2122 + - https://github.com/NVIDIA/OpenShell/pull/1862 --- -# RFC 0008 - Shared Rust SDK core and TypeScript binding +# RFC 0008 - SDK architecture: shared core, per-language bindings ## Summary -Extract a new `openshell-sdk` Rust crate from gRPC client plumbing that today lives in `openshell-cli`, and ship a TypeScript SDK (`@openshell/sdk`) as a [napi-rs](https://napi.rs) wrapper over that crate. Refactor `openshell-cli` to consume `openshell-sdk` so the CLI and the TS SDK share a single transport, auth, and error implementation. The pure-Python SDK at `python/openshell/` stays as-is for this RFC; migrating it onto the shared core is deferred to a follow-up. +The goal for every OpenShell SDK is one thing: a client that is idiomatic and performant in its own language. How that is achieved is immaterial. Two things are shared across all of them: the `proto/` wire contract, and single-flight OIDC refresh, the one behavior that must be byte-identical. Everything a user touches, the types, errors, resource management, and async model, is built to feel native to the language. + +A language reaches that goal one of two ways. A native binding generates its client from `proto/` and implements the surface in the language (TypeScript over connect-es, Go over grpc-go). An FFI binding hand-writes the surface over the shared `openshell-sdk` Rust core, which supplies transport, auth, and refresh (Python over PyO3). Both are first-class. + +Extract the `openshell-sdk` Rust crate as the shared transport, auth, and error core. It backs the Rust consumers, `openshell-cli` and `openshell-tui`, directly, and is the substrate FFI bindings wrap. + +## Principles + +This document is the grounding for OpenShell's SDKs. A future language SDK decides its binding strategy and shapes its surface against these principles and the contract below. + +- **Idiomatic first.** An SDK feels native to its language: the ecosystem's types, error handling, async model, and resource management. A user should not be able to tell whether the client is native or FFI-backed. +- **Performant.** It uses the ecosystem's best transport and adds no overhead a hand-written client would not. +- **Share only what must be shared.** The `proto/` contract and single-flight OIDC refresh are shared across every SDK. Everything else is per-language. +- **Mechanism is a means, not a mandate.** Native codegen and FFI over `openshell-sdk` are equal options, chosen per language by the criteria in [Choosing a binding strategy](#choosing-a-binding-strategy). ## Motivation -OpenShell currently has one programmable surface (Python) and a CLI. The Python SDK is a hand-written gRPC client in `python/openshell/sandbox.py` that duplicates concerns already implemented in Rust (`openshell-cli/src/tls.rs` and `openshell-cli/src/oidc_auth.rs`): +OpenShell has one programmable surface today (Python) plus a CLI. The Python SDK is a hand-written gRPC client in `python/openshell/sandbox.py` that reimplements plumbing already written in Rust (`openshell-cli/src/tls.rs`, `oidc_auth.rs`, `edge_tunnel.rs`): - TLS material loading, mTLS channel setup - Edge-auth bearer token attachment - OIDC token refresh - Plaintext vs TLS transport selection -Adding a TypeScript SDK by hand-writing a third gRPC client would extend the duplication. Three reasons to share a Rust core instead: +Every new SDK written entirely by hand repeats that plumbing. Two properties of OpenShell let us stop repeating it: + +1. **The wire contract is already shared through `proto/`.** RPCs, messages, and types change often and regenerate cheaply into any language, so a generated client inherits the contract for free. +2. **Only one behavior is genuinely stateful across languages.** Single-flight OIDC refresh (one refresh in flight, all waiters share the result, proactive before a call and reactive on `Unauthenticated`) is the piece worth pinning identical. -1. **Multi-language support without re-implementing the transport per language.** We expect TS/Node users (TS-authored agents, web tooling). A shared transport layer keeps retry, auth refresh, and streaming consistent across bindings. -2. **The Rust transport already runs in production.** The CLI exercises every auth mode today. -3. **Establishes the pattern for other-language bindings.** If this works for TS, the same crate can later back a PyO3 binding and replace the pure-Python SDK. +So the plumbing is shared through one Rust implementation, and each language spends its effort only on an idiomatic surface. The `openshell-sdk` crate is that shared implementation: it backs the CLI and TUI directly, dedupes the production transport stack between them, and is the reference every other binding matches or wraps. ## What exists today -- **Python SDK.** `python/openshell/sandbox.py`, hand-written gRPC against the existing protos. -- **CLI transport stack.** Full set of transport/auth modes implemented in `openshell-cli/src/tls.rs`, `openshell-cli/src/oidc_auth.rs`, and `openshell-cli/src/edge_tunnel.rs`. Runs in production today. +- **Python SDK.** `python/openshell/sandbox.py`, hand-written gRPC against the existing protos. Synchronous, with frozen dataclasses, context managers, and a `cloudpickle`-based `exec_python`. +- **CLI transport stack.** The full set of transport and auth modes, in `openshell-cli/src/tls.rs`, `oidc_auth.rs`, and `edge_tunnel.rs`. Runs in production. +- **TypeScript SDK prototype.** `sdk/typescript/` (PR #2122), a native client generated from `proto/` over connect-es. Covers health, sandbox CRUD, waits, and streamed exec. The reference for the native-binding pattern. ## Non-goals -- **Replacing the pure-Python SDK.** That migration is a separate, larger decision (API parity, deprecation window, packaging). This RFC keeps Python on its current pure-Python stack and only ensures the shared core is shaped so a future PyO3 wrapper is feasible. -- **gRPC contract changes.** The SDK is a client of the existing `proto/openshell.proto`, `proto/sandbox.proto`, `proto/inference.proto`. No service or message changes. -- **Browser / WebAssembly support.** napi-rs targets Node only. A browser SDK is a separate future RFC. -- **Bundling the `openshell` CLI binary inside the npm package.** Unlike the Python wheel (which uses maturin's `bindings = "bin"` to bundle the CLI), the TS SDK is gRPC-only. CLI installation stays a separate concern. -- **Streaming `exec` in the initial slice.** Tracked separately. +- **Executing the Python migration.** This RFC names PyO3 over `openshell-sdk` as Python's binding strategy, but the migration itself (API parity, deprecation window, packaging) is a separate decision. The existing pure-Python client stays until then. +- **gRPC contract changes.** The SDKs are clients of the existing `proto/openshell.proto`, `proto/sandbox.proto`, `proto/inference.proto`. Expanding the RPC surface to move capability server-side (so thin SDKs gain reach without per-language code) is a related but separate track; see RFC 0007. +- **Browser / WebAssembly support.** A browser SDK is a separate future RFC. +- **Bundling the `openshell` CLI binary inside a language package.** The SDKs are gRPC clients. CLI installation stays a separate concern. ## Proposal -### New and changed crates +### The SDK contract + +Every SDK, native or FFI, delivers the same contract: + +- **An idiomatic, hand-written surface.** The language's own types, error handling, resource management, and async model. Never an auto-generated 1:1 mirror of the Rust API. +- **The five transport and auth modes** (see [Transport and auth modes](#transport-and-auth-modes)). +- **Single-flight OIDC refresh** with the shared observable behavior. +- **Curated types with string-valued enums**, plus a `raw` escape hatch exposing the generated stubs for RPCs the curated surface does not cover. +- **A stable string-coded error taxonomy** (`not_found`, `already_exists`, `auth`, `invalid_config`, `rpc`, ...) mapped from gRPC status codes. + +The strategies differ only in where the core plumbing (transport, auth, refresh) comes from: + +- A **native binding** generates stubs from `proto/` (connect-es for TS; `protoc-gen-go` + `grpc-go`, or connect-go, for Go) and reimplements the plumbing in the language. Its refresh is pinned to the conformance suite. +- An **FFI binding** inherits the plumbing from `openshell-sdk` and hand-writes only the surface on top. Its refresh is the shared core implementation, so it needs no conformance pin. + +FFI is not a shortcut around surface design: it removes the need to rebuild the core plumbing, not the need to write a good surface. + +### Choosing a binding strategy + +Both strategies satisfy the contract. Pick the better fit for the language, weighing: + +- Is a pure-language gRPC stack mature and idiomatic in the ecosystem? +- Does the ecosystem already ship and expect compiled extensions? +- Would an FFI distribution model break a core ecosystem property (for example, Go's static, cross-compiled binaries)? +- Does sharing the plumbing meaningfully reduce risk (one audited OIDC and TLS path) versus a per-language reimplementation? + +| Language | Strategy | Why it fits | +| --- | --- | --- | +| TypeScript | Native (connect-es) | A clean pure-JS client over generated stubs; shipping prebuilt binaries across Node targets would cost more and add nothing. | +| Go | Native (grpc-go) | grpc-go is idiomatic and fast; cgo would forfeit static, cross-compiled binaries, Go's core distribution property. | +| Python | FFI (PyO3) | The package already ships as a compiled wheel (maturin), so a compiled core costs nothing new, and one shared plumbing core removes a second transport/auth/refresh implementation. The idiomatic surface (frozen dataclasses, context managers, `exec_python`, sync and async) stays hand-written Python over the core. | + +### Crate and package layout ``` crates/ - openshell-sdk/ NEW. Pure Rust async client library. No FFI, no CLI deps. - openshell-sdk-node/ NEW. napi-rs wrapper over openshell-sdk. Ships as @openshell/sdk. + openshell-sdk/ NEW. Shared Rust async client core. Backs the CLI and TUI directly + and is the substrate FFI bindings wrap. openshell-cli/ REFACTORED. Channel/auth code moves out; CLI consumes openshell-sdk. + openshell-tui/ REFACTORED. Consumes openshell-sdk. openshell-core/ UNCHANGED. Still owns proto codegen; openshell-sdk depends on it. + +sdk/ + typescript/ Native binding: generated from proto/ (connect-es). @nvidia/openshell-sdk. + go/ Native binding, planned: generated from proto/ (protoc-gen-go + grpc-go). + +python/openshell/ FFI binding: PyO3 over openshell-sdk (leading candidate; migration is + a separate decision, see Non-goals). Hand-written client until then. ``` -### `openshell-sdk` surface +### `openshell-sdk` (Rust) surface ```rust pub struct ClientConfig { @@ -83,17 +139,15 @@ impl OpenShellClient { } ``` -### `openshell-sdk-node` surface - -A thin napi-rs wrapper exposing the same surface as JS classes / objects. Idiomatic camelCase (`createSandbox`, `waitReady`) is generated automatically from snake_case Rust by napi-derive. +An FFI binding wraps this surface and re-presents it in the host language. Because the core is async (tonic/tokio), a binding can expose an async surface mapped from these futures (for Python, via `pyo3-async-runtimes`) or a synchronous one over `block_on`, or both. ### CLI refactor -Transport mechanics move out of `openshell-cli` and into `openshell-sdk`: gRPC channel construction, TLS material handling, request interceptors, and the Cloudflare Access tunnel. The CLI keeps everything user-facing — gateway-name resolution, default-path lookups, and the OIDC browser flow. The SDK never sees a browser; it consumes a `Refresh` trait that the CLI implements. +Transport mechanics move out of `openshell-cli` into `openshell-sdk`: gRPC channel construction, TLS material handling, request interceptors, and the Cloudflare Access tunnel. The CLI keeps everything user-facing: gateway-name resolution, default-path lookups, and the OIDC browser flow. The SDK never sees a browser; it consumes a `Refresh` trait that the CLI implements. ### Transport and auth modes -MVP must support the same five transport/auth modes the CLI exercises today, so a CLI user can move to the SDK without losing connectivity options: +The MVP preserves the five transport and auth modes the CLI exercises today, so a CLI user can move to any SDK without losing connectivity options: - Plaintext (local development) - mTLS (self-deployed gateways with client certs) @@ -101,79 +155,74 @@ MVP must support the same five transport/auth modes the CLI exercises today, so - Cloudflare Access tunnel (hosted gateways) - Insecure TLS (development/debug; certificate verification disabled) -### Current leanings - -| Decision | Choice | Rationale | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Binding tool for TS** | napi-rs v3 | Required for `ThreadsafeFunction>` + `call_async`. Considered: UniFFI (no stable TS target), Diplomat (smaller community, JS support nascent), wit-bindgen (Node packaging not yet ergonomic). | -| **Tokio runtime ownership at FFI boundary** | napi's ambient tokio runtime is available only inside `async fn` entry points. Every user-facing napi function that needs the runtime must be `async`. No `Handle` plumbing in `ClientConfig`. | Sync `#[napi] fn` runs on the JS thread with no reactor; `tokio::spawn` from sync napi context panics with "no reactor running". | -| **API shape** | Async-only, no blocking facade | Tonic is async-native; a blocking facade would require `block_on` plumbing and confuse the JS Promise contract. Callers needing sync can wrap with `tokio::runtime::Runtime::block_on` themselves. | -| **Error type** | `thiserror` enum in `openshell-sdk`, mapped to napi `Error` with a `code` field for TS discriminated-union ergonomics | Better than the Python SDK's single `SandboxError(RuntimeError)`. Lets TS consumers `switch` on error kind. | -| **Retry policy** | Per-call configurable; default = no retry | Matches the Python SDK. Advanced users opt in. | -| **OIDC refresh trait** | SDK accepts a `Refresh` trait with a domain error type, not `napi::Error`. CLI provides the browser-flow impl. Node binding wraps a JS callback as a `ThreadsafeFunction<(), Promise>`. | Keeps `openshell-sdk` napi-free. | -| **Single-flight refresh coalescing** | Lives in `openshell-sdk` core, not in the binding. | napi does not provide it; standard OIDC pattern needs it (one refresh in flight, all waiters share the result). | -| **OIDC refresh cancellation** | Rust-side future drop does not propagate to JS. In-flight JS refresher promise runs to completion; SDK ignores late-arriving values. | Trait does not need cooperative cancellation. | -| **Streaming pattern** | Iterator-style: a napi class with an async `next()` method, drop-based cancellation, and a thin TS shim layering `for await` on top. Not native AsyncGenerator. | napi-rs v3.8 has no native AsyncGenerator return type. | -| **Auth token file loading** | NOT in `openshell-sdk` directly. Callers pass an explicit token. A separate convenience helper in `openshell-cli` (or a thin helper crate) handles the `~/.config/openshell/gateways//edge_token` lookup. | Keeps `openshell-sdk` free of filesystem access. Usable as a library without CLI assumptions. | -| **SDK layering scope (MVP)** | Sandbox-focused. High-level methods cover health, sandbox CRUD, waits, and non-streaming exec. A `raw` module re-exports generated tonic clients as an escape hatch. Inference, providers, policy, logs, settings, SSH, forwarding, and completions are out of MVP and deferred. | The `raw` escape hatch lets callers reach RPCs the high-level surface doesn't yet cover. | -| **TypeScript API model** | Curated SDK types, not raw proto shapes. Enum-valued fields use string literals (e.g., `"Pending"`), not numeric proto enums. Captured high-level types: `SandboxSpec`, `SandboxRef`, `Health`, `ListOptions`, `ExecOptions`. | TS DX is better with discriminated string unions than with numeric proto enums. | +A binding may realize a mode differently: an FFI binding inherits the modes from the core, and a native binding may run the Cloudflare Access tunnel as a loopback sidecar rather than in-language. The set of supported modes is the same. + +### Design decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| **Binding strategy** | Per-language over one shared `proto/` contract: native codegen or FFI over `openshell-sdk`, chosen by ecosystem fit (native for TS/Go, FFI/PyO3 for Python). | Each ecosystem gets the mechanism that fits it best; the shared contract and core keep the bindings aligned regardless. | +| **Shared behavior** | Single-flight OIDC refresh lives in `openshell-sdk`. FFI bindings inherit it; native bindings match it against a conformance suite. | It is the only genuinely stateful cross-language behavior, so it is worth one shared implementation plus a pin on the reimplementations. | +| **Error model** | Stable string-coded taxonomy, mapped from gRPC status codes. Rust uses a `thiserror` enum; bindings mirror the codes. | Lets consumers discriminate on error kind (`switch`/`match`) instead of parsing messages. | +| **Type model** | Curated SDK types, not raw proto shapes. Enum fields use string literals, not numeric proto enums. Curated set: `SandboxSpec`, `SandboxRef`, `Health`, `ListOptions`, `ExecOptions`, `ExecResult`. | Reads naturally in each language and stays stable as the proto evolves. | +| **Escape hatch** | Each SDK exposes the generated stubs (`raw`) for RPCs the curated surface does not cover. | Callers reach any RPC without waiting for a curated wrapper. | +| **MVP scope** | Sandbox-focused: health, sandbox CRUD, waits, exec (streamed where the language allows). Inference, providers, policy, logs, settings, SSH, and forwarding are deferred. | Ship the common path first; grow the curated surface over time. | +| **Auth token file loading** | Not in `openshell-sdk`. Callers pass an explicit token; the CLI or a helper resolves `~/.config/openshell/gateways//...`. | Keeps the core free of filesystem access; usable as a plain library. | +| **Retry policy** | Per-call configurable; default off. | Predictable behavior by default; callers opt into retries explicitly. | +| **Cloudflare Access tunnel** | Native bindings may run it out-of-process as a language-agnostic loopback sidecar rather than in-language. | Avoids reimplementing the WebSocket tunnel per language; the binding points at the local endpoint and the header mode covers the rest. | ## Implementation plan Phases ordered by dependency. No time estimates. This RFC establishes direction; detailed contracts (the `Refresh` trait shape, error codes, exec semantics) settle at implementation time. -### Phase 1 — Refactor and extract `openshell-sdk` +### Phase 1 - Extract `openshell-sdk` and refactor the Rust consumers - Create `crates/openshell-sdk/` with transport, auth, error, and edge-tunnel modules. -- Execute the CLI refactor described above. -- Exit criteria: all existing `mise run test` and `mise run e2e` paths pass. No new SDK consumers yet. +- Refactor `openshell-cli` and `openshell-tui` onto it. +- Exit criteria: existing `mise run test` and `mise run e2e` paths pass. No behavior change. -### Phase 2 — High-level SDK methods +### Phase 2 - High-level Rust SDK methods -- Implement `health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, `wait_ready`, `wait_deleted`, non-streaming `exec`. -- Unit tests with a mock tonic server. +- Implement `health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, `wait_ready`, `wait_deleted`, and non-streaming `exec`. +- Unit tests against a mock tonic server. - Settle the `Refresh` trait contract: single-flight semantics, proactive vs reactive trigger, deadline, retry-after-refresh-failure, terminal-failure signalling. -### Phase 3 — `openshell-sdk-node` napi binding +### Phase 3 - Language bindings -- Build the JS-facing client surface over `openshell-sdk`. -- Wire the OIDC refresh callback path between Rust and JS. -- Map SDK errors to JS errors with a discriminable `code` field. -- Resolve the tunnel-vs-refresh interaction with one targeted test (does the CF tunnel re-handshake on bearer rotation, swap headers in place, or tear down and rebuild?). -- Smoke test against a plaintext local gateway. +- TypeScript (`sdk/typescript/`, PR #2122) is the reference native binding: generated from `proto/`, curated surface, error taxonomy, transport modes. +- Go (`sdk/go/`) follows over `protoc-gen-go` + `grpc-go` (or connect-go for symmetry with TS). +- Python moves to a PyO3 binding over `openshell-sdk`, keeping its idiomatic Python surface and retiring the hand-written plumbing (the migration is scoped separately; see Non-goals). +- Stand up the shared OIDC-refresh conformance suite for the native bindings (TS, Go). ## Migration and compatibility -- **CLI surface preserved.** The phase 1 refactor does not change `openshell-cli` flags, behavior, or output. Existing scripts continue to work. +- **CLI and TUI surface preserved.** The phase 1 refactor does not change flags, behavior, or output. Existing scripts continue to work. - **gRPC contract unchanged** (see Non-goals). -- **Python SDK frozen.** The pure-Python SDK is unaffected by this RFC. -- **Alpha contract.** `@openshell/sdk` ships under `0.0.0-alpha.x` until the surface stabilizes; no semver guarantee before 1.0. +- **Python client stays until migrated.** The hand-written pure-Python client keeps working until the PyO3 migration is decided and executed. +- **Independent package versioning.** Each SDK package versions on its own release cadence and carries a `0.0.0` placeholder stamped from the release tag at publish time. Surfaces stay pre-1.0 (no semver guarantee) until stabilized. ## Risks -- **CLI regression during phase 1.** Mitigation: extraction PR ships first with no SDK consumers, with the existing CLI tests as the regression surface. -- **napi-rs prebuilt binary CI complexity.** Six-target build matrices break in interesting ways (musl static linking, macOS codesigning, cross-compilation for aarch64). The v3 toolchain has only been exercised on darwin-arm64 so far; the full cross-platform matrix is unproven. Mitigation: lean on napi-rs's published workflow template; treat the first publish as the trigger for completing the build matrix. -- **Python/TS SDK behavior drift.** While Python stays pure-Python over gRPC, behavior (timeouts, retry, error mapping) may drift from the Rust SDK. Mitigation: keep the Python SDK frozen during this RFC's implementation; track parity as a precondition for a future Python-on-shared-core RFC. -- **Refresh contract details.** The FFI mechanism is settled. Still unspecified: proactive vs reactive trigger, deadline, retry-after-refresh-failure, terminal-failure signalling. Mitigation: design alongside phase 2. -- **Tunnel-vs-refresh interaction.** The CF tunnel captures bearer headers at connection time; bearer rotation mid-session is not yet specified. Mitigation: settle in phase 3 with one targeted test before npm alpha publish. +- **CLI/TUI regression during phase 1.** Mitigation: the extraction lands first with no new consumers, using the existing CLI and TUI tests as the regression surface. +- **Native binding drift.** A native binding reimplements the thin surface, so timeouts, retry, error mapping, and refresh can diverge from the core (FFI bindings cannot, since they inherit it). Mitigation: the OIDC-refresh conformance suite pins the stateful part; curated types and the shared error taxonomy keep the surfaces aligned; treat drift as a bug against the suite. +- **Proto surface growth.** Expanding what SDKs can do by pushing capability into RPCs (for example, file transfer, per RFC 0007) grows the gateway API and its authz surface. Track that as its own decision, not a side effect of this RFC. +- **Cloudflare Access tunnel per language.** The loopback-sidecar approach is proven for the CLI's inline tunnel but not yet for every native binding. Mitigation: settle the sidecar contract before the first tunnel-backed native release. ## Alternatives -- **Pure-TS gRPC client (e.g., `@connectrpc/connect`, `ts-proto`).** Cheaper and faster initially, no shared runtime. Loses all the shared-core benefits (auth refresh, retry, error taxonomy) and locks us into duplicating logic per language. Reasonable if the project decided the shared-core direction is overkill; this RFC argues it's not. -- **TS calls Python via subprocess or IPC.** Rejected — terrible DX, forces a Python runtime on Node consumers. -- **UniFFI for both Python and TS.** UniFFI's TS target is not yet stable. Re-evaluate once it lands. -- **Diplomat (Rust → JS/Dart/Kotlin).** Smaller community, JS support less proven. -- **`wit-bindgen` + WebAssembly component model.** The likely long-term target once Node packaging of wasm components matures. -- **Do nothing; tell TS users to use the gRPC stubs directly.** Possible, but leaves every TS consumer to roll their own wrapper. +- **One binding mechanism for every language.** Either FFI everywhere (bind one Rust core from Node, Python, and Go alike) or native everywhere. FFI everywhere forfeits Go's static, cross-compiled binaries and saddles Node with a multi-target prebuilt matrix for a thin, fast-changing surface. Native everywhere makes Python reimplement transport, auth, and refresh it could otherwise inherit through a binding, and drop the compiled-wheel machinery it already runs. The per-language strategy takes the better fit for each instead. +- **UniFFI / Diplomat / wit-bindgen for the FFI bindings** instead of hand-written PyO3. Multi-language binding generators; PyO3 is the standard, well-supported path for Python and is enough for a single FFI target today. `wit-bindgen` plus the WebAssembly component model is a plausible long-term browser story, not a fit here. +- **TS or Go calls Python via subprocess or IPC.** Rejected. Terrible DX and forces a Python runtime on other-language consumers. +- **Do nothing; tell users to use the generated gRPC stubs directly.** Rejected. Leaves every consumer to roll their own transport, auth, refresh, and error handling. ## Prior art -- **Polars** — Rust core, PyO3 for Python, napi-rs for Node. Same pattern. -- **swc** and **Turbopack** — large napi-rs projects in the JS tooling ecosystem, demonstrate the publishing/CI patterns. -- **Bitwarden SDK** — Rust core with UniFFI bindings; useful reference for Refresh-trait-style auth design even though we're not using UniFFI. -- **1Password Connect SDK** — multi-language SDK over a shared gRPC contract, same design choice in a different domain. +- **1Password Connect SDK** and **gRPC's own multi-language codegen.** Multiple languages over a shared wire contract, each generated natively. The model for the native bindings. +- **Polars** (Rust core, PyO3 for Python, napi for Node) and compiled-extension packages like **pydantic-core** and **cryptography**. The model for the Python FFI binding: a compiled core with a hand-written idiomatic surface is normal and well-tooled in the Python ecosystem. ## Open questions -- **Retry policy shape.** Builder on `ClientConfig` (declarative) or `tower::Layer` (composable)? Composable is more flexible; declarative is friendlier for napi/PyO3 consumers who can't construct a `Layer`. -- **Should `OpenShellClient::from_gateway_name(name)` exist in `openshell-sdk` at all,** or only in a CLI-config helper crate? Tradeoff between ergonomics and keeping `openshell-sdk` filesystem-free. +- **Retry policy shape.** Builder on `ClientConfig` (declarative) or `tower::Layer` (composable) in the Rust crate? Composable is more flexible; declarative is friendlier for a native reimplementation. +- **`from_gateway_name`.** Should it exist in `openshell-sdk`, or only in a CLI/config helper? Tradeoff between ergonomics and keeping the core filesystem-free. +- **Server-side vs client-side capability.** As the curated surface grows, some capability (file transfer, policy prove) is cheaper to add once as an RPC than to reimplement per language, at the cost of gateway surface and authz. Coordinate scope with RFC 0007. +- **Python migration timing and fallback.** When to execute the PyO3 migration, and whether to keep a pure-Python path for environments that cannot install a compiled extension.