Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -158,15 +194,16 @@ 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.

Curated methods are added deliberately, so some gateway RPCs are not yet wrapped in a typed helper. Rather than ship methods that exist but throw, the SDK omits what it has not curated and gives you the raw escape hatch below to reach the full gateway surface today. Omission means "not yet ergonomic," never "impossible."

### 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'
Expand Down
4 changes: 4 additions & 0 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
152 changes: 152 additions & 0 deletions sdk/typescript/src/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServiceImpl<typeof OpenShell>>): 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<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['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<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['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' });
});
});
Loading
Loading