@agentuity/sandbox 3.0.12 → 3.1.1

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.
Files changed (122) hide show
  1. package/AGENTS.md +3 -3
  2. package/dist/api-reference.d.ts +1221 -0
  3. package/dist/api-reference.d.ts.map +1 -0
  4. package/dist/api-reference.js +1046 -0
  5. package/dist/api-reference.js.map +1 -0
  6. package/dist/base64.d.ts +2 -0
  7. package/dist/base64.d.ts.map +1 -0
  8. package/dist/base64.js +14 -0
  9. package/dist/base64.js.map +1 -0
  10. package/dist/client.d.ts +431 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +632 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/create.d.ts +203 -0
  15. package/dist/create.d.ts.map +1 -0
  16. package/dist/create.js +235 -0
  17. package/dist/create.js.map +1 -0
  18. package/dist/destroy.d.ts +23 -0
  19. package/dist/destroy.d.ts.map +1 -0
  20. package/dist/destroy.js +30 -0
  21. package/dist/destroy.js.map +1 -0
  22. package/dist/disk-checkpoint.d.ts +108 -0
  23. package/dist/disk-checkpoint.d.ts.map +1 -0
  24. package/dist/disk-checkpoint.js +124 -0
  25. package/dist/disk-checkpoint.js.map +1 -0
  26. package/dist/events.d.ts +56 -0
  27. package/dist/events.d.ts.map +1 -0
  28. package/dist/events.js +54 -0
  29. package/dist/events.js.map +1 -0
  30. package/dist/execute.d.ts +99 -0
  31. package/dist/execute.d.ts.map +1 -0
  32. package/dist/execute.js +138 -0
  33. package/dist/execute.js.map +1 -0
  34. package/dist/execution.d.ts +150 -0
  35. package/dist/execution.d.ts.map +1 -0
  36. package/dist/execution.js +120 -0
  37. package/dist/execution.js.map +1 -0
  38. package/dist/files.d.ts +283 -0
  39. package/dist/files.d.ts.map +1 -0
  40. package/dist/files.js +471 -0
  41. package/dist/files.js.map +1 -0
  42. package/dist/get.d.ts +288 -0
  43. package/dist/get.d.ts.map +1 -0
  44. package/dist/get.js +256 -0
  45. package/dist/get.js.map +1 -0
  46. package/dist/getStatus.d.ts +23 -0
  47. package/dist/getStatus.d.ts.map +1 -0
  48. package/dist/getStatus.js +53 -0
  49. package/dist/getStatus.js.map +1 -0
  50. package/dist/index.d.ts +42 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +22 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/job.d.ts +227 -0
  55. package/dist/job.d.ts.map +1 -0
  56. package/dist/job.js +109 -0
  57. package/dist/job.js.map +1 -0
  58. package/dist/list.d.ts +330 -0
  59. package/dist/list.d.ts.map +1 -0
  60. package/dist/list.js +209 -0
  61. package/dist/list.js.map +1 -0
  62. package/dist/pause.d.ts +39 -0
  63. package/dist/pause.d.ts.map +1 -0
  64. package/dist/pause.js +48 -0
  65. package/dist/pause.js.map +1 -0
  66. package/dist/resolve.d.ts +75 -0
  67. package/dist/resolve.d.ts.map +1 -0
  68. package/dist/resolve.js +76 -0
  69. package/dist/resolve.js.map +1 -0
  70. package/dist/resume.d.ts +23 -0
  71. package/dist/resume.d.ts.map +1 -0
  72. package/dist/resume.js +30 -0
  73. package/dist/resume.js.map +1 -0
  74. package/dist/run.d.ts +73 -0
  75. package/dist/run.d.ts.map +1 -0
  76. package/dist/run.js +568 -0
  77. package/dist/run.js.map +1 -0
  78. package/dist/runtime.d.ts +94 -0
  79. package/dist/runtime.d.ts.map +1 -0
  80. package/dist/runtime.js +82 -0
  81. package/dist/runtime.js.map +1 -0
  82. package/dist/snapshot-build.d.ts +48 -0
  83. package/dist/snapshot-build.d.ts.map +1 -0
  84. package/dist/snapshot-build.js +72 -0
  85. package/dist/snapshot-build.js.map +1 -0
  86. package/dist/snapshot.d.ts +596 -0
  87. package/dist/snapshot.d.ts.map +1 -0
  88. package/dist/snapshot.js +612 -0
  89. package/dist/snapshot.js.map +1 -0
  90. package/dist/types.d.ts +1010 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +853 -0
  93. package/dist/types.js.map +1 -0
  94. package/dist/util.d.ts +296 -0
  95. package/dist/util.d.ts.map +1 -0
  96. package/dist/util.js +234 -0
  97. package/dist/util.js.map +1 -0
  98. package/package.json +7 -2
  99. package/src/api-reference.ts +1094 -0
  100. package/src/base64.ts +14 -0
  101. package/src/client.ts +998 -0
  102. package/src/create.ts +273 -0
  103. package/src/destroy.ts +43 -0
  104. package/src/disk-checkpoint.ts +184 -0
  105. package/src/events.ts +72 -0
  106. package/src/execute.ts +167 -0
  107. package/src/execution.ts +152 -0
  108. package/src/files.ts +637 -0
  109. package/src/get.ts +291 -0
  110. package/src/getStatus.ts +72 -0
  111. package/src/index.ts +252 -18
  112. package/src/job.ts +161 -0
  113. package/src/list.ts +239 -0
  114. package/src/pause.ts +75 -0
  115. package/src/resolve.ts +96 -0
  116. package/src/resume.ts +41 -0
  117. package/src/run.ts +783 -0
  118. package/src/runtime.ts +106 -0
  119. package/src/snapshot-build.ts +94 -0
  120. package/src/snapshot.ts +791 -0
  121. package/src/types.ts +1033 -0
  122. package/src/util.ts +280 -0
