Skip to content

Commit 93b1506

Browse files
committed
feat(ts-sdk): add curated provider client
Signed-off-by: Dhiraj Bokde <dbokde@nvidia.com>
1 parent 572843b commit 93b1506

5 files changed

Lines changed: 401 additions & 2 deletions

File tree

sdk/typescript/README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,42 @@ await client.sandbox.setPolicy(name, config.policy!, { wait: true })
150150
await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolValue', value: true } })
151151
```
152152

153+
Provider lifecycle and credential updates use the curated `client.providers`
154+
API. Credential values are sent only to the gateway and are deliberately absent
155+
from `ProviderRecord` responses. Give each sandbox (or security principal) its
156+
own provider when credentials must remain isolated:
157+
158+
```ts
159+
const provider = await client.providers.ensure('tenant-a', {
160+
name: `backend-token-${sandboxName}`,
161+
type: 'backend-api',
162+
credentials: { USER_JWT: initialJwt },
163+
credentialExpiresAtMs: { USER_JWT: expiresAtMs.toString() },
164+
})
165+
166+
const sandbox = await client.sandbox.create({
167+
name: sandboxName,
168+
image,
169+
providers: [provider.name],
170+
})
171+
172+
// Rotate through OpenShell's provider handling. Sandbox code keeps using its
173+
// provider environment; it never receives the credential as an app secret.
174+
const current = await client.providers.get('tenant-a', provider.name)
175+
await client.providers.update('tenant-a', {
176+
name: current.name,
177+
type: current.type,
178+
resourceVersion: current.resourceVersion,
179+
credentials: { USER_JWT: refreshedJwt },
180+
credentialExpiresAtMs: { USER_JWT: refreshedExpiresAtMs.toString() },
181+
})
182+
```
183+
184+
`update` merges credential, expiry, and configuration keys. A credential owned
185+
by an automatic refresh configuration must instead be rotated through the
186+
provider-refresh API; curated profile and refresh sub-clients are follow-up
187+
work and remain available through `client.raw` in the meantime.
188+
153189
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.
154190

155191
## Surface and roadmap
@@ -158,15 +194,16 @@ The SDK's goal is agent parity: anything the OpenShell gateway can do should be
158194

159195
- `client.sandbox` (`SandboxClient`) is available today: sandbox lifecycle, exec, forward, SSH, sandbox-scoped providers, config, and policy.
160196
- `client.gateway` (`GatewayClient`) is planned: gateway-scoped config and settings, health, and cluster status.
161-
- `client.providers` (`ProviderClient`) is planned: gateway-scoped provider CRUD and profiles.
197+
- `client.providers` (`ProviderClient`) is available: workspace-scoped provider CRUD, idempotent ensure, and manual credential updates.
198+
- `client.providers.profiles` and `client.providers.refresh` are planned: curated provider profile and automatic credential-refresh operations.
162199

163200
`health()` lives at the root today and will move under `client.gateway` (with a root alias) when that lands.
164201

165202
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."
166203

167204
### Advanced: raw escape hatch
168205

169-
`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`.
206+
`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`.
170207

171208
```ts
172209
import { OpenShellClient } from '@nvidia/openshell-sdk'

sdk/typescript/src/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from './gen/openshell_pb.js';
2929
import type { EffectiveSetting, GetSandboxConfigResponse, SandboxPolicy, SettingValue } from './gen/sandbox_pb.js';
3030
import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValueSchema } from './gen/sandbox_pb.js';
31+
import { ProviderClient } from './provider.js';
3132
import { validateSshResponse } from './ssh-validate.js';
3233
import { buildTransport, type ConnectOptions } from './transport.js';
3334

