From 93b1506b5bcb1cb0077e2c9fbcc9e582c48eaee3 Mon Sep 17 00:00:00 2001 From: Dhiraj Bokde Date: Wed, 26 Aug 2026 10:18:19 -0700 Subject: [PATCH] feat(ts-sdk): add curated provider client Signed-off-by: Dhiraj Bokde --- sdk/typescript/README.md | 41 +++++- sdk/typescript/src/client.ts | 4 + sdk/typescript/src/index.ts | 2 + sdk/typescript/src/provider.test.ts | 152 +++++++++++++++++++++ sdk/typescript/src/provider.ts | 204 ++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 sdk/typescript/src/provider.test.ts create mode 100644 sdk/typescript/src/provider.ts diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index b0e7ed696c..3d223feae6 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -150,6 +150,42 @@ await client.sandbox.setPolicy(name, config.policy!, { wait: true }) await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolValue', value: true } }) ``` +Provider lifecycle and credential updates use the curated `client.providers` +API. Credential values are sent only to the gateway and are deliberately absent +from `ProviderRecord` responses. Give each sandbox (or security principal) its +own provider when credentials must remain isolated: + +```ts +const provider = await client.providers.ensure('tenant-a', { + name: `backend-token-${sandboxName}`, + type: 'backend-api', + credentials: { USER_JWT: initialJwt }, + credentialExpiresAtMs: { USER_JWT: expiresAtMs.toString() }, +}) + +const sandbox = await client.sandbox.create({ + name: sandboxName, + image, + providers: [provider.name], +}) + +// Rotate through OpenShell's provider handling. Sandbox code keeps using its +// provider environment; it never receives the credential as an app secret. +const current = await client.providers.get('tenant-a', provider.name) +await client.providers.update('tenant-a', { + name: current.name, + type: current.type, + resourceVersion: current.resourceVersion, + credentials: { USER_JWT: refreshedJwt }, + credentialExpiresAtMs: { USER_JWT: refreshedExpiresAtMs.toString() }, +}) +``` + +`update` merges credential, expiry, and configuration keys. A credential owned +by an automatic refresh configuration must instead be rotated through the +provider-refresh API; curated profile and refresh sub-clients are follow-up +work and remain available through `client.raw` in the meantime. + Sandbox-scoped `setPolicy` may only change `networkPolicies`; static fields (`filesystem`, `landlock`, `process`) must match the create-time policy. Sandbox-scoped setting deletes are rejected by the gateway, so only upsert (`setSetting`) is exposed here. ## Surface and roadmap @@ -158,7 +194,8 @@ The SDK's goal is agent parity: anything the OpenShell gateway can do should be - `client.sandbox` (`SandboxClient`) is available today: sandbox lifecycle, exec, forward, SSH, sandbox-scoped providers, config, and policy. - `client.gateway` (`GatewayClient`) is planned: gateway-scoped config and settings, health, and cluster status. -- `client.providers` (`ProviderClient`) is planned: gateway-scoped provider CRUD and profiles. +- `client.providers` (`ProviderClient`) is available: workspace-scoped provider CRUD, idempotent ensure, and manual credential updates. +- `client.providers.profiles` and `client.providers.refresh` are planned: curated provider profile and automatic credential-refresh operations. `health()` lives at the root today and will move under `client.gateway` (with a root alias) when that lands. @@ -166,7 +203,7 @@ Curated methods are added deliberately, so some gateway RPCs are not yet wrapped ### Advanced: raw escape hatch -`client.raw` is a generated client for every gateway RPC, including surface the curated sub-clients do not wrap yet (gateway config, provider CRUD, policy status, watch, logs, and the full observed `Sandbox`). `client.transport` is the shared connection, so extra clients reuse one socket. Generated request and response types live at `@nvidia/openshell-sdk/raw`. +`client.raw` is a generated client for every gateway RPC, including surface the curated sub-clients do not wrap yet (gateway config, provider profiles and refresh, policy status, watch, logs, and the full observed `Sandbox`). `client.transport` is the shared connection, so extra clients reuse one socket. Generated request and response types live at `@nvidia/openshell-sdk/raw`. ```ts import { OpenShellClient } from '@nvidia/openshell-sdk' diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index b6db97943f..1101463ccf 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -28,6 +28,7 @@ import { } from './gen/openshell_pb.js'; import type { EffectiveSetting, GetSandboxConfigResponse, SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValueSchema } from './gen/sandbox_pb.js'; +import { ProviderClient } from './provider.js'; import { validateSshResponse } from './ssh-validate.js'; import { buildTransport, type ConnectOptions } from './transport.js'; @@ -1203,6 +1204,8 @@ export class SandboxClient { export class OpenShellClient { /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ readonly sandbox: SandboxClient; + /** Provider lifecycle and credential updates. */ + readonly providers: ProviderClient; /** * Advanced escape hatch: a generated client for every gateway RPC, including @@ -1222,6 +1225,7 @@ export class OpenShellClient { this.grpc = createClient(OpenShell, transport); this.raw = this.grpc; this.sandbox = new SandboxClient(transport, this.grpc); + this.providers = new ProviderClient(transport, this.grpc); } /** diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 61cdce3871..c39c034ed5 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -39,3 +39,5 @@ export type { SdkErrorCode } from './errors.js'; export { SdkError } from './errors.js'; export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js'; export { clientCredentials } from './oidc.js'; +export type { ProviderDefinition, ProviderListOptions, ProviderRecord } from './provider.js'; +export { ProviderClient } from './provider.js'; diff --git a/sdk/typescript/src/provider.test.ts b/sdk/typescript/src/provider.test.ts new file mode 100644 index 0000000000..0fd9223787 --- /dev/null +++ b/sdk/typescript/src/provider.test.ts @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { OpenShell } from './gen/openshell_pb.js'; +import { ProviderClient } from './provider.js'; + +function client(impl: Partial>): ProviderClient { + const transport: Transport = createRouterTransport((router) => router.service(OpenShell, impl)); + return new ProviderClient(transport); +} + +function record(name = 'user-token', resourceVersion = 7n) { + return { + provider: { + metadata: { + id: `id-${name}`, + name, + labels: { owner: 'app' }, + annotations: { purpose: 'per-sandbox' }, + workspace: 'tenant-a', + resourceVersion, + createdAtMs: 123n, + }, + type: 'backend-api', + config: { endpoint: 'https://api.example.com' }, + credentialExpiresAtMs: { USER_JWT: 456n }, + profileWorkspace: 'tenant-a', + }, + }; +} + +describe('ProviderClient', () => { + it('creates a provider without returning credential plaintext', async () => { + let request: Parameters>['createProvider']>>[0] | undefined; + const providers = client({ + createProvider: (req) => { + request = req; + return record(); + }, + }); + + const created = await providers.create('tenant-a', { + name: 'user-token', + type: 'backend-api', + credentials: { USER_JWT: 'secret-value' }, + credentialExpiresAtMs: { USER_JWT: '456' }, + }); + + expect(request?.workspace).toBe('tenant-a'); + expect(request?.provider?.credentials).toEqual({ USER_JWT: 'secret-value' }); + expect(request?.provider?.credentialExpiresAtMs.USER_JWT).toBe(456n); + expect(created).not.toHaveProperty('credentials'); + expect(created.resourceVersion).toBe('7'); + expect(created.credentialExpiresAtMs).toEqual({ USER_JWT: '456' }); + }); + + it('lists providers and validates pagination before the RPC', async () => { + let request: { workspace?: string; limit?: number; offset?: number; allWorkspaces?: boolean } | undefined; + const providers = client({ + listProviders: (req) => { + request = req; + return { providers: [record('one').provider, record('two').provider] }; + }, + }); + + const listed = await providers.list('tenant-a', { limit: 10, offset: 2 }); + expect(request).toMatchObject({ workspace: 'tenant-a', limit: 10, offset: 2, allWorkspaces: false }); + expect(listed.map((provider) => provider.name)).toEqual(['one', 'two']); + await expect(providers.list('tenant-a', { limit: -1 })).rejects.toMatchObject({ code: 'invalid_config' }); + await expect(providers.list('tenant-a', { allWorkspaces: true })).rejects.toMatchObject({ + code: 'invalid_config', + }); + }); + + it('updates credentials with a resource-version pin for safe rotation', async () => { + let request: Parameters>['updateProvider']>>[0] | undefined; + const providers = client({ + updateProvider: (req) => { + request = req; + return record('user-token', 9n); + }, + }); + + const updated = await providers.update('tenant-a', { + name: 'user-token', + type: 'backend-api', + credentials: { USER_JWT: 'rotated-value' }, + resourceVersion: '7', + }); + + expect(request?.provider?.metadata?.resourceVersion).toBe(7n); + expect(request?.provider?.credentials).toEqual({ USER_JWT: 'rotated-value' }); + expect(updated.resourceVersion).toBe('9'); + }); + + it('ensure creates when absent and updates with the current resource version when present', async () => { + let exists = false; + let createCount = 0; + let updateVersion = 0n; + const providers = client({ + getProvider: () => { + if (!exists) throw new ConnectError('missing', Code.NotFound); + return record('user-token', 42n); + }, + createProvider: () => { + createCount += 1; + exists = true; + return record(); + }, + updateProvider: (req) => { + updateVersion = req.provider?.metadata?.resourceVersion ?? 0n; + return record('user-token', 43n); + }, + }); + + const desired = { name: 'user-token', type: 'backend-api', credentials: { USER_JWT: 'value' } }; + await providers.ensure('tenant-a', desired); + expect(createCount).toBe(1); + await providers.ensure('tenant-a', desired); + expect(updateVersion).toBe(42n); + }); + + it('does not turn an update race into a create', async () => { + let createCount = 0; + const providers = client({ + getProvider: () => record('user-token', 42n), + updateProvider: () => { + throw new ConnectError('deleted concurrently', Code.NotFound); + }, + createProvider: () => { + createCount += 1; + return record(); + }, + }); + + await expect(providers.ensure('tenant-a', { name: 'user-token', type: 'backend-api' })).rejects.toMatchObject({ + code: 'not_found', + }); + expect(createCount).toBe(0); + }); + + it('maps delete and malformed gateway responses through the SDK error taxonomy', async () => { + const providers = client({ + deleteProvider: () => ({ deleted: true }), + getProvider: () => ({ provider: { type: 'backend-api' } }), + }); + await expect(providers.delete('tenant-a', 'user-token')).resolves.toBe(true); + await expect(providers.get('tenant-a', 'user-token')).rejects.toMatchObject({ code: 'invalid_config' }); + }); +}); diff --git a/sdk/typescript/src/provider.ts b/sdk/typescript/src/provider.ts new file mode 100644 index 0000000000..c385b2eca3 --- /dev/null +++ b/sdk/typescript/src/provider.ts @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Client, Transport } from '@connectrpc/connect'; +import { createClient } from '@connectrpc/connect'; +import { fromConnect, SdkError } from './errors.js'; +import type { Provider as ProtoProvider } from './gen/datamodel_pb.js'; +import { OpenShell } from './gen/openshell_pb.js'; +import { buildTransport, type ConnectOptions } from './transport.js'; + +/** Secret and non-secret values used to create or update a provider. */ +export interface ProviderDefinition { + name: string; + type: string; + labels?: Record; + annotations?: Record; + /** Secret values. Responses never return their plaintext values. */ + credentials?: Record; + config?: Record; + /** Milliseconds since Unix epoch, represented as strings to preserve int64 precision. Zero removes an expiry. */ + credentialExpiresAtMs?: Record; + profileWorkspace?: string; + /** Optimistic-concurrency version, represented as a string to preserve uint64 precision. */ + resourceVersion?: string; +} + +/** A provider returned by the gateway. Credential plaintext is intentionally absent. */ +export interface ProviderRecord { + id: string; + name: string; + type: string; + labels: Record; + annotations: Record; + workspace: string; + resourceVersion: string; + createdAtMs: string; + deletionTimestampMs?: string; + config: Record; + credentialExpiresAtMs: Record; + profileWorkspace: string; +} + +export interface ProviderListOptions { + limit?: number; + offset?: number; + /** List every workspace. Mutually exclusive with a non-empty workspace. */ + allWorkspaces?: boolean; +} + +function integer(value: string | undefined, field: string): bigint { + if (!value) return 0n; + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new SdkError('invalid_config', `${field} is not an integer: '${value}'`); + } + if (parsed < 0n) throw new SdkError('invalid_config', `${field} must not be negative: '${value}'`); + return parsed; +} + +function providerMessage(provider: ProviderDefinition): ProtoProvider { + if (!provider.name.trim()) throw new SdkError('invalid_config', 'provider.name is required'); + if (!provider.type.trim()) throw new SdkError('invalid_config', 'provider.type is required'); + + return { + $typeName: 'openshell.datamodel.v1.Provider', + metadata: { + $typeName: 'openshell.datamodel.v1.ObjectMeta', + id: '', + name: provider.name, + createdAtMs: 0n, + labels: provider.labels ?? {}, + resourceVersion: integer(provider.resourceVersion, 'provider.resourceVersion'), + annotations: provider.annotations ?? {}, + workspace: '', + deletionTimestampMs: 0n, + }, + type: provider.type, + credentials: provider.credentials ?? {}, + config: provider.config ?? {}, + credentialExpiresAtMs: Object.fromEntries( + Object.entries(provider.credentialExpiresAtMs ?? {}).map(([name, value]) => [ + name, + integer(value, `provider.credentialExpiresAtMs.${name}`), + ]), + ), + profileWorkspace: provider.profileWorkspace ?? '', + credentialHandles: {}, + }; +} + +function providerRecord(provider: ProtoProvider | undefined): ProviderRecord { + const meta = provider?.metadata; + if (!provider || !meta?.id || !meta.name) { + throw new SdkError('invalid_config', 'provider metadata.id and metadata.name are required in gateway responses'); + } + return { + id: meta.id, + name: meta.name, + type: provider.type, + labels: meta.labels, + annotations: meta.annotations, + workspace: meta.workspace, + resourceVersion: meta.resourceVersion.toString(), + createdAtMs: meta.createdAtMs.toString(), + ...(meta.deletionTimestampMs ? { deletionTimestampMs: meta.deletionTimestampMs.toString() } : {}), + config: provider.config, + credentialExpiresAtMs: Object.fromEntries( + Object.entries(provider.credentialExpiresAtMs).map(([name, value]) => [name, value.toString()]), + ), + profileWorkspace: provider.profileWorkspace, + }; +} + +/** Curated provider CRUD and credential-update API over the shared authenticated transport. */ +export class ProviderClient { + private readonly grpc: Client; + readonly raw: Client; + readonly transport: Transport; + + constructor(transport: Transport, grpc = createClient(OpenShell, transport)) { + this.transport = transport; + this.grpc = grpc; + this.raw = grpc; + } + + static async connect(options: ConnectOptions): Promise { + return new ProviderClient(buildTransport(options)); + } + + async create(workspace: string, provider: ProviderDefinition): Promise { + try { + const response = await this.grpc.createProvider({ workspace, provider: providerMessage(provider) }); + return providerRecord(response.provider); + } catch (error) { + throw fromConnect(error); + } + } + + async get(workspace: string, name: string): Promise { + try { + const response = await this.grpc.getProvider({ workspace, name }); + return providerRecord(response.provider); + } catch (error) { + throw fromConnect(error); + } + } + + async list(workspace: string, options?: ProviderListOptions | null): Promise { + if ((options?.limit ?? 0) < 0) throw new SdkError('invalid_config', 'limit must not be negative'); + if ((options?.offset ?? 0) < 0) throw new SdkError('invalid_config', 'offset must not be negative'); + if (options?.allWorkspaces && workspace) { + throw new SdkError('invalid_config', 'allWorkspaces is mutually exclusive with a non-empty workspace'); + } + try { + const response = await this.grpc.listProviders({ + workspace, + limit: options?.limit ?? 0, + offset: options?.offset ?? 0, + allWorkspaces: options?.allWorkspaces ?? false, + }); + return response.providers.map(providerRecord); + } catch (error) { + throw fromConnect(error); + } + } + + /** Merge credentials, credential expiries, and config into an existing provider. */ + async update(workspace: string, provider: ProviderDefinition): Promise { + try { + const message = providerMessage(provider); + const response = await this.grpc.updateProvider({ + workspace, + provider: message, + credentialExpiresAtMs: message.credentialExpiresAtMs, + }); + return providerRecord(response.provider); + } catch (error) { + throw fromConnect(error); + } + } + + async delete(workspace: string, name: string): Promise { + try { + const response = await this.grpc.deleteProvider({ workspace, name }); + return response.deleted; + } catch (error) { + throw fromConnect(error); + } + } + + /** Create the provider when absent; otherwise update it with optimistic concurrency. */ + async ensure(workspace: string, provider: ProviderDefinition): Promise { + let existing: ProviderRecord; + try { + existing = await this.get(workspace, provider.name); + } catch (error) { + if (error instanceof SdkError && error.code === 'not_found') return this.create(workspace, provider); + throw error; + } + return this.update(workspace, { ...provider, resourceVersion: existing.resourceVersion }); + } +}