feat(sdk): extract openshell-sdk crate

Per RFC 0005, lift the gRPC client, TLS, OIDC, edge-tunnel, and refresh
plumbing out of openshell-cli into a new openshell-sdk crate. CLI and
TUI now consume the SDK; openshell-cli/src/{tls.rs,oidc_auth.rs} shrink
to thin wrappers over the SDK's transport and OIDC modules.

- New crate openshell-sdk exposes a typed gRPC client, TLS resolver,
  OidcRefresher with single-flight semantics, edge-tunnel dialer, and a
  Sandbox-API surface that mirrors the existing CLI behavior.
- crates/openshell-core/src/auth.rs moves into the SDK as auth.rs.
- crates/openshell-cli/src/edge_tunnel.rs moves into the SDK as
  edge_tunnel.rs.

Tests: 3 unit + 10 mock-gateway integration tests in openshell-sdk.
Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
This commit is contained in:
Max Dubrinsky
2026-06-26 12:42:11 -04:00
parent d93293ad12
commit 5d5cfe9a0d
26 changed files with 2592 additions and 303 deletions
+1
View File
@@ -37,6 +37,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
| `crates/openshell-providers/` | Provider management | Credential provider backends |
| `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring |
| `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods |
Generated
+27 -3
View File
@@ -3422,6 +3422,7 @@ dependencies = [
"openshell-policy",
"openshell-prover",
"openshell-providers",
"openshell-sdk",
"openshell-tui",
"owo-colors",
"prost-types",
@@ -3437,11 +3438,8 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tonic",
"tower 0.5.3",
"tracing",
"tracing-subscriber",
"url",
@@ -3665,6 +3663,31 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "openshell-sdk"
version = "0.0.0"
dependencies = [
"async-trait",
"futures",
"hyper",
"hyper-util",
"miette",
"oauth2",
"openshell-core",
"reqwest 0.12.28",
"rustls",
"rustls-pemfile",
"serde",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tonic",
"tower 0.5.3",
"tracing",
]
[[package]]
name = "openshell-server"
version = "0.0.0"
@@ -3826,6 +3849,7 @@ dependencies = [
"openshell-core",
"openshell-policy",
"openshell-providers",
"openshell-sdk",
"owo-colors",
"ratatui",
"serde",
+1 -5
View File
@@ -20,6 +20,7 @@ openshell-core = { path = "../openshell-core", default-features = false }
openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-prover = { path = "../openshell-prover" }
openshell-sdk = { path = "../openshell-sdk" }
openshell-tui = { path = "../openshell-tui" }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -49,8 +50,6 @@ hyper-util = { workspace = true }
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring", "webpki-tokio"] }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
tokio-rustls = { workspace = true }
tower = { workspace = true }
reqwest = { workspace = true }
# Error handling
@@ -66,9 +65,6 @@ tempfile = "3"
oauth2 = "5"
base64 = { workspace = true }
# WebSocket (Cloudflare tunnel proxy)
tokio-tungstenite = { workspace = true }
# Streams
futures = { workspace = true }
tokio-stream = { workspace = true }
+1 -1
View File
@@ -9,9 +9,9 @@ use openshell_bootstrap::edge_token::load_edge_token;
use openshell_bootstrap::oidc_token::{is_token_expired, load_oidc_token, store_oidc_token};
use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metadata};
use openshell_core::ObjectName;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest};
use openshell_sdk::EdgeAuthInterceptor;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
-1
View File
@@ -10,7 +10,6 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()
pub mod auth;
pub mod completers;
pub mod edge_tunnel;
pub mod oidc_auth;
pub mod output;
pub(crate) mod policy_update;
+1 -1
View File
@@ -3017,7 +3017,7 @@ async fn main() -> Result<()> {
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);
let channel = openshell_cli::tls::build_channel(&ctx.endpoint, &tls).await?;
let interceptor = openshell_core::auth::EdgeAuthInterceptor::new(
let interceptor = openshell_sdk::EdgeAuthInterceptor::new(
tls.oidc_token.as_deref(),
tls.edge_token.as_deref(),
)?;
+19 -66
View File
@@ -17,10 +17,10 @@ use miette::{IntoDiagnostic, Result};
use oauth2::basic::BasicClient;
use oauth2::{
AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use openshell_bootstrap::oidc_token::OidcTokenBundle;
use serde::Deserialize;
use openshell_sdk::oidc::{RefreshTokenInput, discover, http_client, refresh_token};
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -30,50 +30,6 @@ use tracing::debug;
const AUTH_TIMEOUT: Duration = Duration::from_secs(120);
/// OIDC discovery document (subset of fields we need).
#[derive(Debug, Deserialize)]
struct OidcDiscovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
}
/// Discover OIDC endpoints from the issuer's well-known configuration.
///
/// Validates that the discovery document's `issuer` field matches the
/// configured issuer URL to prevent SSRF or misdirection.
async fn discover(issuer: &str, insecure: bool) -> Result<OidcDiscovery> {
let normalized_issuer = issuer.trim_end_matches('/');
let url = format!("{normalized_issuer}/.well-known/openid-configuration");
let client = http_client(insecure);
let resp: OidcDiscovery = client
.get(&url)
.send()
.await
.into_diagnostic()?
.json()
.await
.into_diagnostic()?;
let discovered_issuer = resp.issuer.trim_end_matches('/');
if discovered_issuer != normalized_issuer {
return Err(miette::miette!(
"OIDC discovery issuer mismatch: expected '{}', got '{}'",
normalized_issuer,
discovered_issuer
));
}
Ok(resp)
}
fn http_client(insecure: bool) -> reqwest::Client {
let mut builder = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none());
if insecure {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().expect("failed to build HTTP client")
}
fn build_scopes(scopes: Option<&str>) -> Vec<Scope> {
let mut result = vec![Scope::new("openid".to_string())];
if let Some(s) = scopes {
@@ -227,36 +183,33 @@ pub async fn oidc_client_credentials_flow(
/// Refresh an OIDC token using the `refresh_token` grant.
///
/// Preserves the existing refresh token if the server does not return a new
/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`).
/// Wraps [`openshell_sdk::oidc::refresh_token`] with the CLI's
/// [`OidcTokenBundle`] storage shape. Preserves the existing refresh
/// token when the server omits one (per OAuth 2.0 the refresh response
/// is allowed to leave `refresh_token` out).
pub async fn oidc_refresh_token(
bundle: &OidcTokenBundle,
insecure: bool,
) -> Result<OidcTokenBundle> {
let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| {
let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
miette::miette!(
"no refresh token available — re-authenticate with: openshell gateway login"
)
})?;
let discovery = discover(&bundle.issuer, insecure).await?;
let input =
RefreshTokenInput::new(refresh, &bundle.issuer, &bundle.client_id).with_insecure(insecure);
let output = refresh_token(&input).await.into_diagnostic()?;
let client = BasicClient::new(ClientId::new(bundle.client_id.clone()))
.set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?);
let http = http_client(insecure);
let token_response = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request_async(&http)
.await
.map_err(|e| miette::miette!("token refresh failed: {e}"))?;
let mut refreshed =
bundle_from_oauth2_response(&token_response, &bundle.issuer, &bundle.client_id);
if refreshed.refresh_token.is_none() {
refreshed.refresh_token.clone_from(&bundle.refresh_token);
}
Ok(refreshed)
Ok(OidcTokenBundle {
access_token: output.access_token,
refresh_token: output
.refresh_token
.or_else(|| bundle.refresh_token.clone()),
expires_at: output.expires_at,
issuer: bundle.issuer.clone(),
client_id: bundle.client_id.clone(),
})
}
/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed.
+104 -204
View File
@@ -2,25 +2,26 @@
// SPDX-License-Identifier: Apache-2.0
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::inference_client::InferenceClient;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_sdk::EdgeAuthInterceptor;
use rustls::{
RootCertStore,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime},
pki_types::{CertificateDer, PrivateKeyDer},
};
use std::collections::HashMap;
use std::future::Future;
use std::io::Cursor;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::Mutex;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
use tracing::debug;
// `build_insecure_rustls_config` lives in the SDK (used by the SDK's
// transport stack and by CLI's HTTP health check). The other former
// `tls.rs` helpers (`build_rustls_config`, `build_tonic_tls_config`,
// `load_private_key`, `TlsMaterials`) were tied to mTLS and now live
// below as CLI-private legacy code — they will go away when mTLS is
// retired as an auth method.
pub use openshell_sdk::transport::build_insecure_rustls_config;
/// Concrete gRPC client type used by all commands.
pub type GrpcClient = OpenShellClient<InterceptedService<Channel, EdgeAuthInterceptor>>;
@@ -104,12 +105,44 @@ impl TlsOptions {
pub fn is_bearer_auth(&self) -> bool {
self.edge_token.is_some() || self.oidc_token.is_some()
}
}
pub struct TlsMaterials {
ca: Vec<u8>,
cert: Vec<u8>,
key: Vec<u8>,
/// Returns `true` when this `TlsOptions` carries a full mTLS client
/// identity (cert + key on disk). Used by [`build_channel`] to route
/// mTLS-authenticated gateways through the legacy inline path.
pub fn has_mtls_identity(&self, server: &str) -> bool {
let resolved = self.with_default_paths(server);
resolved.cert.as_ref().is_some_and(|p| p.exists())
&& resolved.key.as_ref().is_some_and(|p| p.exists())
}
/// Convert this CLI-side `TlsOptions` into an SDK [`openshell_sdk::ClientConfig`]
/// for non-mTLS gateways.
///
/// Reads the CA cert from disk if a path resolves; a missing file is
/// non-fatal and falls back to system roots (matches today's OIDC
/// fallback behavior). Maps tokens to [`openshell_sdk::AuthConfig`]
/// with OIDC taking precedence over `EdgeJwt` when both are set.
///
/// mTLS materials are intentionally not carried through; gateways
/// requiring client certificates are dispatched to the legacy inline
/// path in [`build_channel`] before this conversion is reached.
pub fn to_client_config(&self, server: &str) -> openshell_sdk::ClientConfig {
let resolved = self.with_default_paths(server);
let ca_cert = resolved
.ca
.as_ref()
.and_then(|ca_path| std::fs::read(ca_path).ok());
let auth = match (&resolved.oidc_token, &resolved.edge_token) {
(Some(token), _) => Some(openshell_sdk::AuthConfig::Oidc(token.clone())),
(None, Some(token)) => Some(openshell_sdk::AuthConfig::EdgeJwt(token.clone())),
(None, None) => None,
};
let mut config = openshell_sdk::ClientConfig::new(server);
config.ca_cert = ca_cert;
config.auth = auth;
config.insecure_skip_verify = resolved.gateway_insecure;
config
}
}
/// Resolve the TLS cert directory for a known gateway name.
@@ -163,6 +196,20 @@ fn xdg_config_dir() -> Result<PathBuf> {
openshell_core::paths::xdg_config_dir()
}
// ── Legacy mTLS path ─────────────────────────────────────────────────
// Everything in this section supports gateways that authenticate clients
// with an mTLS certificate. mTLS is being retired as an auth method, and
// the SDK does not speak it. Until product removes mTLS support, these
// helpers stay in CLI for the `else { full mTLS }` branch of
// `build_channel` and the matching branch of `http_health_check`.
/// In-memory mTLS materials read from disk by [`require_tls_materials`].
pub struct TlsMaterials {
pub ca: Vec<u8>,
pub cert: Vec<u8>,
pub key: Vec<u8>,
}
pub fn require_tls_materials(server: &str, tls: &TlsOptions) -> Result<TlsMaterials> {
let resolved = tls.with_default_paths(server);
let default_hint = default_tls_dir(server).map_or_else(String::new, |dir| {
@@ -192,6 +239,7 @@ pub fn require_tls_materials(server: &str, tls: &TlsOptions) -> Result<TlsMateri
Ok(TlsMaterials { ca, cert, key })
}
/// Parse a PEM-encoded private key for the legacy mTLS rustls path.
fn load_private_key(pem: &[u8]) -> Result<PrivateKeyDer<'static>> {
let mut cursor = Cursor::new(pem);
let key = rustls_pemfile::private_key(&mut cursor)
@@ -200,11 +248,12 @@ fn load_private_key(pem: &[u8]) -> Result<PrivateKeyDer<'static>> {
Ok(key)
}
/// Build a `rustls` mTLS client config (used by `http_health_check`).
pub fn build_rustls_config(materials: &TlsMaterials) -> Result<rustls::ClientConfig> {
let mut roots = RootCertStore::empty();
let mut ca_cursor = Cursor::new(&materials.ca);
let ca_certs = rustls_pemfile::certs(&mut ca_cursor)
.collect::<Result<Vec<CertificateDer<'static>>, _>>()
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
.into_diagnostic()?;
for cert in ca_certs {
roots.add(cert).into_diagnostic()?;
@@ -212,7 +261,7 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result<rustls::ClientCon
let mut cert_cursor = Cursor::new(&materials.cert);
let cert_chain = rustls_pemfile::certs(&mut cert_cursor)
.collect::<Result<Vec<CertificateDer<'static>>, _>>()
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
.into_diagnostic()?;
let key = load_private_key(&materials.key)?;
@@ -222,6 +271,8 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result<rustls::ClientCon
.into_diagnostic()
}
/// Build a `tonic` mTLS client config (used by the legacy `build_channel`
/// mTLS branch and by `completers.rs`).
pub fn build_tonic_tls_config(materials: &TlsMaterials) -> ClientTlsConfig {
let ca_cert = Certificate::from_pem(materials.ca.clone());
let identity = Identity::from_pem(materials.cert.clone(), materials.key.clone());
@@ -230,202 +281,48 @@ pub fn build_tonic_tls_config(materials: &TlsMaterials) -> ClientTlsConfig {
.identity(identity)
}
#[derive(Debug)]
struct InsecureServerCertVerifier;
impl ServerCertVerifier for InsecureServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Clone)]
struct InsecureTlsConnector {
tls_connector: tokio_rustls::TlsConnector,
}
impl tower::Service<hyper::Uri> for InsecureTlsConnector {
type Response = hyper_util::rt::TokioIo<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Future =
std::pin::Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, uri: hyper::Uri) -> Self::Future {
let tls_connector = self.tls_connector.clone();
Box::pin(async move {
let host = uri.host().unwrap_or("localhost").to_string();
let port = uri.port_u16().unwrap_or(443);
let addr = format!("{host}:{port}");
let tcp = tokio::net::TcpStream::connect(addr).await?;
let server_name = ServerName::try_from(host)?;
let tls_stream = tls_connector.connect(server_name, tcp).await?;
Ok(hyper_util::rt::TokioIo::new(tls_stream))
})
}
}
pub fn build_insecure_rustls_config() -> Result<rustls::ClientConfig> {
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureServerCertVerifier))
.with_no_client_auth();
Ok(config)
}
/// Tunnel proxy addresses keyed by upstream endpoint + token.
///
/// Each distinct edge-authenticated gateway gets its own local proxy instead of
/// reusing the first gateway touched in the current process.
static EDGE_TUNNEL_ADDRS: OnceLock<Mutex<HashMap<(String, String), SocketAddr>>> = OnceLock::new();
async fn edge_tunnel_addr(server: &str, token: &str) -> Result<SocketAddr> {
let key = (server.to_string(), token.to_string());
let registry = EDGE_TUNNEL_ADDRS.get_or_init(|| Mutex::new(HashMap::new()));
{
let addrs = registry.lock().await;
if let Some(addr) = addrs.get(&key).copied() {
return Ok(addr);
}
}
let proxy = crate::edge_tunnel::start_tunnel_proxy(server, token).await?;
debug!(
local_addr = %proxy.local_addr,
server,
"edge tunnel proxy started, routing gRPC through local proxy"
);
let mut addrs = registry.lock().await;
Ok(*addrs.entry(key).or_insert(proxy.local_addr))
}
// ── Channel construction (legacy mTLS dispatcher) ────────────────────
// `build_channel` is a thin dispatcher: gateways that authenticate
// clients with mTLS take the inline `build_legacy_mtls_channel` path
// below; everything else converts to a `ClientConfig` and delegates to
// `openshell_sdk::transport::build_channel`. When mTLS retires as an
// auth method, `needs_legacy_mtls` and `build_legacy_mtls_channel` go
// with it.
pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
if server.starts_with("http://") {
let endpoint = Endpoint::from_shared(server.to_string())
.into_diagnostic()?
.connect_timeout(Duration::from_secs(10))
.http2_adaptive_window(true)
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_while_idle(true);
return endpoint.connect().await.into_diagnostic();
if needs_legacy_mtls(tls, server) {
return build_legacy_mtls_channel(server, tls).await;
}
let config = tls.to_client_config(server);
Ok(openshell_sdk::transport::build_channel(&config).await?)
}
// When Cloudflare edge bearer auth is active and the server is HTTPS,
// route traffic through a local WebSocket tunnel proxy instead.
// OIDC tokens bypass the tunnel — they connect directly.
if tls.edge_token.is_some() && server.starts_with("https://") {
let token = tls
.edge_token
.as_deref()
.ok_or_else(|| miette::miette!("edge token required for tunnel"))?;
let local_addr = edge_tunnel_addr(server, token).await?;
/// Returns `true` when this connection should run through the CLI's
/// inline mTLS path: HTTPS, no insecure-skip, no edge tunnel, and either
/// no OIDC token or OIDC paired with mTLS materials on disk. The combined
/// mTLS+OIDC case preserves the documented "mTLS as transport trust
/// boundary, Bearer for full scope" deployment model.
fn needs_legacy_mtls(tls: &TlsOptions, server: &str) -> bool {
server.starts_with("https://")
&& !tls.gateway_insecure
&& tls.edge_token.is_none()
&& (tls.oidc_token.is_none() || tls.has_mtls_identity(server))
}
// Connect to the local tunnel proxy over plaintext HTTP/2.
let local_url = format!("http://{local_addr}");
let endpoint = Endpoint::from_shared(local_url)
.into_diagnostic()?
.connect_timeout(Duration::from_secs(10))
.http2_adaptive_window(true)
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_while_idle(true);
return endpoint.connect().await.into_diagnostic();
}
if tls.gateway_insecure && server.starts_with("https://") {
tracing::warn!("TLS certificate verification is disabled — do not use in production");
let rustls_config = build_insecure_rustls_config()?;
let tls_connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(rustls_config));
let connector = InsecureTlsConnector { tls_connector };
// Use http:// so tonic does not layer its own TLS on top — our
// connector performs TLS with the insecure config.
let http_uri = server.replacen("https://", "http://", 1);
let endpoint = Endpoint::from_shared(http_uri)
.into_diagnostic()?
.connect_timeout(Duration::from_secs(10))
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_while_idle(true);
return endpoint
.connect_with_connector(connector)
.await
.into_diagnostic();
}
let mut endpoint = Endpoint::from_shared(server.to_string())
/// Inline mTLS channel construction for gateways that require client
/// certificates as the transport-level trust boundary. Goes away when
/// mTLS is retired as an auth method.
async fn build_legacy_mtls_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
let materials = require_tls_materials(server, tls)?;
let tls_config = build_tonic_tls_config(&materials);
let endpoint = Endpoint::from_shared(server.to_string())
.into_diagnostic()?
.connect_timeout(Duration::from_secs(10))
.http2_adaptive_window(true)
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_while_idle(true);
let tls_config = if tls.oidc_token.is_some() {
// Bearer auth over HTTPS: use mTLS certs for the transport layer when
// available (server may still require client certs), and layer the
// Bearer token on top via the interceptor.
require_tls_materials(server, tls).map_or_else(
|_| {
let resolved = tls.with_default_paths(server);
resolved
.ca
.as_ref()
.and_then(|ca_path| std::fs::read(ca_path).ok())
.map_or_else(
|| ClientTlsConfig::new().with_enabled_roots(),
|ca_pem| {
ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem))
},
)
},
|materials| build_tonic_tls_config(&materials),
)
} else if tls.edge_token.is_some() {
// Edge bearer mode — routed through tunnel above; if we reach here
// the server is not HTTPS so connect plaintext.
return endpoint.connect().await.into_diagnostic();
} else {
// Standard mTLS: private CA + client cert.
let materials = require_tls_materials(server, tls)?;
build_tonic_tls_config(&materials)
};
endpoint = endpoint.tls_config(tls_config).into_diagnostic()?;
.keep_alive_while_idle(true)
.tls_config(tls_config)
.into_diagnostic()?;
endpoint.connect().await.into_diagnostic()
}
@@ -441,7 +338,10 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result<GrpcClient> {
}
fn interceptor_from_tls(tls: &TlsOptions) -> Result<EdgeAuthInterceptor> {
EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref())
Ok(EdgeAuthInterceptor::new(
tls.oidc_token.as_deref(),
tls.edge_token.as_deref(),
)?)
}
pub async fn grpc_inference_client(server: &str, tls: &TlsOptions) -> Result<GrpcInferenceClient> {
-1
View File
@@ -10,7 +10,6 @@
//! - Build version metadata
pub mod activity;
pub mod auth;
pub mod config;
pub mod denial;
pub mod driver_mounts;
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[package]
name = "openshell-sdk"
description = "Shared async Rust client for OpenShell gateways"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
openshell-core = { path = "../openshell-core" }
async-trait = "0.1"
futures = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
miette = { workspace = true }
oauth2 = "5"
reqwest = { workspace = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-rustls = { workspace = true }
tokio-stream = { workspace = true }
tokio-tungstenite = { workspace = true }
tonic = { workspace = true, features = ["tls-native-roots"] }
tower = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[lints]
workspace = true
+85
View File
@@ -0,0 +1,85 @@
# openshell-sdk
`openshell-sdk` is the shared async Rust client for OpenShell gateways. It owns
the gRPC transport and auth stack so the CLI, the TUI, and language bindings
share one implementation of channel setup, TLS, OIDC refresh, and the
Cloudflare Access tunnel.
Designed in [RFC 0008](../../rfc/0008-shared-sdk-core-and-ts-binding/README.md).
## 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).
## Responsibilities
- Construct the gRPC channel and select the transport (plaintext vs TLS).
- Load TLS material and set up mTLS channels.
- Attach edge-auth bearer tokens and refresh OIDC tokens, with single-flight
coalescing so only one refresh is in flight at a time.
- Proxy connections through the Cloudflare Access tunnel for hosted gateways.
- Map transport and gateway failures to a typed `SdkError` with a discriminable
kind.
## Non-responsibilities
- Gateway-name resolution, default config-path lookups, and the OIDC browser
flow. These are user-facing concerns owned by `openshell-cli`; the SDK
consumes a `Refresh` trait the CLI implements.
- Reading tokens from disk. Callers pass an explicit token; the SDK performs no
filesystem access.
- Defining the gRPC contract. The protos and generated types are owned by
`openshell-core`.
## Transport and auth modes
Covers the connectivity modes the CLI exercises in production, except mTLS:
- Plaintext (local development)
- Server-authenticated TLS over HTTPS (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 intentionally out of scope; gateways that require
it continue to use `openshell-cli`'s legacy mTLS path until that auth method is
retired.
## 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.
## 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. |
## Consumers
`openshell-cli`, `openshell-tui`, and `openshell-sdk-node` (published as
`@openshell/sdk`).
## Notes
- Async-only. Tonic is async-native; callers needing a blocking call can wrap
with their own runtime.
- The surface is alpha and will grow as more RPCs graduate from `raw` into the
curated client.
@@ -1,15 +1,15 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! gRPC authentication interceptor shared by CLI and TUI.
//! Bearer-token authentication interceptor for outgoing gRPC requests.
use miette::Result;
use crate::error::{Result, SdkError};
/// Interceptor that injects authentication headers into every outgoing gRPC request.
///
/// Supports application-layer Bearer tokens (standard `authorization`
/// header) and Cloudflare Access tokens (custom headers). When no token is
/// set, acts as a no-op. OIDC takes precedence over edge tokens.
/// Supports OIDC Bearer tokens (standard `authorization` header) and
/// Cloudflare Access tokens (custom headers). When no token is set, acts
/// as a no-op. OIDC takes precedence over edge tokens.
#[derive(Clone)]
#[allow(clippy::struct_field_names)]
pub struct EdgeAuthInterceptor {
@@ -21,14 +21,14 @@ pub struct EdgeAuthInterceptor {
impl EdgeAuthInterceptor {
/// Create an interceptor from optional token strings.
///
/// OIDC bearer tokens take precedence over edge tokens. Returns a no-op
/// interceptor when no token is provided.
/// OIDC bearer token takes precedence over edge token. Returns a no-op
/// interceptor when neither token is provided.
pub fn new(oidc_token: Option<&str>, edge_token: Option<&str>) -> Result<Self> {
if let Some(token) = oidc_token {
let bearer: tonic::metadata::MetadataValue<tonic::metadata::Ascii> =
format!("Bearer {token}")
.parse()
.map_err(|_| miette::miette!("invalid bearer token value"))?;
.map_err(|_| SdkError::auth("invalid OIDC token value"))?;
return Ok(Self {
bearer_value: Some(bearer),
header_value: None,
@@ -40,11 +40,11 @@ impl EdgeAuthInterceptor {
Some(t) => {
let hv: tonic::metadata::MetadataValue<tonic::metadata::Ascii> = t
.parse()
.map_err(|_| miette::miette!("invalid edge token value"))?;
.map_err(|_| SdkError::auth("invalid edge token value"))?;
let cv: tonic::metadata::MetadataValue<tonic::metadata::Ascii> =
format!("CF_Authorization={t}")
.parse()
.map_err(|_| miette::miette!("invalid edge token value for cookie"))?;
.map_err(|_| SdkError::auth("invalid edge token value for cookie"))?;
(Some(hv), Some(cv))
}
None => (None, None),
+332
View File
@@ -0,0 +1,332 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! High-level async client over the gateway gRPC surface.
//!
//! Covers the sandbox-focused MVP slice: health, sandbox CRUD, readiness /
//! deletion waits, and non-streaming exec. Other RPCs (inference, providers,
//! policy, logs, settings, SSH, forwarding) are reachable via
//! [`OpenShellClient::raw_grpc`] / [`OpenShellClient::raw_inference`].
use crate::auth::EdgeAuthInterceptor;
use crate::config::{AuthConfig, ClientConfig};
use crate::error::{Result, SdkError};
use crate::raw::{AuthedGrpcClient, AuthedInferenceClient};
use crate::transport;
use crate::types::{
ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec,
};
use futures::StreamExt;
use openshell_core::proto;
use std::time::{Duration, Instant};
use tonic::transport::Channel;
/// Async client for a single `OpenShell` gateway.
///
/// Cheap to clone — the underlying tonic [`Channel`] multiplexes RPCs over a
/// shared HTTP/2 connection. Construct one per logical gateway and share it
/// across tasks; do not call [`OpenShellClient::connect`] per request.
#[derive(Clone)]
pub struct OpenShellClient {
channel: Channel,
interceptor: EdgeAuthInterceptor,
}
impl OpenShellClient {
/// Open a connection to the gateway described by `config`.
///
/// Performs the gRPC channel handshake immediately; subsequent RPCs reuse
/// the connection.
pub async fn connect(config: ClientConfig) -> Result<Self> {
let channel = transport::build_channel(&config).await?;
let interceptor = interceptor_from_config(&config)?;
Ok(Self {
channel,
interceptor,
})
}
/// Construct from an already-built [`Channel`] and interceptor.
///
/// Use when the caller needs to customize channel construction beyond
/// what [`ClientConfig`] exposes.
pub fn from_parts(channel: Channel, interceptor: EdgeAuthInterceptor) -> Self {
Self {
channel,
interceptor,
}
}
/// Underlying tonic [`Channel`].
pub fn channel(&self) -> Channel {
self.channel.clone()
}
/// Authenticated gRPC client for the main `OpenShell` service.
///
/// Use this when the curated surface below doesn't expose the RPC or
/// field you need.
pub fn raw_grpc(&self) -> AuthedGrpcClient {
proto::open_shell_client::OpenShellClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Authenticated gRPC client for the inference service.
pub fn raw_inference(&self) -> AuthedInferenceClient {
proto::inference_client::InferenceClient::with_interceptor(
self.channel.clone(),
self.interceptor.clone(),
)
}
/// Gateway health snapshot.
pub async fn health(&self) -> Result<Health> {
let mut grpc = self.raw_grpc();
let resp = grpc
.health(proto::HealthRequest {})
.await
.map_err(map_status)?
.into_inner();
Ok(Health {
status: resp.status.into(),
version: resp.version,
})
}
/// Create a new sandbox from a curated [`SandboxSpec`].
pub async fn create_sandbox(&self, spec: SandboxSpec) -> Result<SandboxRef> {
let request = create_sandbox_request(spec);
let mut grpc = self.raw_grpc();
let response = grpc
.create_sandbox(request)
.await
.map_err(map_status)?
.into_inner();
sandbox_from_response(response.sandbox)
}
/// Fetch a sandbox by name.
pub async fn get_sandbox(&self, name: &str) -> Result<SandboxRef> {
let mut grpc = self.raw_grpc();
let response = grpc
.get_sandbox(proto::GetSandboxRequest {
name: name.to_string(),
})
.await
.map_err(map_status)?
.into_inner();
sandbox_from_response(response.sandbox)
}
/// List sandboxes.
pub async fn list_sandboxes(&self, opts: ListOptions) -> Result<Vec<SandboxRef>> {
let mut grpc = self.raw_grpc();
let response = grpc
.list_sandboxes(proto::ListSandboxesRequest {
limit: opts.limit,
offset: opts.offset,
label_selector: opts.label_selector.unwrap_or_default(),
})
.await
.map_err(map_status)?
.into_inner();
Ok(response
.sandboxes
.into_iter()
.map(SandboxRef::from_proto)
.collect())
}
/// Delete a sandbox by name.
///
/// Returns `true` when the gateway acknowledges the deletion, `false`
/// when it was already absent. The sandbox may still be in
/// [`SandboxPhase::Deleting`] when this returns — pair with
/// [`OpenShellClient::wait_deleted`] when you need a terminal guarantee.
pub async fn delete_sandbox(&self, name: &str) -> Result<bool> {
let mut grpc = self.raw_grpc();
let response = grpc
.delete_sandbox(proto::DeleteSandboxRequest {
name: name.to_string(),
})
.await
.map_err(map_status)?
.into_inner();
Ok(response.deleted)
}
/// Poll [`OpenShellClient::get_sandbox`] until the sandbox reaches
/// [`SandboxPhase::Ready`] or the `timeout` elapses.
///
/// Returns the terminal sandbox snapshot on success. Returns an
/// [`SdkError::Connect`] when the timeout expires, or whatever error
/// the gateway returns if the sandbox transitions into
/// [`SandboxPhase::Error`].
pub async fn wait_ready(&self, name: &str, timeout: Duration) -> Result<SandboxRef> {
self.wait_for(name, timeout, |phase| match phase {
SandboxPhase::Ready => Some(Ok(())),
SandboxPhase::Error => Some(Err(SdkError::connect(format!(
"sandbox '{name}' entered error phase"
)))),
_ => None,
})
.await
}
/// Poll until the sandbox is gone (gRPC `NotFound`) or the `timeout`
/// elapses.
pub async fn wait_deleted(&self, name: &str, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(250);
loop {
match self.get_sandbox(name).await {
Err(SdkError::NotFound { .. }) => return Ok(()),
Err(other) => return Err(other),
Ok(snapshot) if snapshot.phase == SandboxPhase::Deleting => {}
Ok(_) => {}
}
if Instant::now() >= deadline {
return Err(SdkError::connect(format!(
"timed out waiting for sandbox '{name}' to delete"
)));
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
/// Run a command inside a sandbox and buffer stdout/stderr to the end.
///
/// For streaming output, drop down to [`OpenShellClient::raw_grpc`] and
/// call `exec_sandbox` directly.
pub async fn exec(&self, name: &str, cmd: &[String], opts: ExecOptions) -> Result<ExecResult> {
let sandbox = self.get_sandbox(name).await?;
let request = proto::ExecSandboxRequest {
sandbox_id: sandbox.id,
command: cmd.to_vec(),
workdir: opts.workdir.unwrap_or_default(),
environment: opts.environment,
timeout_seconds: opts
.timeout
.map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX)),
stdin: opts.stdin.unwrap_or_default(),
tty: false,
cols: 0,
rows: 0,
};
let mut grpc = self.raw_grpc();
let mut stream = grpc
.exec_sandbox(request)
.await
.map_err(map_status)?
.into_inner();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<i32> = None;
while let Some(event) = stream.next().await {
let event = event.map_err(map_status)?;
match event.payload {
Some(proto::exec_sandbox_event::Payload::Stdout(chunk)) => {
stdout.extend_from_slice(&chunk.data);
}
Some(proto::exec_sandbox_event::Payload::Stderr(chunk)) => {
stderr.extend_from_slice(&chunk.data);
}
Some(proto::exec_sandbox_event::Payload::Exit(exit)) => {
exit_code = Some(exit.exit_code);
}
None => {}
}
}
Ok(ExecResult {
exit_code: exit_code.unwrap_or(-1),
stdout,
stderr,
})
}
async fn wait_for<F>(&self, name: &str, timeout: Duration, mut decide: F) -> Result<SandboxRef>
where
F: FnMut(SandboxPhase) -> Option<Result<()>>,
{
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(250);
loop {
let snapshot = self.get_sandbox(name).await?;
if let Some(verdict) = decide(snapshot.phase) {
verdict?;
return Ok(snapshot);
}
if Instant::now() >= deadline {
return Err(SdkError::connect(format!(
"timed out waiting for sandbox '{name}'"
)));
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
}
fn interceptor_from_config(config: &ClientConfig) -> Result<EdgeAuthInterceptor> {
match &config.auth {
None => Ok(EdgeAuthInterceptor::noop()),
Some(AuthConfig::Oidc(token)) => EdgeAuthInterceptor::new(Some(token), None),
Some(AuthConfig::EdgeJwt(token)) => EdgeAuthInterceptor::new(None, Some(token)),
}
}
fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
let SandboxSpec {
name,
image,
labels,
environment,
providers,
gpu,
} = spec;
let template = image.map(|image| proto::SandboxTemplate {
image,
..proto::SandboxTemplate::default()
});
let resource_requirements = gpu.then(|| proto::ResourceRequirements {
gpu: Some(proto::GpuResourceRequirements { count: None }),
});
proto::CreateSandboxRequest {
spec: Some(proto::SandboxSpec {
environment,
template,
providers,
resource_requirements,
..proto::SandboxSpec::default()
}),
name: name.unwrap_or_default(),
labels,
}
}
fn sandbox_from_response(sandbox: Option<proto::Sandbox>) -> Result<SandboxRef> {
sandbox
.map(SandboxRef::from_proto)
.ok_or_else(|| SdkError::invalid_config("sandbox missing from gateway response"))
}
fn map_status(status: tonic::Status) -> SdkError {
let message = status.message().to_string();
match status.code() {
tonic::Code::NotFound => SdkError::NotFound { message },
tonic::Code::AlreadyExists => SdkError::AlreadyExists { message },
tonic::Code::InvalidArgument => SdkError::invalid_config(message),
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => SdkError::auth(message),
_ => SdkError::Rpc {
code: status.code() as i32,
message,
},
}
}
+81
View File
@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Public input types for the SDK: how callers describe a gateway and the
//! credentials used to talk to it.
//!
//! The CLI keeps its own filesystem-aware `TlsOptions` for plumbing; it
//! converts to a `ClientConfig` at the moment of dialing the gateway.
/// Authentication mode for outgoing gRPC requests.
///
/// The two variants are functionally distinct in the transport layer:
/// `EdgeJwt` routes through a local WebSocket tunnel (the only way to get a
/// browser-flow JWT past Cloudflare Access on POST/HTTP2), while `Oidc`
/// connects directly over HTTPS and adds an `authorization: Bearer ...`
/// header.
//
// `#[non_exhaustive]` keeps phase 2 additive: when we promote `Oidc(String)`
// to `Oidc { token, refresh: Option<Arc<dyn Refresh>> }` or add a third
// variant, downstream `match` arms aren't forced to break.
#[derive(Clone)]
#[non_exhaustive]
pub enum AuthConfig {
/// Cloudflare Access JWT — routes through the edge WebSocket tunnel.
EdgeJwt(String),
/// OIDC bearer token — direct HTTPS, `authorization` header.
Oidc(String),
}
/// Configuration for opening a gRPC channel to an `OpenShell` gateway.
///
/// Consumed by `openshell_sdk::transport::grpc_client` and the
/// inference-client equivalent. One `ClientConfig` per logical connection;
/// callers that want connection pooling cache the resulting `tonic::Channel`.
//
// NOTE:
// - `gateway` is a full URL (`http://...` or `https://...`) so the scheme
// tells the transport layer whether to use plaintext or TLS. Matches
// today's CLI convention; matches the RFC's `pub gateway: String`.
// - `ca_cert` pins a private-CA certificate (PEM-encoded). `None` falls
// back to the platform's system roots.
// - This SDK does not speak mTLS. Gateways requiring client certificates
// are handled by `openshell-cli`'s legacy mTLS path until product
// retires that auth method.
// - `insecure_skip_verify` is a separate flag rather than a third
// `AuthConfig` variant because it's a transport concern (cert
// verification) that's orthogonal to auth.
// - No `timeout` field yet. The RFC mentions one but today's behavior is
// `connect_timeout(10s)` hard-coded; introducing a configurable timeout
// here would be a behavior change. Phase 2 territory.
// - No `Debug` derive: `auth` carries secrets; `ca_cert` is fine but we
// redact the whole struct for safety. If callers want ergonomic printing
// we can implement `Debug` manually with a redacted token field.
// - `#[non_exhaustive]` + `Default` lets phase 2 add fields (timeout, retry
// policy, `Refresh` trait) without breaking literal-construct callers.
// Idiom is `ClientConfig { gateway: g, ..Default::default() }`.
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct ClientConfig {
/// Gateway URL, e.g. `http://127.0.0.1:8080` or `https://gw.example.com`.
pub gateway: String,
/// CA certificate (PEM) for private-CA gateways. `None` uses system
/// roots. Ignored for plaintext gateways and when
/// `insecure_skip_verify` is enabled.
pub ca_cert: Option<Vec<u8>>,
/// Bearer-token auth mode. `None` = anonymous TLS over HTTPS, or
/// plaintext when `gateway` is `http://`.
pub auth: Option<AuthConfig>,
/// Disable TLS certificate verification (development/debug only).
/// Ignored for plaintext gateways. **Do not enable in production.**
pub insecure_skip_verify: bool,
}
impl ClientConfig {
pub fn new(gateway: impl Into<String>) -> Self {
Self {
gateway: gateway.into(),
..Default::default()
}
}
}
@@ -19,13 +19,13 @@
//! 3. Bidirectionally pipe bytes between the local TCP stream and the
//! WebSocket.
//!
//! The gRPC [`Channel`] then connects to `http://127.0.0.1:<local_port>`
//! The gRPC `Channel` then connects to `http://127.0.0.1:<local_port>`
//! (plaintext) — the edge handles TLS, and the WebSocket carries the raw
//! bytes to the origin.
use crate::error::{Result, SdkError};
use futures::stream::{SplitSink, SplitStream};
use futures::{SinkExt, StreamExt};
use miette::{IntoDiagnostic, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -63,8 +63,8 @@ pub async fn start_tunnel_proxy(
gateway_endpoint: &str,
edge_token: &str,
) -> Result<EdgeTunnelProxy> {
let listener = TcpListener::bind("127.0.0.1:0").await.into_diagnostic()?;
let local_addr = listener.local_addr().into_diagnostic()?;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let local_addr = listener.local_addr()?;
// Convert the gateway endpoint to a WebSocket URL.
// https://foo.com -> wss://foo.com
@@ -88,7 +88,6 @@ pub async fn start_tunnel_proxy(
"starting edge tunnel proxy"
);
// Spawn the accept loop.
tokio::spawn(accept_loop(listener, config));
Ok(EdgeTunnelProxy { local_addr })
@@ -149,13 +148,15 @@ async fn handle_connection(tcp_stream: TcpStream, config: &TunnelConfig) -> Resu
/// Open a WebSocket connection to the edge proxy.
async fn open_ws(config: &TunnelConfig) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>> {
let mut request = (&config.ws_url).into_client_request().into_diagnostic()?;
let mut request = (&config.ws_url)
.into_client_request()
.map_err(|e| SdkError::invalid_config(format!("invalid tunnel URL: {e}")))?;
// Inject the bearer token via multiple headers for compatibility with
// Cloudflare Access (which checks `Cf-Access-Token`, the
// `CF_Authorization` cookie, and the `Cf-Access-Jwt-Assertion` header).
let token_val = HeaderValue::from_str(&config.edge_token)
.map_err(|e| miette::miette!("invalid edge token header value: {e}"))?;
.map_err(|e| SdkError::auth(format!("invalid edge token header value: {e}")))?;
request
.headers_mut()
.insert("Cf-Access-Token", token_val.clone());
@@ -165,14 +166,14 @@ async fn open_ws(config: &TunnelConfig) -> Result<WebSocketStream<MaybeTlsStream
request.headers_mut().insert(
"Cookie",
HeaderValue::from_str(&format!("CF_Authorization={}", config.edge_token))
.map_err(|e| miette::miette!("invalid edge token cookie value: {e}"))?,
.map_err(|e| SdkError::auth(format!("invalid edge token cookie value: {e}")))?,
);
debug!(url = %config.ws_url, "opening WebSocket to edge");
let (ws_stream, response) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| miette::miette!("WebSocket connect failed: {e}"))?;
.map_err(|e| SdkError::connect(format!("WebSocket connect failed: {e}")))?;
debug!(
status = %response.status(),
+138
View File
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! SDK error type. Surfaces a discriminable variant set so consumers (CLI,
//! TUI, language bindings) can decide how to render or remap each kind.
use miette::Diagnostic;
use thiserror::Error;
/// SDK result type alias.
pub type Result<T> = std::result::Result<T, SdkError>;
/// Errors produced by `openshell-sdk`.
///
/// CLI consumers convert these to `miette::Report` at the call boundary;
/// future TS/Python bindings will map them to language-native exceptions
/// via the [`SdkError::code`] accessor.
#[derive(Debug, Error, Diagnostic)]
pub enum SdkError {
/// Caller-supplied configuration is invalid (URL parse, missing field,
/// illegal token characters).
#[error("invalid configuration: {message}")]
#[diagnostic(code(openshell::sdk::invalid_config))]
InvalidConfig {
/// Error message.
message: String,
},
/// TLS material parse or rustls config build failure.
#[error("TLS error: {message}")]
#[diagnostic(code(openshell::sdk::tls))]
Tls {
/// Error message.
message: String,
},
/// Failed to establish a connection to the gateway (TCP, TLS handshake,
/// HTTP/2, WebSocket upgrade).
#[error("connect error: {message}")]
#[diagnostic(code(openshell::sdk::connect))]
Connect {
/// Error message.
message: String,
},
/// Auth-related failure: OIDC discovery / refresh, token format invalid
/// for header injection.
#[error("auth error: {message}")]
#[diagnostic(code(openshell::sdk::auth))]
Auth {
/// Error message.
message: String,
},
/// Local IO failure (file read, listener bind, socket).
#[error("I/O error: {source}")]
#[diagnostic(code(openshell::sdk::io))]
Io {
/// Underlying I/O error.
#[from]
source: std::io::Error,
},
/// Gateway reported the requested object does not exist (gRPC `NotFound`).
#[error("not found: {message}")]
#[diagnostic(code(openshell::sdk::not_found))]
NotFound {
/// Error message.
message: String,
},
/// Gateway reported the requested object already exists (gRPC `AlreadyExists`).
#[error("already exists: {message}")]
#[diagnostic(code(openshell::sdk::already_exists))]
AlreadyExists {
/// Error message.
message: String,
},
/// Catch-all for gRPC errors not mapped to a more specific variant.
#[error("gateway error ({code}): {message}")]
#[diagnostic(code(openshell::sdk::rpc))]
Rpc {
/// Numeric gRPC status code (see [`tonic::Code`]).
code: i32,
/// Error message.
message: String,
},
}
impl SdkError {
/// Create an `InvalidConfig` error.
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::InvalidConfig {
message: message.into(),
}
}
/// Create a `Tls` error.
pub fn tls(message: impl Into<String>) -> Self {
Self::Tls {
message: message.into(),
}
}
/// Create a `Connect` error.
pub fn connect(message: impl Into<String>) -> Self {
Self::Connect {
message: message.into(),
}
}
/// Create an `Auth` error.
pub fn auth(message: impl Into<String>) -> Self {
Self::Auth {
message: message.into(),
}
}
/// Stable string code for cross-language binding consumers.
///
/// Returns one of: `invalid_config`, `tls`, `connect`, `auth`, `io`,
/// `not_found`, `already_exists`, `rpc`. Phase 3 (napi binding) will
/// surface this as the JS error's `code` field for discriminated-union
/// ergonomics.
pub const fn code(&self) -> &'static str {
match self {
Self::InvalidConfig { .. } => "invalid_config",
Self::Tls { .. } => "tls",
Self::Connect { .. } => "connect",
Self::Auth { .. } => "auth",
Self::Io { .. } => "io",
Self::NotFound { .. } => "not_found",
Self::AlreadyExists { .. } => "already_exists",
Self::Rpc { .. } => "rpc",
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Shared async Rust client for `OpenShell` gateways.
//!
//! Two layers:
//!
//! - [`OpenShellClient`] — the high-level sandbox-focused MVP surface:
//! health, sandbox CRUD, readiness/deletion waits, 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).
//!
//! Owns the gRPC transport stack — channel construction, TLS material
//! handling, request interceptors, OIDC token refresh, and the Cloudflare
//! Access tunnel proxy. Consumed by `openshell-cli`, `openshell-tui`, and
//! the napi-rs wrapper that ships as `@openshell/sdk`.
//!
//! # Quick start
//!
//! ```ignore
//! use openshell_sdk::{ClientConfig, ListOptions, OpenShellClient};
//!
//! # async fn run() -> Result<(), openshell_sdk::SdkError> {
//! let client = OpenShellClient::connect(ClientConfig::new("http://127.0.0.1:8080")).await?;
//! let health = client.health().await?;
//! let sandboxes = client.list_sandboxes(ListOptions::default()).await?;
//! # Ok(())
//! # }
//! ```
pub mod auth;
pub mod client;
pub mod config;
pub mod edge_tunnel;
pub mod error;
pub mod oidc;
pub mod raw;
pub mod refresh;
pub mod transport;
pub mod types;
pub use auth::EdgeAuthInterceptor;
pub use client::OpenShellClient;
pub use config::{AuthConfig, ClientConfig};
pub use error::SdkError;
pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource};
pub use types::{
ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxSpec,
ServiceStatus,
};
+146
View File
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! OIDC discovery and refresh-token flow (non-interactive).
//!
//! Browser-based authorization flows live in `openshell-cli` since they
//! require a local callback HTTP server and an OS browser launcher.
use crate::error::{Result, SdkError};
use oauth2::basic::BasicClient;
use oauth2::{ClientId, RefreshToken, TokenResponse, TokenUrl};
use serde::Deserialize;
/// OIDC discovery document (subset of fields callers consume).
#[derive(Debug, Deserialize)]
#[non_exhaustive]
pub struct OidcDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
}
/// Input to [`refresh_token`].
///
/// Constructed by the caller from whatever bundle / storage shape they
/// use — the SDK does not assume any particular persistence model.
#[derive(Clone)]
#[non_exhaustive]
pub struct RefreshTokenInput {
pub refresh_token: String,
pub issuer: String,
pub client_id: String,
pub insecure: bool,
}
impl RefreshTokenInput {
pub fn new(
refresh_token: impl Into<String>,
issuer: impl Into<String>,
client_id: impl Into<String>,
) -> Self {
Self {
refresh_token: refresh_token.into(),
issuer: issuer.into(),
client_id: client_id.into(),
insecure: false,
}
}
#[must_use]
pub fn with_insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
}
/// Output from [`refresh_token`].
///
/// `refresh_token` is `None` when the OIDC server did not return a new
/// refresh token; per OAuth 2.0, callers should preserve the previous
/// refresh token in that case. `expires_at` is a Unix timestamp (seconds
/// since epoch); `None` when the server omits `expires_in`.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct RefreshTokenOutput {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<u64>,
}
/// Discover OIDC endpoints from the issuer's well-known configuration.
///
/// Validates that the discovery document's `issuer` field matches the
/// configured issuer URL to prevent SSRF or misdirection. When `insecure`
/// is true, TLS certificate verification is disabled (intended for
/// development against self-signed gateways).
pub async fn discover(issuer: &str, insecure: bool) -> Result<OidcDiscovery> {
let normalized_issuer = issuer.trim_end_matches('/');
let url = format!("{normalized_issuer}/.well-known/openid-configuration");
let client = http_client(insecure);
let resp: OidcDiscovery = client
.get(&url)
.send()
.await
.map_err(|e| SdkError::auth(format!("OIDC discovery request failed: {e}")))?
.json()
.await
.map_err(|e| SdkError::auth(format!("OIDC discovery JSON parse failed: {e}")))?;
let discovered_issuer = resp.issuer.trim_end_matches('/');
if discovered_issuer != normalized_issuer {
return Err(SdkError::auth(format!(
"OIDC discovery issuer mismatch: expected '{normalized_issuer}', got '{discovered_issuer}'"
)));
}
Ok(resp)
}
/// Build an HTTP client suitable for OIDC token-endpoint requests.
///
/// Disables redirects so token-endpoint responses aren't accidentally
/// followed; OIDC providers should not redirect on the token endpoint.
/// When `insecure` is true, TLS certificate verification is disabled.
pub fn http_client(insecure: bool) -> reqwest::Client {
let mut builder = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none());
if insecure {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().expect("failed to build HTTP client")
}
/// Refresh an OIDC access token using the `refresh_token` grant.
///
/// The caller is responsible for preserving the prior refresh token when
/// the output's `refresh_token` is `None` — per OAuth 2.0 the server may
/// omit it from the refresh response.
pub async fn refresh_token(input: &RefreshTokenInput) -> Result<RefreshTokenOutput> {
let discovery = discover(&input.issuer, input.insecure).await?;
let client = BasicClient::new(ClientId::new(input.client_id.clone())).set_token_uri(
TokenUrl::new(discovery.token_endpoint)
.map_err(|e| SdkError::auth(format!("invalid token endpoint URL: {e}")))?,
);
let http = http_client(input.insecure);
let token_response = client
.exchange_refresh_token(&RefreshToken::new(input.refresh_token.clone()))
.request_async(&http)
.await
.map_err(|e| SdkError::auth(format!("token refresh failed: {e}")))?;
Ok(output_from_oauth2_response(&token_response))
}
fn output_from_oauth2_response(resp: &oauth2::basic::BasicTokenResponse) -> RefreshTokenOutput {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
RefreshTokenOutput {
access_token: resp.access_token().secret().clone(),
refresh_token: resp.refresh_token().map(|rt| rt.secret().clone()),
expires_at: resp.expires_in().map(|ei| now + ei.as_secs()),
}
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Escape hatch — direct access to the generated tonic clients and protobuf
//! types.
//!
//! Use this module when the curated high-level surface in
//! [`crate::client::OpenShellClient`] doesn't expose the RPC or field you
//! need. The high-level surface is sandbox-focused for MVP; inference,
//! providers, policy, logs, settings, SSH, and forwarding all live here.
//!
//! ```ignore
//! use openshell_sdk::{ClientConfig, OpenShellClient};
//! use openshell_sdk::raw::ListProvidersRequest;
//!
//! let client = OpenShellClient::connect(ClientConfig::new("http://127.0.0.1:8080")).await?;
//! let mut grpc = client.raw_grpc();
//! let providers = grpc.list_providers(ListProvidersRequest::default()).await?;
//! ```
pub use openshell_core::proto;
pub use openshell_core::proto::inference_client::InferenceClient;
pub use openshell_core::proto::open_shell_client::OpenShellClient as GrpcClient;
pub use openshell_core::proto::{
CreateSandboxRequest, DeleteSandboxRequest, ExecSandboxRequest, GetSandboxRequest,
HealthRequest, ListProvidersRequest, ListSandboxesRequest, Sandbox,
SandboxPhase as ProtoSandboxPhase, SandboxSpec as ProtoSandboxSpec, SandboxTemplate,
ServiceStatus as ProtoServiceStatus,
};
/// Type alias for the gRPC client wrapped in the SDK's auth interceptor.
pub type AuthedGrpcClient = GrpcClient<
tonic::service::interceptor::InterceptedService<
tonic::transport::Channel,
crate::EdgeAuthInterceptor,
>,
>;
/// Type alias for the inference client wrapped in the SDK's auth interceptor.
pub type AuthedInferenceClient = InferenceClient<
tonic::service::interceptor::InterceptedService<
tonic::transport::Channel,
crate::EdgeAuthInterceptor,
>,
>;
+301
View File
@@ -0,0 +1,301 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! OIDC bearer-token refresh contract.
//!
//! The SDK never talks to a browser or any specific `IdP`. Callers that need
//! the SDK to rotate an OIDC bearer mid-session implement [`Refresh`] and
//! construct a [`TokenSource`] around it. Implementations live where the
//! browser flow / token store / FFI callback belongs — in `openshell-cli`
//! for the desktop browser flow, in `openshell-sdk-node` for a JS callback.
//!
//! The trait is intentionally minimal. Single-flight coalescing (one refresh
//! in flight at a time, with all waiters sharing the result) is the SDK's
//! responsibility, not the implementer's; see [`TokenSource`].
//!
//! TODO(rfc-0004): plumb [`TokenSource`] into the gRPC auth interceptor so
//! refreshes happen automatically before each request. Today the napi
//! binding exposes [`TokenSource::refresh_now`] / [`TokenSource::current`]
//! directly to JS callers, which can rotate the token by calling
//! `set_oidc_token` on a future iteration of the SDK client.
use crate::error::{Result, SdkError};
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, RwLock};
/// Errors a refresher can return.
///
/// Domain-specific, deliberately not coupled to `tonic`, `napi`, or any
/// FFI-facing error type. The SDK maps these into [`SdkError::Auth`] before
/// surfacing to callers.
#[derive(Debug)]
#[non_exhaustive]
pub enum RefreshError {
/// Refresh failed but a retry might succeed (network blip, transient
/// `IdP` error).
Transient(String),
/// Refresh cannot succeed without user interaction (refresh token
/// expired, `IdP` revoked the session). Callers should not retry; they
/// should re-authenticate.
Terminal(String),
}
impl fmt::Display for RefreshError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transient(msg) => write!(f, "transient refresh error: {msg}"),
Self::Terminal(msg) => write!(f, "terminal refresh error: {msg}"),
}
}
}
impl std::error::Error for RefreshError {}
impl From<RefreshError> for SdkError {
fn from(value: RefreshError) -> Self {
Self::auth(value.to_string())
}
}
/// A freshly minted access token + its absolute expiry.
///
/// `expires_at` is seconds since the Unix epoch. `None` means the token's
/// expiry was not advertised — the SDK will not refresh it proactively but
/// may refresh on demand if [`Refresh::refresh`] is called.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct RefreshedToken {
pub access_token: String,
pub expires_at: Option<u64>,
}
impl RefreshedToken {
pub fn new(access_token: impl Into<String>) -> Self {
Self {
access_token: access_token.into(),
expires_at: None,
}
}
#[must_use]
pub fn with_expires_at(mut self, expires_at: u64) -> Self {
self.expires_at = Some(expires_at);
self
}
}
/// Pluggable OIDC refresher.
///
/// Implementations should be cheap to clone and safe to call from any tokio
/// task. They MUST NOT do their own single-flight coalescing — that's the
/// SDK's job (see [`TokenSource`]).
#[async_trait::async_trait]
pub trait Refresh: Send + Sync + 'static {
/// Mint a fresh access token. Called by the SDK when it determines the
/// current token is near expiry (or has been explicitly invalidated).
async fn refresh(&self) -> std::result::Result<RefreshedToken, RefreshError>;
}
/// Mutable token state shared between the auth interceptor and the
/// background refresh task.
#[derive(Debug)]
struct TokenState {
token: String,
expires_at: Option<u64>,
}
/// A bearer-token source with single-flight refresh coalescing.
///
/// Wraps a [`Refresh`] implementation and tracks the current token + its
/// advertised expiry. Phase 3 of the RFC plumbs this into the auth path; for
/// now language bindings hand it out directly so JS/Python code can drive
/// refreshes externally.
#[derive(Clone)]
pub struct TokenSource {
state: Arc<RwLock<TokenState>>,
refresher: Arc<dyn Refresh>,
in_flight: Arc<Mutex<()>>,
/// Refresh `skew` seconds before the advertised `expires_at`. Tokens
/// without `expires_at` are not auto-refreshed.
skew: Duration,
}
impl TokenSource {
/// Construct a token source backed by `refresher`. Use this when wiring
/// an FFI callback or browser flow into the SDK.
pub fn new(initial: RefreshedToken, refresher: Arc<dyn Refresh>) -> Self {
Self {
state: Arc::new(RwLock::new(TokenState {
token: initial.access_token,
expires_at: initial.expires_at,
})),
refresher,
in_flight: Arc::new(Mutex::new(())),
skew: Duration::from_secs(60),
}
}
/// Current token without checking expiry. Used by the sync gRPC
/// interceptor, which can't await.
pub fn snapshot(&self) -> String {
self.state
.try_read()
.map(|s| s.token.clone())
.unwrap_or_default()
}
/// Async-fetch the current token, refreshing if it's within `skew` of
/// expiry. Single-flight: concurrent callers share one refresh.
pub async fn current(&self) -> Result<String> {
if !self.needs_refresh().await {
return Ok(self.state.read().await.token.clone());
}
self.refresh_now().await
}
/// Force a refresh regardless of expiry. Used on `Unauthenticated`
/// responses from the gateway.
pub async fn refresh_now(&self) -> Result<String> {
// Single-flight: only one refresh in flight at a time. Other waiters
// block here and then see the updated state on re-check.
let _guard = self.in_flight.lock().await;
// Re-check inside the critical section: another caller may have just
// refreshed while we were waiting on the lock.
if !self.needs_refresh().await {
return Ok(self.state.read().await.token.clone());
}
let refreshed = self.refresher.refresh().await?;
let mut state = self.state.write().await;
state.token.clone_from(&refreshed.access_token);
state.expires_at = refreshed.expires_at;
Ok(refreshed.access_token)
}
async fn needs_refresh(&self) -> bool {
let state = self.state.read().await;
let Some(expires_at) = state.expires_at else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now + self.skew.as_secs() >= expires_at
}
/// Replace the current token without invoking the refresher.
///
/// Used by callers that manage refresh externally (e.g. the napi
/// binding's JS-side timer) or for testing.
pub async fn replace(&self, token: RefreshedToken) {
let mut state = self.state.write().await;
state.token = token.access_token;
state.expires_at = token.expires_at;
}
}
impl fmt::Debug for TokenSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenSource")
.field("skew", &self.skew)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingRefresher {
calls: Arc<AtomicUsize>,
delay: Duration,
}
#[async_trait::async_trait]
impl Refresh for CountingRefresher {
async fn refresh(&self) -> std::result::Result<RefreshedToken, RefreshError> {
tokio::time::sleep(self.delay).await;
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
Ok(RefreshedToken::new(format!("token-{n}")).with_expires_at(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600,
))
}
}
#[tokio::test]
async fn refresh_now_coalesces_concurrent_callers() {
let calls = Arc::new(AtomicUsize::new(0));
let refresher = Arc::new(CountingRefresher {
calls: Arc::clone(&calls),
delay: Duration::from_millis(50),
});
let source = TokenSource::new(RefreshedToken::new("initial").with_expires_at(0), refresher);
let tasks = (0..5).map(|_| {
let src = source.clone();
tokio::spawn(async move { src.refresh_now().await })
});
for t in tasks {
t.await.unwrap().unwrap();
}
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"single-flight should have collapsed 5 concurrent calls into 1 refresh"
);
}
#[tokio::test]
async fn current_returns_cached_when_not_near_expiry() {
let calls = Arc::new(AtomicUsize::new(0));
let refresher = Arc::new(CountingRefresher {
calls: Arc::clone(&calls),
delay: Duration::from_millis(0),
});
let future = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let source = TokenSource::new(
RefreshedToken::new("fresh").with_expires_at(future),
refresher,
);
let token = source.current().await.unwrap();
assert_eq!(token, "fresh");
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn current_refreshes_when_within_skew() {
let calls = Arc::new(AtomicUsize::new(0));
let refresher = Arc::new(CountingRefresher {
calls: Arc::clone(&calls),
delay: Duration::from_millis(0),
});
let near = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 5;
let source = TokenSource::new(
RefreshedToken::new("stale").with_expires_at(near),
refresher,
);
let token = source.current().await.unwrap();
assert_eq!(token, "token-1");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}
+253
View File
@@ -0,0 +1,253 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! gRPC transport stack: channel construction and the insecure-TLS connector.
//!
//! mTLS is intentionally out of scope here. Gateways that require client
//! certificates are handled by `openshell-cli`'s legacy path until the auth
//! method is retired.
use crate::config::{AuthConfig, ClientConfig};
use crate::edge_tunnel;
use crate::error::{Result, SdkError};
use rustls::{
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{CertificateDer, ServerName, UnixTime},
};
use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::Mutex;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
use tracing::debug;
/// Standard endpoint settings used by every dialed connection.
///
/// Centralizes timeouts and HTTP/2 keepalive so behavior is consistent across
/// transport branches. Returns an `Endpoint` ready for `connect()` /
/// `connect_with_connector()`.
fn standard_endpoint(uri: String) -> Result<Endpoint> {
Endpoint::from_shared(uri)
.map(|ep| {
ep.connect_timeout(Duration::from_secs(10))
.http2_adaptive_window(true)
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_while_idle(true)
})
.map_err(|e| SdkError::invalid_config(format!("invalid gateway URL: {e}")))
}
// ── Edge tunnel registry ─────────────────────────────────────────────
// Each distinct edge-authenticated gateway gets its own local proxy
// instead of reusing the first gateway touched in the current process.
static EDGE_TUNNEL_ADDRS: OnceLock<Mutex<HashMap<(String, String), SocketAddr>>> = OnceLock::new();
/// Look up (or start) the local tunnel proxy for an edge-authenticated
/// gateway. Subsequent calls with the same `(server, token)` reuse the
/// existing proxy.
async fn edge_tunnel_addr(server: &str, token: &str) -> Result<SocketAddr> {
let key = (server.to_string(), token.to_string());
let registry = EDGE_TUNNEL_ADDRS.get_or_init(|| Mutex::new(HashMap::new()));
{
let addrs = registry.lock().await;
if let Some(addr) = addrs.get(&key).copied() {
return Ok(addr);
}
}
let proxy = edge_tunnel::start_tunnel_proxy(server, token).await?;
debug!(
local_addr = %proxy.local_addr,
server,
"edge tunnel proxy started, routing gRPC through local proxy"
);
let mut addrs = registry.lock().await;
Ok(*addrs.entry(key).or_insert(proxy.local_addr))
}
// ── Channel construction ─────────────────────────────────────────────
/// Open a gRPC channel to the gateway described by `config`.
///
/// Routing is determined by `gateway` scheme + `auth` variant +
/// `insecure_skip_verify`. Reference today's CLI implementation in
/// `openshell-cli/src/tls.rs::build_channel` (lines 219–308) for behavior
/// the SDK needs to preserve.
///
/// **Branch table:**
///
/// | `gateway` scheme | `auth` | `insecure_skip_verify` | TLS handling |
/// |------------------|--------|------------------------|-------------|
/// | `http://` | (any) | (any) | plaintext, ignore tls |
/// | `https://` | `Some(EdgeJwt)` | (any) | tunnel proxy + plaintext to local proxy |
/// | `https://` | (any) | `true` | `InsecureTlsConnector`, no verification |
/// | `https://` | `Some(Oidc)` or `None` | `false` | tonic TLS, pin `ca_cert` if set, system roots otherwise |
pub async fn build_channel(config: &ClientConfig) -> Result<Channel> {
let gateway = &config.gateway;
// Branch 1 — plaintext.
// Reference: cli/tls.rs:220-228 (http:// branch).
if gateway.starts_with("http://") {
return standard_endpoint(gateway.clone())?
.connect()
.await
.map_err(|e| SdkError::connect(format!("{e}")));
}
if !gateway.starts_with("https://") {
return Err(SdkError::invalid_config(format!(
"gateway URL must start with http:// or https://: {gateway}"
)));
}
// Branch 2 — Cloudflare Access edge JWT: tunnel proxy + plaintext-to-local.
// Reference: cli/tls.rs:233-249 (https:// + edge_token branch). Use
// `edge_tunnel_addr(gateway, token).await?` to get the local proxy
// address, then `standard_endpoint(format!("http://{local_addr}"))?.connect()`.
if let Some(AuthConfig::EdgeJwt(token)) = &config.auth {
let local_addr = edge_tunnel_addr(gateway, token).await?;
return standard_endpoint(format!("http://{local_addr}"))?
.connect()
.await
.map_err(|e| SdkError::connect(format!("{e}")));
}
// Branch 3 — insecure TLS (skip cert verification).
// Reference: cli/tls.rs:251-268 (gateway_insecure branch). Build the
// insecure rustls config, wrap it in `InsecureTlsConnector`, swap the
// gateway scheme to http:// (so tonic doesn't double-layer TLS), and
// call `endpoint.connect_with_connector(connector)`.
if config.insecure_skip_verify {
tracing::warn!("TLS certificate verification is disabled — do not use in production");
let rustls_config = build_insecure_rustls_config()?;
let tls_connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(rustls_config));
let connector = InsecureTlsConnector { tls_connector };
let http_uri = gateway.replacen("https://", "http://", 1);
return standard_endpoint(http_uri)?
.connect_with_connector(connector)
.await
.map_err(|e| SdkError::connect(format!("{e}")));
}
// Branch 4 — anonymous TLS or OIDC bearer over HTTPS.
// Reference: cli/tls.rs:270-307 (the `oidc_token` and final mTLS
// branches collapsed). Build a `ClientTlsConfig`:
// - if `config.ca_cert` is `Some(pem)`, pin it via `.ca_certificate(...)`
// - else fall back to `.with_enabled_roots()` (system roots)
// Then `endpoint.tls_config(tls_config)?.connect()`.
//
// The OIDC bearer header is added by the gRPC interceptor at request
// time, not here — `build_channel` only owns the TLS layer.
let tls_config = config.ca_cert.as_ref().map_or_else(
|| ClientTlsConfig::new().with_enabled_roots(),
|ca_cert| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_cert)),
);
standard_endpoint(gateway.clone())?
.tls_config(tls_config)
.map_err(|e| SdkError::tls(format!("{e}")))?
.connect()
.await
.map_err(|e| SdkError::connect(format!("{e}")))
}
/// rustls verifier that accepts any server certificate.
///
/// Used only when the caller explicitly opts into
/// [`ClientConfig::insecure_skip_verify`]. Do not use in production.
///
/// [`ClientConfig::insecure_skip_verify`]: crate::config::ClientConfig::insecure_skip_verify
#[derive(Debug)]
pub struct InsecureServerCertVerifier;
impl ServerCertVerifier for InsecureServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// rustls client config that disables server certificate verification.
///
/// Pairs with [`InsecureTlsConnector`] for transports that need to skip
/// verification (development, debug). Returns `Result` for symmetry with
/// future verifying variants; the current implementation cannot fail.
pub fn build_insecure_rustls_config() -> Result<rustls::ClientConfig> {
Ok(rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureServerCertVerifier))
.with_no_client_auth())
}
/// `tower::Service` connector that performs TLS using the supplied rustls
/// connector, bypassing tonic's built-in TLS layering.
///
/// Used to plumb [`InsecureServerCertVerifier`]-backed configs into a tonic
/// `Endpoint` via `connect_with_connector`.
#[derive(Clone)]
pub struct InsecureTlsConnector {
/// Inner rustls connector configured by the caller.
pub tls_connector: tokio_rustls::TlsConnector,
}
impl tower::Service<hyper::Uri> for InsecureTlsConnector {
type Response = hyper_util::rt::TokioIo<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Future = std::pin::Pin<
Box<dyn Future<Output = std::result::Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, uri: hyper::Uri) -> Self::Future {
let tls_connector = self.tls_connector.clone();
Box::pin(async move {
let host = uri.host().unwrap_or("localhost").to_string();
let port = uri.port_u16().unwrap_or(443);
let addr = format!("{host}:{port}");
let tcp = tokio::net::TcpStream::connect(addr).await?;
let server_name = ServerName::try_from(host)?;
let tls_stream = tls_connector.connect(server_name, tcp).await?;
Ok(hyper_util::rt::TokioIo::new(tls_stream))
})
}
}
+167
View File
@@ -0,0 +1,167 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Curated public types for the high-level SDK surface.
//!
//! These types intentionally diverge from the raw protobuf shapes so future
//! language bindings (TypeScript via napi, Python via `PyO3`) can render them
//! idiomatically. In particular, enum-valued fields use Rust enums that map
//! to string literals in TypeScript rather than numeric proto enums; nested
//! `Option<...>` chains from proto are flattened where one of the wrappers
//! is structurally meaningless.
//!
//! The raw proto clients are still accessible via [`crate::raw`] as an
//! escape hatch for callers who need fields not exposed here.
use openshell_core::proto;
use std::collections::HashMap;
use std::time::Duration;
/// Gateway health snapshot.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Health {
pub status: ServiceStatus,
pub version: String,
}
/// Coarse gateway service status.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ServiceStatus {
Unspecified,
Healthy,
Degraded,
Unhealthy,
}
impl From<proto::ServiceStatus> for ServiceStatus {
fn from(value: proto::ServiceStatus) -> Self {
match value {
proto::ServiceStatus::Healthy => Self::Healthy,
proto::ServiceStatus::Degraded => Self::Degraded,
proto::ServiceStatus::Unhealthy => Self::Unhealthy,
proto::ServiceStatus::Unspecified => Self::Unspecified,
}
}
}
impl From<i32> for ServiceStatus {
fn from(value: i32) -> Self {
proto::ServiceStatus::try_from(value).map_or(Self::Unspecified, Self::from)
}
}
/// High-level sandbox lifecycle phase.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SandboxPhase {
Unspecified,
Provisioning,
Ready,
Error,
Deleting,
Unknown,
}
impl From<proto::SandboxPhase> for SandboxPhase {
fn from(value: proto::SandboxPhase) -> Self {
match value {
proto::SandboxPhase::Unspecified => Self::Unspecified,
proto::SandboxPhase::Provisioning => Self::Provisioning,
proto::SandboxPhase::Ready => Self::Ready,
proto::SandboxPhase::Error => Self::Error,
proto::SandboxPhase::Deleting => Self::Deleting,
proto::SandboxPhase::Unknown => Self::Unknown,
}
}
}
impl From<i32> for SandboxPhase {
fn from(value: i32) -> Self {
proto::SandboxPhase::try_from(value).map_or(Self::Unspecified, Self::from)
}
}
/// Caller intent for a new sandbox.
///
/// Only the most commonly used fields are exposed. Callers that need the
/// full proto surface (volume claim templates, runtime classes, struct
/// resources, etc.) should drop down to [`crate::raw`].
#[derive(Clone, Debug, Default)]
pub struct SandboxSpec {
/// Optional user-supplied sandbox name. When empty the server generates one.
pub name: Option<String>,
/// Container image reference (e.g. `ghcr.io/nvidia/openshell-community/sandboxes/python:latest`).
pub image: Option<String>,
/// Labels attached to the sandbox.
pub labels: HashMap<String, String>,
/// Environment variables injected into the sandbox runtime.
pub environment: HashMap<String, String>,
/// Provider names to attach.
pub providers: Vec<String>,
/// Request a GPU. Driver-specific device selection is configured via
/// driver config on the raw proto surface (see [`crate::raw`]).
pub gpu: bool,
}
/// Reference to a sandbox owned by the gateway.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct SandboxRef {
pub id: String,
pub name: String,
pub phase: SandboxPhase,
pub labels: HashMap<String, String>,
pub resource_version: u64,
}
impl SandboxRef {
pub(crate) fn from_proto(sandbox: proto::Sandbox) -> Self {
let phase = sandbox.phase().into();
let meta = sandbox.metadata.unwrap_or_default();
Self {
id: meta.id,
name: meta.name,
phase,
labels: meta.labels,
resource_version: meta.resource_version,
}
}
}
/// Options for listing sandboxes.
#[derive(Clone, Debug, Default)]
pub struct ListOptions {
/// Maximum sandboxes to return. `0` defers to the server default.
pub limit: u32,
/// Offset into the result list.
pub offset: u32,
/// Optional Kubernetes-style label selector (e.g. `env=prod,team=core`).
pub label_selector: Option<String>,
}
/// Options for [`crate::client::OpenShellClient::exec`].
#[derive(Clone, Debug, Default)]
pub struct ExecOptions {
/// Working directory inside the sandbox.
pub workdir: Option<String>,
/// Environment overrides for the exec.
pub environment: HashMap<String, String>,
/// Optional command timeout. `None` lets the gateway choose.
pub timeout: Option<Duration>,
/// Optional stdin payload.
pub stdin: Option<Vec<u8>>,
}
/// Result of a non-streaming exec call.
///
/// `stdout` and `stderr` are buffered to the end of the command. Use the
/// raw streaming RPC ([`crate::raw`]) for long-running output.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
+778
View File
@@ -0,0 +1,778 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! High-level [`OpenShellClient`] tests against an in-process mock gateway.
//!
//! The mock binds an ephemeral plaintext TCP listener and serves the
//! `OpenShell` gRPC service. Tests dial it via `http://127.0.0.1:<port>` so
//! TLS and auth code paths are skipped — those are exercised by the CLI's
//! `mtls_integration` and OIDC tests.
use openshell_core::proto;
use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
use openshell_sdk::{
ClientConfig, ExecOptions, ListOptions, OpenShellClient, SandboxPhase, SandboxSpec,
ServiceStatus as SdkServiceStatus,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_stream::wrappers::TcpListenerStream;
use tonic::{Response, Status};
/// Captured fixture state — what the mock observed and the canned replies it
/// returned. One per test so assertions are scoped.
#[derive(Default)]
struct MockState {
last_get_name: Mutex<Option<String>>,
last_create: Mutex<Option<proto::CreateSandboxRequest>>,
last_delete_name: Mutex<Option<String>>,
last_list_request: Mutex<Option<proto::ListSandboxesRequest>>,
last_exec_request: Mutex<Option<proto::ExecSandboxRequest>>,
get_calls: AtomicU32,
phase_sequence: Vec<proto::SandboxPhase>,
get_returns_not_found: bool,
not_found_after: Option<u32>,
}
#[derive(Clone)]
struct TestOpenShell {
state: Arc<MockState>,
}
fn sandbox_with_phase(name: &str, phase: proto::SandboxPhase) -> proto::Sandbox {
proto::Sandbox {
metadata: Some(proto::datamodel::v1::ObjectMeta {
id: format!("id-{name}"),
name: name.to_string(),
created_at_ms: 0,
labels: HashMap::new(),
resource_version: 1,
}),
spec: None,
status: Some(proto::SandboxStatus {
phase: phase.into(),
..Default::default()
}),
}
}
#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn health(
&self,
_request: tonic::Request<proto::HealthRequest>,
) -> Result<Response<proto::HealthResponse>, Status> {
Ok(Response::new(proto::HealthResponse {
status: proto::ServiceStatus::Healthy.into(),
version: "test-1.2.3".to_string(),
}))
}
async fn update_provider_profiles(
&self,
_: tonic::Request<proto::UpdateProviderProfilesRequest>,
) -> Result<Response<proto::UpdateProviderProfilesResponse>, Status> {
Ok(Response::new(proto::UpdateProviderProfilesResponse::default()))
}
async fn create_sandbox(
&self,
request: tonic::Request<proto::CreateSandboxRequest>,
) -> Result<Response<proto::SandboxResponse>, Status> {
let req = request.into_inner();
let name = if req.name.is_empty() {
"generated".to_string()
} else {
req.name.clone()
};
*self.state.last_create.lock().await = Some(req);
Ok(Response::new(proto::SandboxResponse {
sandbox: Some(sandbox_with_phase(&name, proto::SandboxPhase::Provisioning)),
}))
}
async fn get_sandbox(
&self,
request: tonic::Request<proto::GetSandboxRequest>,
) -> Result<Response<proto::SandboxResponse>, Status> {
let name = request.into_inner().name;
*self.state.last_get_name.lock().await = Some(name.clone());
let count = self.state.get_calls.fetch_add(1, Ordering::SeqCst);
if self.state.get_returns_not_found {
return Err(Status::not_found(format!("sandbox '{name}' not found")));
}
if let Some(threshold) = self.state.not_found_after
&& count >= threshold
{
return Err(Status::not_found(format!("sandbox '{name}' not found")));
}
let phase = self
.state
.phase_sequence
.get(count as usize)
.copied()
.or_else(|| self.state.phase_sequence.last().copied())
.unwrap_or(proto::SandboxPhase::Ready);
Ok(Response::new(proto::SandboxResponse {
sandbox: Some(sandbox_with_phase(&name, phase)),
}))
}
async fn list_sandboxes(
&self,
request: tonic::Request<proto::ListSandboxesRequest>,
) -> Result<Response<proto::ListSandboxesResponse>, Status> {
*self.state.last_list_request.lock().await = Some(request.into_inner());
Ok(Response::new(proto::ListSandboxesResponse {
sandboxes: vec![
sandbox_with_phase("alpha", proto::SandboxPhase::Ready),
sandbox_with_phase("beta", proto::SandboxPhase::Provisioning),
],
}))
}
async fn list_sandbox_providers(
&self,
_: tonic::Request<proto::ListSandboxProvidersRequest>,
) -> Result<Response<proto::ListSandboxProvidersResponse>, Status> {
Ok(Response::new(proto::ListSandboxProvidersResponse::default()))
}
async fn attach_sandbox_provider(
&self,
_: tonic::Request<proto::AttachSandboxProviderRequest>,
) -> Result<Response<proto::AttachSandboxProviderResponse>, Status> {
Ok(Response::new(
proto::AttachSandboxProviderResponse::default(),
))
}
async fn detach_sandbox_provider(
&self,
_: tonic::Request<proto::DetachSandboxProviderRequest>,
) -> Result<Response<proto::DetachSandboxProviderResponse>, Status> {
Ok(Response::new(
proto::DetachSandboxProviderResponse::default(),
))
}
async fn delete_sandbox(
&self,
request: tonic::Request<proto::DeleteSandboxRequest>,
) -> Result<Response<proto::DeleteSandboxResponse>, Status> {
let name = request.into_inner().name;
*self.state.last_delete_name.lock().await = Some(name);
Ok(Response::new(proto::DeleteSandboxResponse {
deleted: true,
}))
}
async fn create_ssh_session(
&self,
_: tonic::Request<proto::CreateSshSessionRequest>,
) -> Result<Response<proto::CreateSshSessionResponse>, Status> {
Ok(Response::new(proto::CreateSshSessionResponse::default()))
}
async fn expose_service(
&self,
_: tonic::Request<proto::ExposeServiceRequest>,
) -> Result<Response<proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(proto::ServiceEndpointResponse::default()))
}
async fn get_service(
&self,
_: tonic::Request<proto::GetServiceRequest>,
) -> Result<Response<proto::ServiceEndpointResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn list_services(
&self,
_: tonic::Request<proto::ListServicesRequest>,
) -> Result<Response<proto::ListServicesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn delete_service(
&self,
_: tonic::Request<proto::DeleteServiceRequest>,
) -> Result<Response<proto::DeleteServiceResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn revoke_ssh_session(
&self,
_: tonic::Request<proto::RevokeSshSessionRequest>,
) -> Result<Response<proto::RevokeSshSessionResponse>, Status> {
Ok(Response::new(proto::RevokeSshSessionResponse::default()))
}
type ExecSandboxStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::ExecSandboxEvent, Status>>;
async fn exec_sandbox(
&self,
request: tonic::Request<proto::ExecSandboxRequest>,
) -> Result<Response<Self::ExecSandboxStream>, Status> {
*self.state.last_exec_request.lock().await = Some(request.into_inner());
let (tx, rx) = tokio::sync::mpsc::channel(8);
tokio::spawn(async move {
let _ = tx
.send(Ok(proto::ExecSandboxEvent {
payload: Some(proto::exec_sandbox_event::Payload::Stdout(
proto::ExecSandboxStdout {
data: b"hello\n".to_vec(),
},
)),
}))
.await;
let _ = tx
.send(Ok(proto::ExecSandboxEvent {
payload: Some(proto::exec_sandbox_event::Payload::Stderr(
proto::ExecSandboxStderr {
data: b"warn\n".to_vec(),
},
)),
}))
.await;
let _ = tx
.send(Ok(proto::ExecSandboxEvent {
payload: Some(proto::exec_sandbox_event::Payload::Exit(
proto::ExecSandboxExit { exit_code: 7 },
)),
}))
.await;
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
}
type ForwardTcpStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::TcpForwardFrame, Status>>;
async fn forward_tcp(
&self,
_: tonic::Request<tonic::Streaming<proto::TcpForwardFrame>>,
) -> Result<Response<Self::ForwardTcpStream>, Status> {
Err(Status::unimplemented("unused"))
}
type ExecSandboxInteractiveStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::ExecSandboxEvent, Status>>;
async fn exec_sandbox_interactive(
&self,
_: tonic::Request<tonic::Streaming<proto::ExecSandboxInput>>,
) -> Result<Response<Self::ExecSandboxInteractiveStream>, Status> {
Err(Status::unimplemented("unused"))
}
async fn create_provider(
&self,
_: tonic::Request<proto::CreateProviderRequest>,
) -> Result<Response<proto::ProviderResponse>, Status> {
Ok(Response::new(proto::ProviderResponse::default()))
}
async fn get_provider(
&self,
_: tonic::Request<proto::GetProviderRequest>,
) -> Result<Response<proto::ProviderResponse>, Status> {
Ok(Response::new(proto::ProviderResponse::default()))
}
async fn list_providers(
&self,
_: tonic::Request<proto::ListProvidersRequest>,
) -> Result<Response<proto::ListProvidersResponse>, Status> {
Ok(Response::new(proto::ListProvidersResponse::default()))
}
async fn list_provider_profiles(
&self,
_: tonic::Request<proto::ListProviderProfilesRequest>,
) -> Result<Response<proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_provider_profile(
&self,
_: tonic::Request<proto::GetProviderProfileRequest>,
) -> Result<Response<proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn import_provider_profiles(
&self,
_: tonic::Request<proto::ImportProviderProfilesRequest>,
) -> Result<Response<proto::ImportProviderProfilesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn lint_provider_profiles(
&self,
_: tonic::Request<proto::LintProviderProfilesRequest>,
) -> Result<Response<proto::LintProviderProfilesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn update_provider(
&self,
_: tonic::Request<proto::UpdateProviderRequest>,
) -> Result<Response<proto::ProviderResponse>, Status> {
Ok(Response::new(proto::ProviderResponse::default()))
}
async fn get_provider_refresh_status(
&self,
_: tonic::Request<proto::GetProviderRefreshStatusRequest>,
) -> Result<Response<proto::GetProviderRefreshStatusResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn configure_provider_refresh(
&self,
_: tonic::Request<proto::ConfigureProviderRefreshRequest>,
) -> Result<Response<proto::ConfigureProviderRefreshResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn rotate_provider_credential(
&self,
_: tonic::Request<proto::RotateProviderCredentialRequest>,
) -> Result<Response<proto::RotateProviderCredentialResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn delete_provider_refresh(
&self,
_: tonic::Request<proto::DeleteProviderRefreshRequest>,
) -> Result<Response<proto::DeleteProviderRefreshResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn delete_provider(
&self,
_: tonic::Request<proto::DeleteProviderRequest>,
) -> Result<Response<proto::DeleteProviderResponse>, Status> {
Ok(Response::new(proto::DeleteProviderResponse::default()))
}
async fn delete_provider_profile(
&self,
_: tonic::Request<proto::DeleteProviderProfileRequest>,
) -> Result<Response<proto::DeleteProviderProfileResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_sandbox_config(
&self,
_: tonic::Request<proto::GetSandboxConfigRequest>,
) -> Result<Response<proto::GetSandboxConfigResponse>, Status> {
Ok(Response::new(proto::GetSandboxConfigResponse::default()))
}
async fn get_gateway_config(
&self,
_: tonic::Request<proto::GetGatewayConfigRequest>,
) -> Result<Response<proto::GetGatewayConfigResponse>, Status> {
Ok(Response::new(proto::GetGatewayConfigResponse::default()))
}
async fn update_config(
&self,
_: tonic::Request<proto::UpdateConfigRequest>,
) -> Result<Response<proto::UpdateConfigResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_sandbox_policy_status(
&self,
_: tonic::Request<proto::GetSandboxPolicyStatusRequest>,
) -> Result<Response<proto::GetSandboxPolicyStatusResponse>, Status> {
Ok(Response::new(
proto::GetSandboxPolicyStatusResponse::default(),
))
}
async fn list_sandbox_policies(
&self,
_: tonic::Request<proto::ListSandboxPoliciesRequest>,
) -> Result<Response<proto::ListSandboxPoliciesResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn report_policy_status(
&self,
_: tonic::Request<proto::ReportPolicyStatusRequest>,
) -> Result<Response<proto::ReportPolicyStatusResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_sandbox_provider_environment(
&self,
_: tonic::Request<proto::GetSandboxProviderEnvironmentRequest>,
) -> Result<Response<proto::GetSandboxProviderEnvironmentResponse>, Status> {
Ok(Response::new(
proto::GetSandboxProviderEnvironmentResponse::default(),
))
}
async fn get_sandbox_logs(
&self,
_: tonic::Request<proto::GetSandboxLogsRequest>,
) -> Result<Response<proto::GetSandboxLogsResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn push_sandbox_logs(
&self,
_: tonic::Request<tonic::Streaming<proto::PushSandboxLogsRequest>>,
) -> Result<Response<proto::PushSandboxLogsResponse>, Status> {
Err(Status::unimplemented("unused"))
}
type WatchSandboxStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::SandboxStreamEvent, Status>>;
async fn watch_sandbox(
&self,
_: tonic::Request<proto::WatchSandboxRequest>,
) -> Result<Response<Self::WatchSandboxStream>, Status> {
Err(Status::unimplemented("unused"))
}
async fn submit_policy_analysis(
&self,
_: tonic::Request<proto::SubmitPolicyAnalysisRequest>,
) -> Result<Response<proto::SubmitPolicyAnalysisResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_draft_policy(
&self,
_: tonic::Request<proto::GetDraftPolicyRequest>,
) -> Result<Response<proto::GetDraftPolicyResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn approve_draft_chunk(
&self,
_: tonic::Request<proto::ApproveDraftChunkRequest>,
) -> Result<Response<proto::ApproveDraftChunkResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn approve_all_draft_chunks(
&self,
_: tonic::Request<proto::ApproveAllDraftChunksRequest>,
) -> Result<Response<proto::ApproveAllDraftChunksResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn reject_draft_chunk(
&self,
_: tonic::Request<proto::RejectDraftChunkRequest>,
) -> Result<Response<proto::RejectDraftChunkResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn edit_draft_chunk(
&self,
_: tonic::Request<proto::EditDraftChunkRequest>,
) -> Result<Response<proto::EditDraftChunkResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn undo_draft_chunk(
&self,
_: tonic::Request<proto::UndoDraftChunkRequest>,
) -> Result<Response<proto::UndoDraftChunkResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn clear_draft_chunks(
&self,
_: tonic::Request<proto::ClearDraftChunksRequest>,
) -> Result<Response<proto::ClearDraftChunksResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn get_draft_history(
&self,
_: tonic::Request<proto::GetDraftHistoryRequest>,
) -> Result<Response<proto::GetDraftHistoryResponse>, Status> {
Err(Status::unimplemented("unused"))
}
type ConnectSupervisorStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::GatewayMessage, Status>>;
async fn connect_supervisor(
&self,
_: tonic::Request<tonic::Streaming<proto::SupervisorMessage>>,
) -> Result<Response<Self::ConnectSupervisorStream>, Status> {
Err(Status::unimplemented("unused"))
}
type RelayStreamStream =
tokio_stream::wrappers::ReceiverStream<Result<proto::RelayFrame, Status>>;
async fn relay_stream(
&self,
_: tonic::Request<tonic::Streaming<proto::RelayFrame>>,
) -> Result<Response<Self::RelayStreamStream>, Status> {
Err(Status::unimplemented("unused"))
}
async fn issue_sandbox_token(
&self,
_: tonic::Request<proto::IssueSandboxTokenRequest>,
) -> Result<Response<proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("unused"))
}
async fn refresh_sandbox_token(
&self,
_: tonic::Request<proto::RefreshSandboxTokenRequest>,
) -> Result<Response<proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("unused"))
}
}
/// Spin up the mock gateway, return its endpoint URL.
async fn start_mock(state: Arc<MockState>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let endpoint = format!("http://{addr}");
let stream = TcpListenerStream::new(listener);
let svc = OpenShellServer::new(TestOpenShell { state });
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(svc)
.serve_with_incoming(stream)
.await;
});
endpoint
}
async fn connect(endpoint: &str) -> OpenShellClient {
OpenShellClient::connect(ClientConfig::new(endpoint))
.await
.expect("connect should succeed against local mock")
}
#[tokio::test]
async fn health_returns_curated_snapshot() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let h = client.health().await.unwrap();
assert_eq!(h.status, SdkServiceStatus::Healthy);
assert_eq!(h.version, "test-1.2.3");
}
#[tokio::test]
async fn create_sandbox_passes_spec_through() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let mut labels = HashMap::new();
labels.insert("team".to_string(), "core".to_string());
let spec = SandboxSpec {
name: Some("my-box".to_string()),
image: Some("ghcr.io/foo:bar".to_string()),
labels: labels.clone(),
gpu: true,
..Default::default()
};
let result = client.create_sandbox(spec).await.unwrap();
assert_eq!(result.name, "my-box");
assert_eq!(result.phase, SandboxPhase::Provisioning);
let observed = state.last_create.lock().await.clone().unwrap();
assert_eq!(observed.name, "my-box");
assert_eq!(observed.labels, labels);
let observed_spec = observed.spec.unwrap();
assert!(
observed_spec
.resource_requirements
.as_ref()
.and_then(|r| r.gpu.as_ref())
.is_some()
);
assert_eq!(
observed_spec.template.as_ref().unwrap().image,
"ghcr.io/foo:bar"
);
}
#[tokio::test]
async fn get_sandbox_sends_name_and_maps_phase() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Ready],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let sandbox = client.get_sandbox("my-box").await.unwrap();
assert_eq!(sandbox.name, "my-box");
assert_eq!(sandbox.id, "id-my-box");
assert_eq!(sandbox.phase, SandboxPhase::Ready);
let observed = state.last_get_name.lock().await.clone();
assert_eq!(observed.as_deref(), Some("my-box"));
}
#[tokio::test]
async fn list_sandboxes_propagates_filters() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let opts = ListOptions {
limit: 25,
offset: 5,
label_selector: Some("team=core".to_string()),
};
let items = client.list_sandboxes(opts).await.unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "alpha");
assert_eq!(items[0].phase, SandboxPhase::Ready);
assert_eq!(items[1].phase, SandboxPhase::Provisioning);
let observed = state.last_list_request.lock().await.clone().unwrap();
assert_eq!(observed.limit, 25);
assert_eq!(observed.offset, 5);
assert_eq!(observed.label_selector, "team=core");
}
#[tokio::test]
async fn delete_sandbox_returns_server_ack() {
let state = Arc::new(MockState::default());
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let deleted = client.delete_sandbox("doomed").await.unwrap();
assert!(deleted);
let observed = state.last_delete_name.lock().await.clone();
assert_eq!(observed.as_deref(), Some("doomed"));
}
#[tokio::test]
async fn wait_ready_transitions_through_phases() {
let state = Arc::new(MockState {
phase_sequence: vec![
proto::SandboxPhase::Provisioning,
proto::SandboxPhase::Provisioning,
proto::SandboxPhase::Ready,
],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let sandbox = client
.wait_ready("my-box", std::time::Duration::from_secs(5))
.await
.unwrap();
assert_eq!(sandbox.phase, SandboxPhase::Ready);
assert!(state.get_calls.load(Ordering::SeqCst) >= 3);
}
#[tokio::test]
async fn wait_ready_surfaces_error_phase() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Error],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let err = client
.wait_ready("my-box", std::time::Duration::from_secs(5))
.await
.unwrap_err();
assert_eq!(err.code(), "connect");
}
#[tokio::test]
async fn wait_deleted_returns_when_get_reports_not_found() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Deleting],
not_found_after: Some(2),
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
client
.wait_deleted("my-box", std::time::Duration::from_secs(5))
.await
.unwrap();
assert!(state.get_calls.load(Ordering::SeqCst) >= 3);
}
#[tokio::test]
async fn get_sandbox_not_found_maps_to_typed_error() {
let state = Arc::new(MockState {
get_returns_not_found: true,
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let err = client.get_sandbox("missing").await.unwrap_err();
assert_eq!(err.code(), "not_found");
}
#[tokio::test]
async fn exec_buffers_stdout_stderr_and_exit() {
let state = Arc::new(MockState {
phase_sequence: vec![proto::SandboxPhase::Ready],
..Default::default()
});
let endpoint = start_mock(state.clone()).await;
let client = connect(&endpoint).await;
let result = client
.exec(
"my-box",
&["echo".to_string(), "hello".to_string()],
ExecOptions {
workdir: Some("/work".to_string()),
timeout: Some(std::time::Duration::from_secs(10)),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(result.exit_code, 7);
assert_eq!(result.stdout, b"hello\n");
assert_eq!(result.stderr, b"warn\n");
let observed = state.last_exec_request.lock().await.clone().unwrap();
assert_eq!(observed.sandbox_id, "id-my-box");
assert_eq!(
observed.command,
vec!["echo".to_string(), "hello".to_string()]
);
assert_eq!(observed.workdir, "/work");
assert_eq!(observed.timeout_seconds, 10);
}
+1
View File
@@ -15,6 +15,7 @@ openshell-core = { path = "../openshell-core", default-features = false }
openshell-bootstrap = { path = "../openshell-bootstrap" }
openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-sdk = { path = "../openshell-sdk" }
base64 = { workspace = true }
ratatui = { workspace = true }
+1 -1
View File
@@ -6,10 +6,10 @@ use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use openshell_bootstrap::GatewayMetadataSource;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_core::proto::setting_value;
use openshell_core::settings::{self, SettingValueKind};
use openshell_sdk::EdgeAuthInterceptor;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
+1 -1
View File
@@ -19,10 +19,10 @@ use crossterm::terminal::{
};
use miette::{IntoDiagnostic, Result};
use openshell_bootstrap::list_gateways_with_source;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::metadata::{ObjectId, ObjectLabels, ObjectName};
use openshell_core::proto::SandboxPhase;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_sdk::EdgeAuthInterceptor;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;