@openkaiden/opnshll-sdk 0.0.116 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,13 +1,39 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import * as net from 'node:net';
4
+ import { durationFromMs } from '@bufbuild/protobuf/wkt';
4
5
  import { createClient } from '@connectrpc/connect';
5
6
  import { errorCode, fromConnect, SdkError } from './errors.js';
6
7
  import { OpenShell, SandboxPhase, ServiceStatus, } from './gen/openshell_pb.js';
7
8
  import { PolicySource, SettingScope } from './gen/sandbox_pb.js';
8
9
  import { validateSshResponse } from './ssh-validate.js';
9
10
  import { buildTransport } from './transport.js';
11
+ function durationFromSeconds(seconds) {
12
+ if (!Number.isFinite(seconds) || seconds < 0) {
13
+ throw new RangeError('timeoutSecs must be a finite, non-negative number');
14
+ }
15
+ return seconds === 0 ? undefined : durationFromMs(seconds * 1000);
16
+ }
17
+ function timestampMillis(timestamp) {
18
+ if (!timestamp)
19
+ return undefined;
20
+ const millis = timestamp.seconds * 1000n + BigInt(Math.trunc(timestamp.nanos / 1_000_000));
21
+ return millis === 0n ? undefined : millis.toString();
22
+ }
10
23
  export { errorCode };
24
+ function deletionResult(response) {
25
+ const names = {
26
+ 0: 'unspecified',
27
+ 1: 'completed',
28
+ 2: 'accepted',
29
+ 3: 'already_absent',
30
+ };
31
+ return {
32
+ outcome: names[response.outcome] ?? 'unknown',
33
+ rawOutcome: response.outcome,
34
+ ...(response.sandboxId ? { sandboxId: response.sandboxId } : {}),
35
+ };
36
+ }
11
37
  // ---- enum → lowercase string -----------------------------------------------
12
38
  // Exported for the enum-name drift test only; not re-exported from index.ts, so
13
39
  // they are not part of the public package API.
@@ -21,6 +47,7 @@ export const PHASE_NAMES = {
21
47
  [SandboxPhase.STOPPING]: 'stopping',
22
48
  [SandboxPhase.STOPPED]: 'stopped',
23
49
  [SandboxPhase.STARTING]: 'starting',
50
+ [SandboxPhase.COMPLETED]: 'completed',
24
51
  };