package/src/run.ts ADDED
@@ -0,0 +1,783 @@
1
+ import type { Logger } from '@agentuity/client';
2
+ import type { Readable, Writable } from 'node:stream';
3
+ import { PassThrough } from 'node:stream';
4
+ import { finished } from 'node:stream/promises';
5
+ import { z } from 'zod';
6
+ import { APIClient, PaymentRequiredError } from '@agentuity/api';
7
+ import { sandboxCreate } from './create.ts';
8
+ import { sandboxDestroy } from './destroy.ts';
9
+ import { executionGet } from './execution.ts';
10
+ import { sandboxGetStatus } from './getStatus.ts';
11
+ import { ExecutionCancelledError, writeAndDrain } from './util.ts';
12
+ import { SandboxRunOptionsSchema, type SandboxRunResult } from './types.ts';
13
+ import { getServiceUrls } from '@agentuity/config';
14
+
15
+ const timingLogsEnabled = false;
16
+ const EXECUTION_WAIT_DURATION = '5m';
17
+ const EXIT_CODE_FAST_WAIT_DURATION = '250ms';
18
+ const TERMINAL_EXECUTION_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled']);
19
+
20
+ /**
21
+ * Creates a Writable stream that captures all chunks to a buffer array
22
+ * and optionally tees (forwards) them to one or more user-provided streams.
23
+ *
24
+ * @param chunks - Array to collect Buffer chunks into
25
+ * @param userStreams - Optional user-provided Writable stream(s) to forward chunks to
26
+ * @returns A Writable stream that captures and optionally forwards data
27
+ */
28
+ function createTeeWritable(chunks: Buffer[], ...userStreams: (Writable | undefined)[]): Writable {
29
+ const tee = new PassThrough();
30
+
31
+ // Always capture chunks to the buffer
32
+ tee.on('data', (chunk: Buffer) => {
33
+ chunks.push(chunk);
34
+ });
35
+
36
+ // Pipe to all provided user streams with proper backpressure handling
37
+ for (const userStream of userStreams) {
38
+ if (userStream) {
39
+ tee.pipe(userStream, { end: false });
40
+ }
41
+ }
42
+
43
+ return tee;
44
+ }
45
+
46
+ export const SandboxRunParamsSchema = z.object({
47
+ options: SandboxRunOptionsSchema.describe('sandbox run options'),
48
+ orgId: z.string().optional().describe('organization id'),
49
+ region: z.string().optional().describe('region id'),
50
+ apiKey: z.string().optional().describe('api key'),
51
+ signal: z.custom<AbortSignal>().optional().describe('abort signal'),
52
+ stdin: z.custom<Readable>().optional().describe('stdin readable stream'),
53
+ stdout: z.custom<Writable>().optional().describe('stdout writable stream'),
54
+ stderr: z.custom<Writable>().optional().describe('stderr writable stream'),
55
+ logger: z.custom<Logger>().optional().describe('logger instance'),
56
+ });
57
+ export type SandboxRunParams = z.infer<typeof SandboxRunParamsSchema>;
58
+
59
+ /**
60
+ * Creates a sandbox, executes a command, and waits for completion.
61
+ *
62
+ * This is a high-level convenience function that handles the full lifecycle:
63
+ * creating a sandbox, streaming I/O, polling for completion, and cleanup.
64
+ *
65
+ * @param client - The API client to use for the request
66
+ * @param params - Parameters including command options, I/O streams, and timeout settings
67
+ * @returns The run result including exit code and duration
68
+ * @throws {SandboxResponseError} If sandbox creation fails, execution times out, or is cancelled
69
+ */
70
+ export async function sandboxRun(
71
+ client: APIClient,
72
+ params: SandboxRunParams
73
+ ): Promise<SandboxRunResult> {
74
+ const { options, orgId, region, apiKey, signal, stdin, stdout, stderr, logger } = params;
75
+ const started = Date.now();
76
+ if (timingLogsEnabled) console.error(`[TIMING] +0ms: sandbox run started`);
77
+
78
+ let stdinStreamId: string | undefined;
79
+ let stdinStreamUrl: string | undefined;
80
+
81
+ // Handle stdin stream configuration:
82
+ // - If stdin is "ignore", pass it through to skip stdin handling on server
83
+ // - If stdin is an explicit stream ID, use it directly
84
+ // - If stdin readable is provided, create a stream for it
85
+ const stdinConfig = options.stream?.stdin;
86
+ if (stdinConfig === 'ignore') {
87
+ stdinStreamId = 'ignore';
88
+ logger?.debug('stdin explicitly ignored');
89
+ } else if (stdinConfig && stdinConfig !== 'ignore') {
90
+ // User provided an explicit stream ID
91
+ stdinStreamId = stdinConfig;
92
+ logger?.debug('using provided stdin stream ID: %s', stdinStreamId);
93
+ } else if (stdin && region && apiKey) {
94
+ const streamResult = await createStdinStream(region, apiKey, orgId, logger);
95
+ stdinStreamId = streamResult.id;
96
+ stdinStreamUrl = streamResult.url;
97
+ logger?.debug('created stdin stream: %s', stdinStreamId);
98
+ }
99
+
100
+ const createResponse = await sandboxCreate(client, {
101
+ options: {
102
+ ...options,
103
+ command: {
104
+ exec: options.command.exec,
105
+ files: options.command.files,
106
+ mode: 'oneshot',
107
+ },
108
+ stream: {
109
+ ...options.stream,
110
+ stdin: stdinStreamId,
111
+ },
112
+ },
113
+ orgId,
114
+ });
115
+
116
+ const sandboxId = createResponse.sandboxId;
117
+ const stdoutStreamUrl = createResponse.stdoutStreamUrl;
118
+ const stderrStreamUrl = createResponse.stderrStreamUrl;
119
+
120
+ logger?.debug(
121
+ 'sandbox created: %s, stdoutUrl: %s, stderrUrl: %s',
122
+ sandboxId,
123
+ stdoutStreamUrl ?? 'none',
124
+ stderrStreamUrl ?? 'none'
125
+ );
126
+ if (timingLogsEnabled)
127
+ console.error(`[TIMING] +${Date.now() - started}ms: sandbox created (${sandboxId})`);
128
+
129
+ const abortController = new AbortController();
130
+ const streamPromises: Promise<void>[] = [];
131
+
132
+ // Create capture buffers for stdout/stderr
133
+ const stdoutChunks: Buffer[] = [];
134
+ const stderrChunks: Buffer[] = [];
135
+
136
+ try {
137
+ // Start stdin streaming if we have stdin and a stream URL
138
+ if (stdin && stdinStreamUrl && apiKey) {
139
+ const stdinPromise = streamStdinToUrl(
140
+ stdin,
141
+ stdinStreamUrl,
142
+ apiKey,
143
+ abortController.signal,
144
+ logger
145
+ );
146
+ streamPromises.push(stdinPromise);
147
+ }
148
+
149
+ // Check if stdout and stderr are the same stream (combined output)
150
+ const isCombinedOutput =
151
+ stdoutStreamUrl && stderrStreamUrl && stdoutStreamUrl === stderrStreamUrl;
152
+
153
+ if (isCombinedOutput) {
154
+ // Stream combined output to stdout only to avoid duplicates
155
+ if (stdoutStreamUrl) {
156
+ logger?.debug('using combined output stream (stdout === stderr)');
157
+ const teeStream = createTeeWritable(stdoutChunks, stdout);
158
+ const combinedPromise = streamUrlToWritable(
159
+ stdoutStreamUrl,
160
+ teeStream,
161
+ abortController.signal,
162
+ logger,
163
+ started
164
+ );
165
+ streamPromises.push(combinedPromise);
166
+ }
167
+ } else {
168
+ // Start stdout streaming with capture
169
+ if (stdoutStreamUrl) {
170
+ const teeStream = createTeeWritable(stdoutChunks, stdout);
171
+ const stdoutPromise = streamUrlToWritable(
172
+ stdoutStreamUrl,
173
+ teeStream,
174
+ abortController.signal,
175
+ logger,
176
+ started
177
+ );
178
+ streamPromises.push(stdoutPromise);
179
+ }
180
+
181
+ // Start stderr streaming with capture
182
+ if (stderrStreamUrl) {
183
+ const teeStream = createTeeWritable(stderrChunks, stderr);
184
+ const stderrPromise = streamUrlToWritable(
185
+ stderrStreamUrl,
186
+ teeStream,
187
+ abortController.signal,
188
+ logger,
189
+ started
190
+ );
191
+ streamPromises.push(stderrPromise);
192
+ }
193
+ }
194
+
195
+ // Wait for execution completion in parallel with stream consumption. The old
196
+ // flow waited for stream EOF first and only then started polling for the
197
+ // final exit code, which adds avoidable tail latency now that create returns
198
+ // an execution ID immediately for oneshot sandboxes.
199
+ let finalExecution:
200
+ | {
201
+ exitCode?: number;
202
+ status: string;
203
+ }
204
+ | undefined;
205
+ if (createResponse.executionId) {
206
+ logger?.debug(
207
+ 'waiting for execution %s and %d stream(s) in parallel',
208
+ createResponse.executionId,
209
+ streamPromises.length
210
+ );
211
+ const completionPromise = waitForRunCompletion(
212
+ client,
213
+ sandboxId,
214
+ createResponse.executionId,
215
+ orgId,
216
+ signal,
217
+ logger,
218
+ started
219
+ );
220
+
221
+ finalExecution = signal
222
+ ? await raceWithAbort(completionPromise, signal, abortController, sandboxId)
223
+ : await completionPromise;
224
+ await waitForStreamsToDrain(streamPromises, signal, abortController, sandboxId);
225
+ } else {
226
+ logger?.debug(
227
+ 'missing executionId on create response, falling back to stream-first completion'
228
+ );
229
+ await waitForStreamsToDrain(streamPromises, signal, abortController, sandboxId);
230
+ }
231
+
232
+ if (timingLogsEnabled)
233
+ console.error(`[TIMING] +${Date.now() - started}ms: completion wait finished`);
234
+ logger?.debug('completion wait finished, resolving final exit code');
235
+
236
+ // Stream EOF means the sandbox is done — hadron only closes streams after the
237
+ // container exits. Poll for the exit code with retries because the lifecycle
238
+ // event (carrying the exit code) may still be in flight to Catalyst when the
239
+ // stream completes.
240
+ //
241
+ // Hadron drains container logs for up to 5s after exit, then closes the
242
+ // stream, then sends the lifecycle event in a goroutine. So the exit code
243
+ // typically arrives at Catalyst 5–7s after the container exits. We use a
244
+ // linear 1s polling interval (not exponential backoff) so we don't overshoot
245
+ // the window — 15 attempts × 1s = 15s total, which comfortably covers the
246
+ // drain + lifecycle propagation delay.
247
+ let exitCode = finalExecution?.exitCode ?? 0;
248
+ const statusPollStart = Date.now();
249
+ let shouldWaitForSandboxStatus = finalExecution?.exitCode == null;
250
+ let sandboxStatusReconciled = false;
251
+ if (finalExecution?.exitCode == null) {
252
+ if (createResponse.executionId && finalExecution?.status === 'completed') {
253
+ try {
254
+ const execution = await executionGet(client, {
255
+ executionId: createResponse.executionId,
256
+ orgId,
257
+ wait: EXIT_CODE_FAST_WAIT_DURATION,
258
+ signal,
259
+ });
260
+ if (execution.exitCode != null) {
261
+ exitCode = execution.exitCode;
262
+ finalExecution.exitCode = execution.exitCode;
263
+ shouldWaitForSandboxStatus = false;
264
+ logger?.debug(
265
+ '[run] exit code %d found from fast execution retry (+%dms)',
266
+ exitCode,
267
+ Date.now() - statusPollStart
268
+ );
269
+ }
270
+ } catch (err) {
271
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
272
+ logger?.debug(
273
+ '[run] fast execution exit code retry failed (+%dms): %s',
274
+ Date.now() - statusPollStart,
275
+ err
276
+ );
277
+ }
278
+ }
279
+ }
280
+ }
281
+ if (shouldWaitForSandboxStatus) {
282
+ try {
283
+ const sandboxStatus = await sandboxGetStatus(client, {
284
+ sandboxId,
285
+ orgId,
286
+ waitForStatus: ['terminated', 'failed'],
287
+ waitMs: 15000,
288
+ });
289
+ if (sandboxStatus.exitCode != null) {
290
+ exitCode = sandboxStatus.exitCode;
291
+ sandboxStatusReconciled = true;
292
+ logger?.debug(
293
+ '[run] exit code %d found after server-side wait (+%dms)',
294
+ exitCode,
295
+ Date.now() - statusPollStart
296
+ );
297
+ } else if (sandboxStatus.status === 'failed') {
298
+ exitCode = 1;
299
+ sandboxStatusReconciled = true;
300
+ logger?.debug(
301
+ '[run] sandbox failed after server-side wait (+%dms)',
302
+ Date.now() - statusPollStart
303
+ );
304
+ } else if (sandboxStatus.status === 'terminated') {
305
+ sandboxStatusReconciled = true;
306
+ logger?.debug(
307
+ '[run] sandbox terminated without exit code after server-side wait (+%dms)',
308
+ Date.now() - statusPollStart
309
+ );
310
+ } else {
311
+ logger?.debug(
312
+ '[run] sandbox status wait expired with status=%s (+%dms)',
313
+ sandboxStatus.status,
314
+ Date.now() - statusPollStart
315
+ );
316
+ }
317
+ } catch (err) {
318
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
319
+ logger?.debug(
320
+ '[run] sandboxGetStatus server-side wait failed (+%dms): %s',
321
+ Date.now() - statusPollStart,
322
+ err
323
+ );
324
+ }
325
+ }
326
+ }
327
+ if (
328
+ finalExecution &&
329
+ finalExecution?.exitCode == null &&
330
+ finalExecution?.status !== 'completed' &&
331
+ !sandboxStatusReconciled
332
+ ) {
333
+ exitCode = 1;
334
+ logger?.debug(
335
+ '[run] using fallback exit code 1 for terminal status=%s after sandbox status reconciliation failed',
336
+ finalExecution?.status
337
+ );
338
+ }
339
+ if (exitCode === 0) {
340
+ if (finalExecution?.exitCode != null) {
341
+ logger?.debug('[run] using execution exit code 0 from long-poll result');
342
+ } else {
343
+ logger?.debug(
344
+ '[run] exit code wait finished with default 0 (+%dms)',
345
+ Date.now() - statusPollStart
346
+ );
347
+ }
348
+ }
349
+
350
+ if (timingLogsEnabled)
351
+ console.error(
352
+ `[TIMING] +${Date.now() - started}ms: sandboxGet complete (exit: ${exitCode})`
353
+ );
354
+
355
+ // Build captured output strings
356
+ const capturedStdout = Buffer.concat(stdoutChunks).toString('utf-8');
357
+ const capturedStderr = isCombinedOutput
358
+ ? capturedStdout
359
+ : Buffer.concat(stderrChunks).toString('utf-8');
360
+
361
+ return {
362
+ sandboxId,
363
+ exitCode,
364
+ durationMs: Date.now() - started,
365
+ stdout: capturedStdout,
366
+ stderr: capturedStderr,
367
+ };
368
+ } catch (error) {
369
+ abortController.abort();
370
+ try {
371
+ await sandboxDestroy(client, { sandboxId, orgId });
372
+ } catch {
373
+ // Ignore cleanup errors
374
+ }
375
+ throw error;
376
+ }
377
+ }
378
+
379
+ async function waitForRunCompletion(
380
+ client: APIClient,
381
+ sandboxId: string,
382
+ executionId: string,
383
+ orgId: string | undefined,
384
+ signal: AbortSignal | undefined,
385
+ logger: Logger | undefined,
386
+ started: number
387
+ ): Promise<{ exitCode?: number; status: string }> {
388
+ const completionAbortController = new AbortController();
389
+ let onAbort: (() => void) | undefined;
390
+ if (signal) {
391
+ onAbort = () => completionAbortController.abort(signal.reason);
392
+ if (signal.aborted) {
393
+ onAbort();
394
+ } else {
395
+ signal.addEventListener('abort', onAbort, { once: true });
396
+ }
397
+ }
398
+
399
+ try {
400
+ const completionSignal = completionAbortController.signal;
401
+ const executionPromise = waitForExecutionCompletion(
402
+ client,
403
+ executionId,
404
+ orgId,
405
+ completionSignal,
406
+ logger,
407
+ started
408
+ );
409
+ const statusPromise = waitForSandboxStatusCompletion(
410
+ client,
411
+ sandboxId,
412
+ orgId,
413
+ completionSignal,
414
+ logger,
415
+ started
416
+ ).catch((err) => {
417
+ if (completionSignal.aborted) {
418
+ throw err;
419
+ }
420
+ logger?.debug('[run] sandbox status completion wait failed: %s', err);
421
+ return new Promise<never>(() => {});
422
+ });
423
+
424
+ const result = await Promise.race([executionPromise, statusPromise]);
425
+ return result;
426
+ } finally {
427
+ if (onAbort && signal) {
428
+ signal.removeEventListener('abort', onAbort);
429
+ }
430
+ }
431
+ }
432
+
433
+ async function waitForExecutionCompletion(
434
+ client: APIClient,
435
+ executionId: string,
436
+ orgId: string | undefined,
437
+ signal: AbortSignal | undefined,
438
+ logger: Logger | undefined,
439
+ started: number
440
+ ): Promise<{ exitCode?: number; status: string }> {
441
+ while (true) {
442
+ if (signal?.aborted) {
443
+ throw new DOMException('Aborted', 'AbortError');
444
+ }
445
+
446
+ const result = await executionGet(client, {
447
+ executionId,
448
+ orgId,
449
+ wait: EXECUTION_WAIT_DURATION,
450
+ signal,
451
+ });
452
+ logger?.debug(
453
+ '[run] execution wait: id=%s status=%s exit=%s +%dms',
454
+ executionId,
455
+ result.status,
456
+ result.exitCode ?? 'undefined',
457
+ Date.now() - started
458
+ );
459
+
460
+ if (TERMINAL_EXECUTION_STATUSES.has(result.status)) {
461
+ return {
462
+ exitCode: result.exitCode,
463
+ status: result.status,
464
+ };
465
+ }
466
+ }
467
+ }
468
+
469
+ async function waitForSandboxStatusCompletion(
470
+ client: APIClient,
471
+ sandboxId: string,
472
+ orgId: string | undefined,
473
+ signal: AbortSignal | undefined,
474
+ logger: Logger | undefined,
475
+ started: number
476
+ ): Promise<{ exitCode?: number; status: string }> {
477
+ while (true) {
478
+ if (signal?.aborted) {
479
+ throw new DOMException('Aborted', 'AbortError');
480
+ }
481
+
482
+ const result = await sandboxGetStatus(client, {
483
+ sandboxId,
484
+ orgId,
485
+ waitForStatus: ['idle', 'terminated', 'failed'],
486
+ waitMs: 300000,
487
+ signal,
488
+ });
489
+ logger?.debug(
490
+ '[run] sandbox status wait: sandbox=%s status=%s exit=%s +%dms',
491
+ sandboxId,
492
+ result.status,
493
+ result.exitCode ?? 'undefined',
494
+ Date.now() - started
495
+ );
496
+
497
+ if (result.exitCode != null) {
498
+ return {
499
+ exitCode: result.exitCode,
500
+ status: 'completed',
501
+ };
502
+ }
503
+ if (result.status === 'failed') {
504
+ return {
505
+ exitCode: 1,
506
+ status: 'failed',
507
+ };
508
+ }
509
+ if (result.status === 'terminated') {
510
+ return {
511
+ status: 'completed',
512
+ };
513
+ }
514
+
515
+ await new Promise((resolve) => setTimeout(resolve, 25));
516
+ }
517
+ }
518
+
519
+ async function waitForStreamsToDrain(
520
+ streamPromises: Promise<void>[],
521
+ signal: AbortSignal | undefined,
522
+ abortController: AbortController,
523
+ sandboxId: string
524
+ ): Promise<void> {
525
+ if (streamPromises.length === 0) {
526
+ return;
527
+ }
528
+
529
+ if (signal) {
530
+ let onAbort: (() => void) | undefined;
531
+ try {
532
+ await Promise.race([
533
+ Promise.allSettled(streamPromises).then(() => undefined),
534
+ new Promise<never>((_, reject) => {
535
+ onAbort = () => {
536
+ abortController.abort();
537
+ reject(
538
+ new ExecutionCancelledError({
539
+ message: 'Sandbox execution cancelled',
540
+ sandboxId,
541
+ })
542
+ );
543
+ };
544
+ if (signal.aborted) {
545
+ onAbort();
546
+ } else {
547
+ signal.addEventListener('abort', onAbort, { once: true });
548
+ }
549
+ }),
550
+ ]);
551
+ } finally {
552
+ if (onAbort) {
553
+ signal.removeEventListener('abort', onAbort);
554
+ }
555
+ }
556
+ return;
557
+ }
558
+
559
+ await Promise.allSettled(streamPromises);
560
+ }
561
+
562
+ async function raceWithAbort<T>(
563
+ promise: Promise<T>,
564
+ signal: AbortSignal,
565
+ abortController: AbortController,
566
+ sandboxId: string
567
+ ): Promise<T> {
568
+ let onAbort: (() => void) | undefined;
569
+ try {
570
+ return await Promise.race([
571
+ promise,
572
+ new Promise<never>((_, reject) => {
573
+ onAbort = () => {
574
+ abortController.abort();
575
+ reject(
576
+ new ExecutionCancelledError({
577
+ message: 'Sandbox execution cancelled',
578
+ sandboxId,
579
+ })
580
+ );
581
+ };
582
+ if (signal.aborted) {
583
+ onAbort();
584
+ } else {
585
+ signal.addEventListener('abort', onAbort, { once: true });
586
+ }
587
+ }),
588
+ ]);
589
+ } finally {
590
+ if (onAbort) {
591
+ signal.removeEventListener('abort', onAbort);
592
+ }
593
+ }
594
+ }
595
+
596
+ async function createStdinStream(
597
+ region: string,
598
+ apiKey: string,
599
+ orgId?: string,
600
+ logger?: Logger
601
+ ): Promise<{ id: string; url: string }> {
602
+ const urls = getServiceUrls(region);
603
+ const streamBaseUrl = urls.stream;
604
+
605
+ // Build URL with orgId query param for CLI token validation
606
+ const queryParams = new URLSearchParams();
607
+ if (orgId) {
608
+ queryParams.set('orgId', orgId);
609
+ }
610
+ const queryString = queryParams.toString();
611
+ const url = `${streamBaseUrl}${queryString ? `?${queryString}` : ''}`;
612
+ logger?.trace('creating stdin stream: %s', url);
613
+
614
+ const response = await fetch(url, {
615
+ method: 'POST',
616
+ headers: {
617
+ 'Content-Type': 'application/json',
618
+ Authorization: `Bearer ${apiKey}`,
619
+ },
620
+ body: JSON.stringify({
621
+ name: `sandbox-stdin-${Date.now()}`,
622
+ }),
623
+ });
624
+
625
+ if (!response.ok) {
626
+ if (response.status === 402) {
627
+ throw new PaymentRequiredError({
628
+ url: url,
629
+ });
630
+ }
631
+ throw new Error(`Failed to create stdin stream: ${response.status} ${response.statusText}`);
632
+ }
633
+
634
+ const data = (await response.json()) as { id: string };
635
+ logger?.debug('created stdin stream: %s', data.id);
636
+
637
+ // Include orgId in the URL for subsequent PUT requests (needed for CLI token auth)
638
+ const putQueryString = orgId ? `?orgId=${encodeURIComponent(orgId)}` : '';
639
+ return {
640
+ id: data.id,
641
+ url: `${streamBaseUrl}/${data.id}${putQueryString}`,
642
+ };
643
+ }
644
+
645
+ async function streamStdinToUrl(
646
+ stdin: Readable,
647
+ url: string,
648
+ apiKey: string,
649
+ signal: AbortSignal,
650
+ logger?: Logger
651
+ ): Promise<void> {
652
+ try {
653
+ logger?.debug('streaming stdin to: %s', url);
654
+
655
+ // Convert Node.js Readable to a web ReadableStream for fetch body
656
+ let controllerClosed = false;
657
+ const webStream = new ReadableStream({
658
+ start(controller) {
659
+ stdin.on('data', (chunk: Buffer) => {
660
+ if (!signal.aborted && !controllerClosed) {
661
+ controller.enqueue(chunk);
662
+ }
663
+ });
664
+ stdin.on('end', () => {
665
+ if (!controllerClosed) {
666
+ controllerClosed = true;
667
+ controller.close();
668
+ }
669
+ });
670
+ stdin.on('error', (err) => {
671
+ if (!controllerClosed) {
672
+ controllerClosed = true;
673
+ controller.error(err);
674
+ }
675
+ });
676
+ signal.addEventListener('abort', () => {
677
+ if (!controllerClosed) {
678
+ controllerClosed = true;
679
+ controller.close();
680
+ }
681
+ });
682
+ },
683
+ });
684
+
685
+ const response = await fetch(url, {
686
+ method: 'PUT',
687
+ headers: {
688
+ Authorization: `Bearer ${apiKey}`,
689
+ },
690
+ body: webStream,
691
+ signal,
692
+ duplex: 'half',
693
+ } as RequestInit);
694
+
695
+ if (!response.ok) {
696
+ logger?.debug('stdin stream PUT failed: %d', response.status);
697
+ } else {
698
+ logger?.debug('stdin stream completed');
699
+ }
700
+ } catch (err) {
701
+ if (err instanceof Error && err.name === 'AbortError') {
702
+ logger?.debug('stdin stream aborted (expected on completion)');
703
+ } else {
704
+ logger?.debug('stdin stream error: %s', err);
705
+ }
706
+ }
707
+ }
708
+
709
+ async function streamUrlToWritable(
710
+ url: string,
711
+ writable: Writable,
712
+ signal: AbortSignal,
713
+ logger?: Logger,
714
+ _started?: number
715
+ ): Promise<void> {
716
+ const streamStart = Date.now();
717
+ try {
718
+ // Signal to Pulse that this is a v2 stream so it waits for v2 metadata
719
+ // instead of falling back to the legacy download path on a short timeout.
720
+ const v2Url = new URL(url);
721
+ v2Url.searchParams.set('v', '2');
722
+ logger?.debug('[stream] fetching: %s', v2Url.href);
723
+ const response = await fetch(v2Url.href, { signal });
724
+ logger?.debug(
725
+ '[stream] response status=%d in %dms',
726
+ response.status,
727
+ Date.now() - streamStart
728
+ );
729
+
730
+ if (!response.ok || !response.body) {
731
+ logger?.debug('[stream] not ok or no body (status=%d) — returning empty', response.status);
732
+ return;
733
+ }
734
+
735
+ const reader = response.body.getReader();
736
+ let chunks = 0;
737
+ let totalBytes = 0;
738
+
739
+ // Read until EOF - Pulse will block until data is available
740
+ while (true) {
741
+ const { done, value } = await reader.read();
742
+ if (done) {
743
+ logger?.debug(
744
+ '[stream] EOF after %dms (%d chunks, %d bytes)',
745
+ Date.now() - streamStart,
746
+ chunks,
747
+ totalBytes
748
+ );
749
+ break;
750
+ }
751
+
752
+ if (value) {
753
+ chunks++;
754
+ totalBytes += value.length;
755
+ if (chunks <= 3 || chunks % 100 === 0) {
756
+ logger?.debug(
757
+ '[stream] chunk #%d: %d bytes (total: %d bytes, +%dms)',
758
+ chunks,
759
+ value.length,
760
+ totalBytes,
761
+ Date.now() - streamStart
762
+ );
763
+ }
764
+ await writeAndDrain(writable, value);
765
+ }
766
+ }
767
+ // Signal end-of-stream to the tee/pipe chain so downstream
768
+ // consumers (e.g. process.stdout pipe) know no more data is coming.
769
+ writable.end();
770
+ if ('once' in writable) {
771
+ await finished(writable as NodeJS.WritableStream).catch(() => {
772
+ // Ignore finish errors here; the main read/write path already
773
+ // reported meaningful stream errors.
774
+ });
775
+ }
776
+ } catch (err) {
777
+ if (err instanceof Error && err.name === 'AbortError') {
778
+ logger?.debug('[stream] aborted after %dms', Date.now() - streamStart);
779
+ return;
780
+ }
781
+ logger?.debug('[stream] error after %dms: %s', Date.now() - streamStart, err);
782
+ }
783
+ }