@@ -1203,6 +1204,8 @@ export class SandboxClient {
12031204
export class OpenShellClient {
12041205
/** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */
12051206
readonly sandbox: SandboxClient;
1207+
/** Provider lifecycle and credential updates. */
1208+
readonly providers: ProviderClient;
12061209

12071210
/**
12081211
* Advanced escape hatch: a generated client for every gateway RPC, including
@@ -1222,6 +1225,7 @@ export class OpenShellClient {
12221225
this.grpc = createClient(OpenShell, transport);
12231226
this.raw = this.grpc;
12241227
this.sandbox = new SandboxClient(transport, this.grpc);
1228+
this.providers = new ProviderClient(transport, this.grpc);
12251229
}
12261230

12271231
/**

sdk/typescript/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ export type { SdkErrorCode } from './errors.js';
3939
export { SdkError } from './errors.js';
4040
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
4141
export { clientCredentials } from './oidc.js';
42+
export type { ProviderDefinition, ProviderListOptions, ProviderRecord } from './provider.js';
43+
export { ProviderClient } from './provider.js';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect';
5+
import { describe, expect, it } from 'vitest';
6+
import { OpenShell } from './gen/openshell_pb.js';
7+
import { ProviderClient } from './provider.js';
8+
9+
function client(impl: Partial<ServiceImpl<typeof OpenShell>>): ProviderClient {
10+
const transport: Transport = createRouterTransport((router) => router.service(OpenShell, impl));
11+
return new ProviderClient(transport);
12+
}
13+
14+
function record(name = 'user-token', resourceVersion = 7n) {
15+
return {
16+
provider: {
17+
metadata: {
18+
id: `id-${name}`,
19+
name,
20+
labels: { owner: 'app' },
21+
annotations: { purpose: 'per-sandbox' },
22+
workspace: 'tenant-a',
23+
resourceVersion,
24+
createdAtMs: 123n,
25+
},
26+
type: 'backend-api',
27+
config: { endpoint: 'https://api.example.com' },
28+
credentialExpiresAtMs: { USER_JWT: 456n },
29+
profileWorkspace: 'tenant-a',
30+
},
31+
};
32+
}
33+
34+
describe('ProviderClient', () => {
35+
it('creates a provider without returning credential plaintext', async () => {
36+
let request: Parameters<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['createProvider']>>[0] | undefined;
37+
const providers = client({
38+
createProvider: (req) => {
39+
request = req;
40+
return record();
41+
},
42+
});
43+
44+
const created = await providers.create('tenant-a', {
45+
name: 'user-token',
46+
type: 'backend-api',
47+
credentials: { USER_JWT: 'secret-value' },
48+
credentialExpiresAtMs: { USER_JWT: '456' },
49+
});
50+
51+
expect(request?.workspace).toBe('tenant-a');
52+
expect(request?.provider?.credentials).toEqual({ USER_JWT: 'secret-value' });
53+
expect(request?.provider?.credentialExpiresAtMs.USER_JWT).toBe(456n);
54+
expect(created).not.toHaveProperty('credentials');
55+
expect(created.resourceVersion).toBe('7');
56+
expect(created.credentialExpiresAtMs).toEqual({ USER_JWT: '456' });
57+
});
58+
59+
it('lists providers and validates pagination before the RPC', async () => {
60+
let request: { workspace?: string; limit?: number; offset?: number; allWorkspaces?: boolean } | undefined;
61+
const providers = client({
62+
listProviders: (req) => {
63+
request = req;
64+
return { providers: [record('one').provider, record('two').provider] };
65+
},
66+
});
67+
68+
const listed = await providers.list('tenant-a', { limit: 10, offset: 2 });
69+
expect(request).toMatchObject({ workspace: 'tenant-a', limit: 10, offset: 2, allWorkspaces: false });
70+
expect(listed.map((provider) => provider.name)).toEqual(['one', 'two']);
71+
await expect(providers.list('tenant-a', { limit: -1 })).rejects.toMatchObject({ code: 'invalid_config' });
72+
await expect(providers.list('tenant-a', { allWorkspaces: true })).rejects.toMatchObject({
73+
code: 'invalid_config',
74+
});
75+
});
76+
77+
it('updates credentials with a resource-version pin for safe rotation', async () => {
78+
let request: Parameters<NonNullable<Partial<ServiceImpl<typeof OpenShell>>['updateProvider']>>[0] | undefined;
79+
const providers = client({
80+
updateProvider: (req) => {
81+
request = req;
82+
return record('user-token', 9n);
83+
},
84+
});
85+
86+
const updated = await providers.update('tenant-a', {
87+
name: 'user-token',
88+
type: 'backend-api',
89+
credentials: { USER_JWT: 'rotated-value' },
90+
resourceVersion: '7',
91+
});
92+
93+
expect(request?.provider?.metadata?.resourceVersion).toBe(7n);
94+
expect(request?.provider?.credentials).toEqual({ USER_JWT: 'rotated-value' });
95+
expect(updated.resourceVersion).toBe('9');
96+
});
97+
98+
it('ensure creates when absent and updates with the current resource version when present', async () => {
99+
let exists = false;
100+
let createCount = 0;
101+
let updateVersion = 0n;
102+
const providers = client({
103+
getProvider: () => {
104+
if (!exists) throw new ConnectError('missing', Code.NotFound);
105+
return record('user-token', 42n);
106+
},
107+
createProvider: () => {
108+
createCount += 1;
109+
exists = true;
110+
return record();
111+
},
112+
updateProvider: (req) => {
113+
updateVersion = req.provider?.metadata?.resourceVersion ?? 0n;
114+
return record('user-token', 43n);
115+
},
116+
});
117+
118+
const desired = { name: 'user-token', type: 'backend-api', credentials: { USER_JWT: 'value' } };
119+
await providers.ensure('tenant-a', desired);
120+
expect(createCount).toBe(1);
121+
await providers.ensure('tenant-a', desired);
122+
expect(updateVersion).toBe(42n);
123+
});
124+
125+
it('does not turn an update race into a create', async () => {
126+
let createCount = 0;
127+
const providers = client({
128+
getProvider: () => record('user-token', 42n),
129+
updateProvider: () => {
130+
throw new ConnectError('deleted concurrently', Code.NotFound);
131+
},
132+
createProvider: () => {
133+
createCount += 1;
134+
return record();
135+
},
136+
});
137+
138+
await expect(providers.ensure('tenant-a', { name: 'user-token', type: 'backend-api' })).rejects.toMatchObject({
139+
code: 'not_found',
140+
});
141+
expect(createCount).toBe(0);
142+
});
143+
144+
it('maps delete and malformed gateway responses through the SDK error taxonomy', async () => {
145+
const providers = client({
146+
deleteProvider: () => ({ deleted: true }),
147+
getProvider: () => ({ provider: { type: 'backend-api' } }),
148+
});
149+
await expect(providers.delete('tenant-a', 'user-token')).resolves.toBe(true);
150+
await expect(providers.get('tenant-a', 'user-token')).rejects.toMatchObject({ code: 'invalid_config' });
151+
});
152+
});

0 commit comments

Comments
 (0)