25
52
  export const STATUS_NAMES = {
26
53
  [ServiceStatus.UNSPECIFIED]: 'unspecified',
@@ -50,7 +77,7 @@ function scopeName(s) {
50
77
  function policySourceName(s) {
51
78
  return POLICY_SOURCE_NAMES[s] ?? 'unspecified';
52
79
  }
53
- function sandboxRef(sandbox) {
80
+ function sandboxRef(sandbox, serviceUrls = {}) {
54
81
  if (!sandbox)
55
82
  throw new SdkError('invalid_config', 'sandbox missing from gateway response');
56
83
  const meta = sandbox.metadata;
@@ -60,13 +87,26 @@ function sandboxRef(sandbox) {
60
87
  return {
61
88
  id: meta.id,
62
89
  name: meta.name,
90
+ workspace: meta.workspace,
63
91
  phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED),
64
92
  labels: meta?.labels ?? {},
65
93
  resourceVersion: (meta?.resourceVersion ?? 0n).toString(),
66
94
  mainProcessInstanceId: sandbox.status?.mainProcessInstanceId || undefined,
67
95
  exitCode: sandbox.status?.exitCode,
96
+ createdFromWorkloadTemplate: sandbox.createdFromWorkloadTemplate
97
+ ? {
98
+ name: sandbox.createdFromWorkloadTemplate.name,
99
+ resourceVersion: sandbox.createdFromWorkloadTemplate.resourceVersion,
100
+ }
101
+ : undefined,
102
+ serviceUrls,
68
103
  };
69
104
  }
105
+ function sandboxTemplate(template) {
106
+ if (!template)
107
+ throw new SdkError('invalid_config', 'sandbox template missing from gateway response');
108
+ return template;
109
+ }
70
110
  function providerRef(provider) {
71
111
  const meta = provider.metadata;
72
112
  return {
@@ -128,6 +168,30 @@ function versionPin(value) {
128
168
  return pin;
129
169
  }
130
170
  const FORWARD_CHUNK = 64 * 1024;
171
+ function workspaceName(options) {
172
+ const workspace = options?.workspace ?? 'default';
173
+ if (workspace.trim() === '')
174
+ throw new SdkError('invalid_config', 'workspace must be non-empty');
175
+ return workspace;
176
+ }
177
+ function workspaceScope(options) {
178
+ return { selection: { case: 'workspace', value: workspaceName(options) } };
179
+ }
180
+ function listWorkspaceScope(options) {
181
+ return options?.allWorkspaces ? { selection: { case: 'allWorkspaces', value: {} } } : workspaceScope(options);
182
+ }
183
+ function sandboxTarget(name, options) {
184
+ return { sandbox: name, workspaceScope: workspaceScope(options) };
185
+ }
186
+ function namedTarget(name, options) {
187
+ return { name, workspaceScope: workspaceScope(options) };
188
+ }
189
+ function requestCallOptions(options) {
190
+ if (!options)
191
+ return undefined;
192
+ const { workspace: _workspace, ...callOptions } = options;
193
+ return callOptions;
194
+ }
131
195
  // Build CallOptions that bound one poll RPC by the remaining wall-clock budget
132
196
  // and honor caller cancellation, so a stalled RPC cannot outlive the deadline.
133
197
  function deadlineOptions(remainingMs, signal) {
@@ -136,12 +200,12 @@ function deadlineOptions(remainingMs, signal) {
136
200
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
137
201
  };
138
202
  }
139
- // Translate a poll failure at the wait boundary: caller cancellation and
140
- // deadline expiry become explicit SdkErrors; anything else propagates.
141
- function mapWaitError(err, name, deadline, signal) {
203
+ // Translate a poll failure at the wait boundary: caller cancellation and the
204
+ // poll's deadline signal both become explicit SdkErrors; anything else propagates.
205
+ function mapWaitError(err, name, deadline, signal, pollSignal) {
142
206
  if (signal?.aborted)
143
207
  return new SdkError('connect', `wait for sandbox '${name}' aborted`);
144
- if (Date.now() >= deadline)
208
+ if (pollSignal?.aborted || Date.now() >= deadline)
145
209
  return new SdkError('connect', `timed out waiting for sandbox '${name}'`);
146
210
  return err instanceof SdkError ? err : fromConnect(err);
147
211
  }
@@ -245,6 +309,198 @@ export class Pushable {
245
309
  }
246
310
  }
247
311
  }
312
+ // Single producer/consumer queue. Closing wakes both directions, including a
313
+ // producer blocked by backpressure. Successful completion drains queued values.
314
+ class ExecOutputQueue {
315
+ values = [];
316
+ ended = false;
317
+ error;
318
+ reader;
319
+ writer;
320
+ async push(value) {
321
+ // The terminal exit carries no output bytes and must never block cleanup
322
+ // after done has already settled and its cancellation listener is removed.
323
+ while (!('type' in value) && this.values.length >= 16 && !this.ended) {
324
+ await new Promise((resolve) => {
325
+ this.writer = resolve;
326
+ });
327
+ }
328
+ if (this.ended)
329
+ throw this.error ?? new SdkError('canceled', 'exec output closed');
330
+ this.values.push(value);
331
+ this.reader?.();
332
+ this.reader = undefined;
333
+ }
334
+ end(error, discard = false) {
335
+ if (discard)
336
+ this.values.length = 0;
337
+ if (!this.ended) {
338
+ this.ended = true;
339
+ this.error = error;
340
+ }
341
+ this.reader?.();
342
+ this.writer?.();
343
+ this.reader = undefined;
344
+ this.writer = undefined;
345
+ }
346
+ async *[Symbol.asyncIterator]() {
347
+ for (;;) {
348
+ const value = this.values.shift();
349
+ if (value !== undefined) {
350
+ this.writer?.();
351
+ this.writer = undefined;
352
+ yield value;
353
+ }
354
+ else if (this.ended) {
355
+ if (this.error !== undefined)
356
+ throw this.error;
357
+ return;
358
+ }
359
+ else {
360
+ await new Promise((resolve) => {
361
+ this.reader = resolve;
362
+ });
363
+ }
364
+ }
365
+ }
366
+ }
367
+ const maxConsumedPageTokens = 10_000;
368
+ const maxConsumedPageTokenBytes = 1 << 20;
369
+ /** Lazy, single-pass iterator that fetches one RPC page per advance. */
370
+ export class Pager {
371
+ fetch;
372
+ maxConsumedTokens;
373
+ maxConsumedTokenBytes;
374
+ nextToken;
375
+ consumedTokens = new Set();
376
+ consumedTokenBytes = 0;
377
+ constructor(fetch, pageToken = '', maxConsumedTokens = maxConsumedPageTokens, maxConsumedTokenBytes = maxConsumedPageTokenBytes) {
378
+ this.fetch = fetch;
379
+ this.maxConsumedTokens = maxConsumedTokens;
380
+ this.maxConsumedTokenBytes = maxConsumedTokenBytes;
381
+ this.nextToken = pageToken;
382
+ }
383
+ validateCurrentTokenBudget(pageToken) {
384
+ if (pageToken === '')
385
+ return 0;
386
+ const tokenBytes = new TextEncoder().encode(pageToken).byteLength;
387
+ if (this.consumedTokens.size >= this.maxConsumedTokens ||
388
+ tokenBytes > this.maxConsumedTokenBytes - this.consumedTokenBytes) {
389
+ throw new Error('pager continuation token history limit exceeded');
390
+ }
391
+ return tokenBytes;
392
+ }
393
+ /** Fetch the next page, or return undefined after the final page. */
394
+ async nextPage() {
395
+ if (this.nextToken === undefined)
396
+ return undefined;
397
+ const pageToken = this.nextToken;
398
+ const tokenBytes = this.validateCurrentTokenBudget(pageToken);
399
+ const page = await this.fetch(pageToken);
400
+ if (pageToken !== '') {
401
+ this.consumedTokens.add(pageToken);
402
+ this.consumedTokenBytes += tokenBytes;
403
+ }
404
+ if (page.nextPageToken !== '' && this.consumedTokens.has(page.nextPageToken)) {
405
+ throw new Error('pager received a repeated continuation token');
406
+ }
407
+ this.nextToken = page.nextPageToken === '' ? undefined : page.nextPageToken;
408
+ return page;
409
+ }
410
+ /** Consume the pager and collect every remaining item. */
411
+ async all() {
412
+ const items = [];
413
+ for await (const page of this)
414
+ items.push(...page.items);
415
+ return items;
416
+ }
417
+ async *[Symbol.asyncIterator]() {
418
+ for (;;) {
419
+ const page = await this.nextPage();
420
+ if (page === undefined)
421
+ return;
422
+ yield page;
423
+ }
424
+ }
425
+ }
426
+ // ---- sandbox template client ----------------------------------------------
427
+ // Reusable sandbox workload template lifecycle. Templates intentionally return
428
+ // generated proto messages because the resource owns portable workload fields
429
+ // plus driver-specific config that should not be lossy in the curated layer.
430
+ export class SandboxTemplateClient {
431
+ grpc;
432
+ raw;
433
+ transport;
434
+ constructor(transport, grpc = createClient(OpenShell, transport)) {
435
+ this.transport = transport;
436
+ this.grpc = grpc;
437
+ this.raw = this.grpc;
438
+ }
439
+ static async connect(options) {
440
+ return new SandboxTemplateClient(buildTransport(options));
441
+ }
442
+ async create(template, options) {
443
+ try {
444
+ const resp = await this.grpc.createSandboxTemplate({
445
+ workspaceScope: workspaceScope(options),
446
+ template,
447
+ });
448
+ return sandboxTemplate(resp.template);
449
+ }
450
+ catch (e) {
451
+ throw e instanceof SdkError ? e : fromConnect(e);
452
+ }
453
+ }
454
+ async get(name, options) {
455
+ if (name.trim() === '')
456
+ throw new SdkError('invalid_config', 'template name is required');
457
+ try {
458
+ const resp = await this.grpc.getSandboxTemplate({
459
+ workspaceScope: workspaceScope(options),
460
+ name,
461
+ });
462
+ return sandboxTemplate(resp.template);
463
+ }
464
+ catch (e) {
465
+ throw e instanceof SdkError ? e : fromConnect(e);
466
+ }
467
+ }
468
+ list(options) {
469
+ return new Pager(async (pageToken) => {
470
+ try {
471
+ const resp = await this.grpc.listSandboxTemplates({
472
+ pageSize: options?.pageSize ?? 0,
473
+ pageToken,
474
+ labelSelector: options?.labelSelector ?? '',
475
+ workspaceScope: listWorkspaceScope(options),
476
+ });
477
+ return { items: resp.templates, nextPageToken: resp.nextPageToken };
478
+ }
479
+ catch (e) {
480
+ throw fromConnect(e);
481
+ }
482
+ }, options?.pageToken ?? '');
483
+ }
484
+ /** List and collect every sandbox template in this scope. */
485
+ async listAll(options) {
486
+ return this.list(options).all();
487
+ }
488
+ async delete(name, options) {
489
+ if (name.trim() === '')
490
+ throw new SdkError('invalid_config', 'template name is required');
491
+ try {
492
+ const resp = await this.grpc.deleteSandboxTemplate({
493
+ workspaceScope: workspaceScope(options),
494
+ allowMissing: options?.allowMissing ?? false,
495
+ name,
496
+ });
497
+ return deletionResult(resp);
498
+ }
499
+ catch (e) {
500
+ throw fromConnect(e);
501
+ }
502
+ }
503
+ }
248
504
  // ---- sandbox client --------------------------------------------------------
249
505
  // Sandbox lifecycle + exec. Usable standalone via `SandboxClient.connect()`,
250
506
  // or reached as `client.sandbox` on an OpenShellClient, which shares one
@@ -292,42 +548,86 @@ export class SandboxClient {
292
548
  if (spec.rawSpec)
293
549
  Object.assign(specInit, spec.rawSpec);
294
550
  const resp = await this.grpc.createSandbox({
551
+ workspaceScope: workspaceScope(spec),
295
552
  name: spec.name ?? '',
296
553
  labels: spec.labels ?? {},
297
554
  spec: specInit,
555
+ serviceExposures: spec.serviceExposures?.map((exposure) => ({
556
+ service: exposure.service ?? '',
557
+ targetPort: exposure.targetPort,
558
+ })) ?? [],
298
559
  });
299
- return sandboxRef(resp.sandbox);
560
+ return sandboxRef(resp.sandbox, resp.serviceUrls);
300
561
  }
301
562
  catch (e) {
302
563
  throw fromConnect(e);
303
564
  }
304
565
  }
305
- async get(name, callOptions) {
566
+ async createFromTemplate(spec) {
567
+ if (spec.workloadTemplate.trim() === '')
568
+ throw new SdkError('invalid_config', 'workloadTemplate is required');
306
569
  try {
307
- const resp = await this.grpc.getSandbox({ name }, callOptions);
308
- return sandboxRef(resp.sandbox);
570
+ const resp = await this.grpc.createSandbox({
571
+ workspaceScope: workspaceScope(spec),
572
+ name: spec.name ?? '',
573
+ labels: spec.labels ?? {},
574
+ spec: {
575
+ providers: spec.providers ?? [],
576
+ command: spec.command ?? [],
577
+ tty: spec.tty ?? false,
578
+ policy: spec.policy,
579
+ },
580
+ workloadTemplate: spec.workloadTemplate,
581
+ serviceExposures: spec.serviceExposures?.map((exposure) => ({
582
+ service: exposure.service ?? '',
583
+ targetPort: exposure.targetPort,
584
+ })) ?? [],
585
+ });
586
+ return sandboxRef(resp.sandbox, resp.serviceUrls);
309
587
  }
310
588
  catch (e) {
311
589
  throw fromConnect(e);
312
590
  }
313
591
  }
314
- async list(options) {
592
+ async get(name, options) {
315
593
  try {
316
- const resp = await this.grpc.listSandboxes({
317
- limit: options?.limit ?? 0,
318
- offset: options?.offset ?? 0,
319
- labelSelector: options?.labelSelector ?? '',
320
- });
321
- return resp.sandboxes.map((s) => sandboxRef(s));
594
+ const resp = await this.grpc.getSandbox({ ...namedTarget(name, options) }, requestCallOptions(options));
595
+ return sandboxRef(resp.sandbox);
322
596
  }
323
597
  catch (e) {
324
598
  throw fromConnect(e);
325
599
  }
326
600
  }
327
- async delete(name) {
601
+ list(options) {
602
+ return new Pager(async (pageToken) => {
603
+ try {
604
+ const resp = await this.grpc.listSandboxes({
605
+ pageSize: options?.pageSize ?? 0,
606
+ pageToken,
607
+ labelSelector: options?.labelSelector ?? '',
608
+ workspaceScope: listWorkspaceScope(options),
609
+ });
610
+ return {
611
+ items: resp.sandboxes.map((sandbox) => sandboxRef(sandbox)),
612
+ nextPageToken: resp.nextPageToken,
613
+ };
614
+ }
615
+ catch (e) {
616
+ throw fromConnect(e);
617
+ }
618
+ }, options?.pageToken ?? '');
619
+ }
620
+ /** List and collect every sandbox in this scope. */
621
+ async listAll(options) {
622
+ return this.list(options).all();
623
+ }
624
+ async delete(name, options) {
328
625
  try {
329
- const resp = await this.grpc.deleteSandbox({ name });
330
- return resp.deleted;
626
+ const resp = await this.grpc.deleteSandbox({
627
+ ...namedTarget(name, options),
628
+ allowMissing: options?.allowMissing ?? false,
629
+ });
630
+ return deletionResult(resp);
331
631
  }
332
632
  catch (e) {
333
633
  throw fromConnect(e);
@@ -346,14 +646,20 @@ export class SandboxClient {
346
646
  if (Date.now() >= deadline)
347
647
  throw new SdkError('connect', `timed out waiting for sandbox '${name}'`);
348
648
  let ref;
649
+ const pollOptions = deadlineOptions(deadline - Date.now(), signal);
349
650
  try {
350
- ref = await this.get(name, deadlineOptions(deadline - Date.now(), signal));
651
+ ref = await this.get(name, {
652
+ ...pollOptions,
653
+ workspace: options?.workspace,
654
+ });
351
655
  }
352
656
  catch (e) {
353
- throw mapWaitError(e, name, deadline, signal);
657
+ throw mapWaitError(e, name, deadline, signal, pollOptions.signal);
354
658
  }
355
- if (ref.phase === 'ready')
659
+ if (ref.phase === 'ready' || ref.phase === 'completed')
356
660
  return ref;
661
+ if (ref.phase === 'stopped')
662
+ throw new SdkError('connect', `sandbox '${name}' stopped before becoming ready`);
357
663
  if (ref.phase === 'error')
358
664
  throw new SdkError('connect', `sandbox '${name}' entered error phase`);
359
665
  if (Date.now() >= deadline)
@@ -362,8 +668,8 @@ export class SandboxClient {
362
668
  delay = Math.min(delay * 2, 2000);
363
669
  }
364
670
  }
365
- // Poll until the sandbox is gone. Timeout and cancellation bound the returned
366
- // promise the same way as waitReady.
671
+ // Poll until the sandbox is gone, or its name resolves to a different ID when
672
+ // expectedSandboxId is supplied. Timeout and cancellation work as in waitReady.
367
673
  async waitDeleted(name, timeoutSecs, options) {
368
674
  const deadline = Date.now() + timeoutSecs * 1000;
369
675
  const signal = options?.signal;
@@ -373,13 +679,16 @@ export class SandboxClient {
373
679
  throw new SdkError('connect', `wait for sandbox '${name}' aborted`);
374
680
  if (Date.now() >= deadline)
375
681
  throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`);
682
+ const pollOptions = deadlineOptions(deadline - Date.now(), signal);
376
683
  try {
377
- await this.get(name, deadlineOptions(deadline - Date.now(), signal));
684
+ const ref = await this.get(name, { ...pollOptions, workspace: options?.workspace });
685
+ if (options?.expectedSandboxId !== undefined && ref.id !== options.expectedSandboxId)
686
+ return;
378
687
  }
379
688
  catch (e) {
380
689
  if (e instanceof SdkError && e.code === 'not_found')
381
690
  return;
382
- throw mapWaitError(e, name, deadline, signal);
691
+ throw mapWaitError(e, name, deadline, signal, pollOptions.signal);
383
692
  }
384
693
  if (Date.now() >= deadline)
385
694
  throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`);
@@ -394,16 +703,20 @@ export class SandboxClient {
394
703
  // path to reconstruct the buffered result.
395
704
  async *execStream(name, command, options) {
396
705
  try {
397
- // Resolve the sandbox id first, exactly like the gateway client.
398
- const sandbox = await this.get(name, options?.signal ? { signal: options.signal } : undefined);
706
+ // Preserve the existing preflight so lookup failures surface before the stream starts.
707
+ await this.get(name, {
708
+ workspace: options?.workspace,
709
+ ...(options?.signal ? { signal: options.signal } : {}),
710
+ });
399
711
  const stream = this.grpc.execSandbox({
400
- sandboxId: sandbox.id,
712
+ ...sandboxTarget(name, options),
401
713
  command,
402
714
  workdir: options?.workdir ?? '',
403
715
  environment: options?.environment ?? {},
404
- timeoutSeconds: options?.timeoutSecs ?? 0,
716
+ executionTimeout: durationFromSeconds(options?.timeoutSecs ?? 0),
405
717
  stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(),
406
718
  tty: false,
719
+ noLoginShell: options?.noLoginShell ?? false,
407
720
  }, { signal: options?.signal });
408
721
  let sawExit = false;
409
722
  for await (const event of stream) {
@@ -461,9 +774,8 @@ export class SandboxClient {
461
774
  // `stdin`/`resize`. No terminal glue: raw mode, signal forwarding, and
462
775
  // SIGWINCH stay with the caller.
463
776
  async execInteractive(name, command, options) {
464
- let sandboxId;
465
777
  try {
466
- sandboxId = (await this.get(name, options?.signal ? { signal: options.signal } : undefined)).id;
778
+ await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}) });
467
779
  }
468
780
  catch (e) {
469
781
  throw e instanceof SdkError ? e : fromConnect(e);
@@ -473,19 +785,33 @@ export class SandboxClient {
473
785
  payload: {
474
786
  case: 'start',
475
787
  value: {
476
- sandboxId,
788
+ ...sandboxTarget(name, options),
477
789
  command,
478
790
  workdir: options?.workdir ?? '',
479
791
  environment: options?.environment ?? {},
480
- timeoutSeconds: options?.timeoutSecs ?? 0,
792
+ executionTimeout: durationFromSeconds(options?.timeoutSecs ?? 0),
481
793
  stdin: new Uint8Array(),
482
794
  tty: options?.tty ?? true,
483
795
  cols: options?.cols ?? 0,
484
796
  rows: options?.rows ?? 0,
797
+ noLoginShell: options?.noLoginShell ?? false,
485
798
  },
486
799
  },
487
800
  });
488
- const stream = this.grpc.execSandboxInteractive(input, { signal: options?.signal });
801
+ const controller = new AbortController();
802
+ const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
803
+ const grpc = this.grpc;
804
+ const queue = new ExecOutputQueue();
805
+ let inputClosed = false;
806
+ let exitCode;
807
+ const closeInput = () => {
808
+ inputClosed = true;
809
+ input.end();
810
+ };
811
+ const assertInputOpen = () => {
812
+ if (inputClosed || signal.aborted)
813
+ throw new SdkError('io', 'exec input is closed');
814
+ };
489
815
  let resolveDone;
490
816
  let rejectDone;
491
817
  const done = new Promise((resolve, reject) => {
@@ -496,74 +822,118 @@ export class SandboxClient {
496
822
  // keeps an unobserved rejection from surfacing as an unhandledRejection;
497
823
  // real awaiters still receive it through their own handler.
498
824
  void done.catch(() => { });
499
- // Settle exactly once. The exit code wins; error/abandonment only apply
500
- // when no exit was observed.
825
+ // The process exit and the terminal transport status are separate outcomes.
501
826
  let settled = false;
502
827
  const settleExit = (code) => {
503
828
  if (settled)
504
829
  return;
505
830
  settled = true;
831
+ signal.removeEventListener('abort', onAbort);
506
832
  resolveDone(code);
507
833
  };
508
834
  const settleError = (err) => {
509
835
  if (settled)
510
836
  return;
511
837
  settled = true;
838
+ signal.removeEventListener('abort', onAbort);
512
839
  rejectDone(err);
513
840
  };
514
- async function* output() {
515
- let sawExit = false;
841
+ const onAbort = () => {
842
+ closeInput();
843
+ const error = new SdkError('canceled', 'exec cancelled');
844
+ queue.end(error, true);
845
+ settleError(error);
846
+ };
847
+ signal.addEventListener('abort', onAbort, { once: true });
848
+ if (signal.aborted)
849
+ onAbort();
850
+ async function receive() {
516
851
  try {
852
+ if (signal.aborted)
853
+ throw new SdkError('canceled', 'exec cancelled');
854
+ // Start and observe the transport immediately, independently of output
855
+ // consumption. Backpressure bounds the queue to 16 chunks of 64 KiB.
856
+ const stream = grpc.execSandboxInteractive(input, { signal });
517
857
  for await (const event of stream) {
858
+ if (signal.aborted)
859
+ throw new SdkError('canceled', 'exec cancelled');
860
+ if (exitCode !== undefined) {
861
+ throw new SdkError('rpc', 'ExecSandboxInteractive received an event after exit');
862
+ }
518
863
  switch (event.payload.case) {
519
864
  case 'stdout':
520
- yield {
521
- stream: 'stdout',
522
- data: Buffer.from(event.payload.value.data),
523
- };
524
- break;
525
865
  case 'stderr':
526
- yield {
527
- stream: 'stderr',
528
- data: Buffer.from(event.payload.value.data),
529
- };
866
+ for (let offset = 0; offset < event.payload.value.data.length; offset += 64 * 1024) {
867
+ await queue.push({
868
+ stream: event.payload.case,
869
+ data: Buffer.from(event.payload.value.data.subarray(offset, offset + 64 * 1024)),
870
+ });
871
+ }
530
872
  break;
531
873
  case 'exit':
532
- sawExit = true;
533
- // Settle `done` before yielding: a consumer that breaks on the
534
- // exit event abandons the generator at the yield, so anything
535
- // after it would never run.
536
- settleExit(event.payload.value.exitCode);
537
- yield { type: 'exit', exitCode: event.payload.value.exitCode };
874
+ exitCode = event.payload.value.exitCode;
875
+ closeInput();
538
876
  break;
877
+ default:
878
+ throw new SdkError('rpc', 'ExecSandboxInteractive received an empty or unknown event');
539
879
  }
540
880
  }
541
- if (!sawExit) {
881
+ if (exitCode === undefined) {
542
882
  throw new SdkError('rpc', 'ExecSandboxInteractive stream ended without an exit event');
543
883
  }
884
+ if (signal.aborted)
885
+ throw new SdkError('canceled', 'exec cancelled before completion');
886
+ // Delay the public exit event until trailers have been consumed. A
887
+ // caller can still break on exit without losing the terminal status.
888
+ settleExit(exitCode);
889
+ await queue.push({ type: 'exit', exitCode });
890
+ queue.end();
544
891
  }
545
892
  catch (e) {
546
893
  const err = e instanceof SdkError ? e : fromConnect(e);
547
894
  settleError(err);
548
- throw err;
895
+ queue.end(err);
896
+ }
897
+ finally {
898
+ closeInput();
899
+ controller.abort();
900
+ }
901
+ }
902
+ // receive catches transport failures even when nobody consumes output/done.
903
+ const receiving = receive();
904
+ async function* output() {
905
+ try {
906
+ yield* queue;
549
907
  }
550
908
  finally {
551
- input.end();
552
- // Consumer abandoned the stream before an exit event (early break or
553
- // return): settle `done` so it can never hang.
554
- settleError(new SdkError('rpc', 'exec output abandoned before exit'));
909
+ const error = new SdkError('rpc', 'exec output abandoned before completion');
910
+ settleError(error);
911
+ queue.end(error, true);
912
+ closeInput();
913
+ controller.abort();
914
+ await receiving;
555
915
  }
556
916
  }
557
917
  return {
558
918
  output: output(),
559
919
  write(data) {
920
+ assertInputOpen();
560
921
  input.push({ payload: { case: 'stdin', value: new Uint8Array(data) } });
561
922
  },
562
923
  resize(cols, rows) {
924
+ assertInputOpen();
563
925
  input.push({ payload: { case: 'resize', value: { cols, rows } } });
564
926
  },
565
- close() {
566
- input.end();
927
+ closeInput,
928
+ close: closeInput,
929
+ cancel() {
930
+ closeInput();
931
+ queue.end(new SdkError('canceled', 'exec cancelled'), true);
932
+ controller.abort();
933
+ settleError(new SdkError('canceled', 'exec cancelled'));
934
+ },
935
+ get exitCode() {
936
+ return exitCode;
567
937
  },
568
938
  done,
569
939
  };
@@ -580,7 +950,7 @@ export class SandboxClient {
580
950
  const localPort = opts.localPort ?? 0;
581
951
  let sandboxId;
582
952
  try {
583
- const ref = await this.get(name, opts.signal ? { signal: opts.signal } : undefined);
953
+ const ref = await this.get(name, { workspace: opts.workspace, ...(opts.signal ? { signal: opts.signal } : {}) });
584
954
  if (ref.phase !== 'ready') {
585
955
  throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`);
586
956
  }
@@ -603,7 +973,7 @@ export class SandboxClient {
603
973
  socket.on('error', () => { });
604
974
  const controller = new AbortController();
605
975
  controllers.add(controller);
606
- const task = this.forwardConnection(socket, sandboxId, name, targetHost, targetPort, controller.signal)
976
+ const task = this.forwardConnection(socket, sandboxId, name, opts.workspace, targetHost, targetPort, controller.signal)
607
977
  .catch((error) => {
608
978
  if (!closing) {
609
979
  try {
@@ -674,12 +1044,12 @@ export class SandboxClient {
674
1044
  closed,
675
1045
  };
676
1046
  }
677
- async forwardConnection(socket, sandboxId, name, targetHost, targetPort, signal) {
1047
+ async forwardConnection(socket, sandboxId, name, workspace, targetHost, targetPort, signal) {
678
1048
  let token;
679
1049
  const input = new Pushable();
680
1050
  input.onDrain = () => socket.resume();
681
1051
  try {
682
- const session = await this.grpc.createSshSession({ sandboxId }, { signal });
1052
+ const session = await this.grpc.createSshSession({ ...sandboxTarget(name, { workspace }) }, { signal });
683
1053
  // Defense-in-depth: the token feeds forwardTcp authorization, so hold it
684
1054
  // to the same trust-boundary contract as createSshSession. A violation
685
1055
  // tears down this one socket via the catch below.
@@ -689,7 +1059,7 @@ export class SandboxClient {
689
1059
  payload: {
690
1060
  case: 'init',
691
1061
  value: {
692
- sandboxId,
1062
+ ...sandboxTarget(name, { workspace }),
693
1063
  serviceId: `service-forward:${name}:${targetHost}:${targetPort}`,
694
1064
  target: {
695
1065
  case: 'tcp',
@@ -743,7 +1113,7 @@ export class SandboxClient {
743
1113
  input.end();
744
1114
  if (token !== undefined) {
745
1115
  try {
746
- await this.grpc.revokeSshSession({ token }, { signal });
1116
+ await this.grpc.revokeSshSession({ token, allowMissing: true }, { signal });
747
1117
  }
748
1118
  catch {
749
1119
  // Best-effort revoke; the token expires on its own regardless.
@@ -753,10 +1123,10 @@ export class SandboxClient {
753
1123
  }
754
1124
  // Mint a short-lived SSH session token for the sandbox — the input side of
755
1125
  // ssh-config / ProxyCommand and forwardTcp authorization.
756
- async createSshSession(name) {
1126
+ async createSshSession(name, options) {
757
1127
  try {
758
- const sandbox = await this.get(name);
759
- const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id });
1128
+ const sandbox = await this.get(name, options);
1129
+ const resp = await this.grpc.createSshSession({ ...sandboxTarget(name, options) });
760
1130
  // Reject any response outside the proto trust-boundary contract before
761
1131
  // handing these values to the caller (they feed OpenSSH ProxyCommand).
762
1132
  validateSshResponse(resp, sandbox.id);
@@ -767,17 +1137,17 @@ export class SandboxClient {
767
1137
  gatewayPort: resp.gatewayPort,
768
1138
  gatewayScheme: resp.gatewayScheme,
769
1139
  ...(resp.hostKeyFingerprint ? { hostKeyFingerprint: resp.hostKeyFingerprint } : {}),
770
- ...(resp.expiresAtMs !== 0n ? { expiresAtMs: resp.expiresAtMs.toString() } : {}),
1140
+ ...(timestampMillis(resp.expirationTime) ? { expiresAtMs: timestampMillis(resp.expirationTime) } : {}),
771
1141
  };
772
1142
  }
773
1143
  catch (e) {
774
1144
  throw e instanceof SdkError ? e : fromConnect(e);
775
1145
  }
776
1146
  }
777
- async revokeSshSession(token) {
1147
+ async revokeSshSession(token, options) {
778
1148
  try {
779
- const resp = await this.grpc.revokeSshSession({ token });
780
- return resp.revoked;
1149
+ const resp = await this.grpc.revokeSshSession({ token, allowMissing: options?.allowMissing ?? false });
1150
+ return deletionResult(resp);
781
1151
  }
782
1152
  catch (e) {
783
1153
  throw fromConnect(e);
@@ -786,8 +1156,8 @@ export class SandboxClient {
786
1156
  async attachProvider(name, provider, options) {
787
1157
  try {
788
1158
  const resp = await this.grpc.attachSandboxProvider({
789
- sandboxName: name,
790
- providerName: provider,
1159
+ ...sandboxTarget(name, options),
1160
+ provider,
791
1161
  expectedResourceVersion: versionPin(options?.expectedResourceVersion),
792
1162
  });
793
1163
  return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached };
@@ -799,8 +1169,8 @@ export class SandboxClient {
799
1169
  async detachProvider(name, provider, options) {
800
1170
  try {
801
1171
  const resp = await this.grpc.detachSandboxProvider({
802
- sandboxName: name,
803
- providerName: provider,
1172
+ ...sandboxTarget(name, options),
1173
+ provider,
804
1174
  expectedResourceVersion: versionPin(options?.expectedResourceVersion),
805
1175
  });
806
1176
  return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached };
@@ -809,19 +1179,32 @@ export class SandboxClient {
809
1179
  throw fromConnect(e);
810
1180
  }
811
1181
  }
812
- async listProviders(name) {
813
- try {
814
- const resp = await this.grpc.listSandboxProviders({ sandboxName: name });
815
- return resp.providers.map((p) => providerRef(p));
816
- }
817
- catch (e) {
818
- throw fromConnect(e);
819
- }
1182
+ listProviders(name, options) {
1183
+ return new Pager(async (pageToken) => {
1184
+ try {
1185
+ const resp = await this.grpc.listSandboxProviders({
1186
+ ...sandboxTarget(name, options),
1187
+ pageSize: options?.pageSize ?? 0,
1188
+ pageToken,
1189
+ });
1190
+ return {
1191
+ items: resp.providers.map((provider) => providerRef(provider)),
1192
+ nextPageToken: resp.nextPageToken,
1193
+ };
1194
+ }
1195
+ catch (e) {
1196
+ throw fromConnect(e);
1197
+ }
1198
+ }, options?.pageToken ?? '');
1199
+ }
1200
+ /** List and collect every provider attached to this sandbox. */
1201
+ async listAllProviders(name, options) {
1202
+ return this.listProviders(name, options).all();
820
1203
  }
821
- async getConfig(name, callOptions) {
1204
+ async getConfig(name, options) {
822
1205
  try {
823
- const sandbox = await this.get(name, callOptions);
824
- const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }, callOptions);
1206
+ await this.get(name, options);
1207
+ const resp = await this.grpc.getSandboxConfig({ ...namedTarget(name, options) }, requestCallOptions(options));
825
1208
  return sandboxConfig(resp);
826
1209
  }
827
1210
  catch (e) {
@@ -835,14 +1218,14 @@ export class SandboxClient {
835
1218
  async setPolicy(name, policy, options) {
836
1219
  try {
837
1220
  const resp = await this.grpc.updateConfig({
838
- name,
1221
+ ...sandboxTarget(name, options),
839
1222
  policy,
840
1223
  global: false,
841
1224
  expectedResourceVersion: versionPin(options?.expectedResourceVersion),
842
1225
  });
843
1226
  const result = updateConfigResult(resp);
844
1227
  if (options?.wait)
845
- await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs);
1228
+ await this.waitForPolicyHash(name, result.policyHash, options.waitTimeoutSecs, options.workspace);
846
1229
  return result;
847
1230
  }
848
1231
  catch (e) {
@@ -851,10 +1234,10 @@ export class SandboxClient {
851
1234
  }
852
1235
  // Upsert a single sandbox-scoped setting. Sandbox-scoped deletes are rejected
853
1236
  // by the gateway, so there is no sandbox-scoped delete on this surface.
854
- async setSetting(name, key, value) {
1237
+ async setSetting(name, key, value, options) {
855
1238
  try {
856
1239
  const resp = await this.grpc.updateConfig({
857
- name,
1240
+ ...sandboxTarget(name, options),
858
1241
  settingKey: key,
859
1242
  settingValue: value,
860
1243
  global: false,
@@ -868,16 +1251,17 @@ export class SandboxClient {
868
1251
  // Poll getConfig until the applied policy hash is observed. Each poll RPC is
869
1252
  // bounded by the remaining deadline (deadlineOptions), so a stalled getConfig
870
1253
  // cannot make the returned promise outlive timeoutSecs.
871
- async waitForPolicyHash(name, policyHash, timeoutSecs = 60) {
1254
+ async waitForPolicyHash(name, policyHash, timeoutSecs = 60, workspace) {
872
1255
  const deadline = Date.now() + timeoutSecs * 1000;
873
1256
  let delay = 100;
874
1257
  for (;;) {
875
1258
  let config;
1259
+ const pollOptions = deadlineOptions(deadline - Date.now());
876
1260
  try {
877
- config = await this.getConfig(name, deadlineOptions(deadline - Date.now()));
1261
+ config = await this.getConfig(name, { ...pollOptions, workspace });
878
1262
  }
879
1263
  catch (e) {
880
- if (Date.now() >= deadline) {
1264
+ if (pollOptions.signal?.aborted || Date.now() >= deadline) {
881
1265
  throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`);
882
1266
  }
883
1267
  throw e instanceof SdkError ? e : fromConnect(e);
@@ -896,6 +1280,8 @@ export class SandboxClient {
896
1280
  export class OpenShellClient {
897
1281
  /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */
898
1282
  sandbox;
1283
+ /** Reusable sandbox workload template lifecycle. */
1284
+ sandboxTemplates;
899
1285
  /**
900
1286
  * Advanced escape hatch: a generated client for every gateway RPC, including
901
1287
  * surface the curated sub-clients do not wrap yet (gateway config, provider
@@ -912,6 +1298,7 @@ export class OpenShellClient {
912
1298
  this.grpc = createClient(OpenShell, transport);
913
1299
  this.raw = this.grpc;
914
1300
  this.sandbox = new SandboxClient(transport, this.grpc);
1301
+ this.sandboxTemplates = new SandboxTemplateClient(transport, this.grpc);
915
1302
  }
916
1303
  /**
917
1304
  * Constructs a lazy Connect client. No network request is made until the