@namzu/sandbox 7.0.0 → 7.2.0

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 (39) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +29 -1
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +95 -82
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +108 -114
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +117 -52
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +19 -27
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +57 -32
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +23 -4
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +153 -32
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/http-worker-client.d.ts +17 -0
  21. package/dist/backends/http-worker-client.d.ts.map +1 -0
  22. package/dist/backends/http-worker-client.js +173 -0
  23. package/dist/backends/http-worker-client.js.map +1 -0
  24. package/dist/backends/remote-execution-controller.d.ts +76 -0
  25. package/dist/backends/remote-execution-controller.d.ts.map +1 -0
  26. package/dist/backends/remote-execution-controller.js +294 -0
  27. package/dist/backends/remote-execution-controller.js.map +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -0
  30. package/dist/index.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/backends/aci-standby-pool/index.ts +101 -88
  33. package/src/backends/docker/index.ts +101 -132
  34. package/src/backends/firecracker/index.ts +117 -45
  35. package/src/backends/firecracker/protocol.ts +67 -33
  36. package/src/backends/firecracker/transport.ts +214 -39
  37. package/src/backends/http-worker-client.ts +230 -0
  38. package/src/backends/remote-execution-controller.ts +462 -0
  39. package/src/index.ts +4 -0
@@ -56,8 +56,15 @@
56
56
  import net from 'node:net'
57
57
  import tls from 'node:tls'
58
58
 
59
- import type { SandboxExecResult } from '@namzu/sdk'
59
+ import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
60
60
  import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
61
+ import {
62
+ RemoteCancellationUnknownError,
63
+ RemoteCancellationUnsupportedError,
64
+ type RemoteExecutionAdapter,
65
+ RemoteExecutionController,
66
+ RemoteProtocolError,
67
+ } from '../remote-execution-controller.js'
61
68
  import {
62
69
  type ExecRequest,
63
70
  ExecResultAccumulator,
@@ -156,6 +163,11 @@ export type WireSandboxAgentHandle =
156
163
  */
157
164
  export type AgentRequest =
158
165
  | { readonly op: 'execute'; readonly body: ExecRequest }
166
+ | { readonly op: 'reserve-execution' }
167
+ | {
168
+ readonly op: 'cancel-execution'
169
+ readonly body: { readonly executionId: string }
170
+ }
159
171
  | { readonly op: 'read-file'; readonly body: ReadFileRequest }
160
172
  | { readonly op: 'write-file'; readonly body: WriteFileRequest }
161
173
  | { readonly op: 'healthz' }
@@ -181,6 +193,14 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
181
193
  const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000
182
194
  const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100
183
195
  const DEFAULT_READ_IDLE_TIMEOUT_MS = 60_000
196
+ const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000
197
+ // The ownership controller begins reconciliation shortly after the requested
198
+ // command timeout. The data socket itself stays observable for the peer's
199
+ // bounded TERM -> KILL confirmation window so a quiet but correctly
200
+ // terminating command can still deliver its terminal frame and output tail.
201
+ const EXECUTION_TRANSPORT_GRACE_MS = 10_000
202
+ const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000
203
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
184
204
 
185
205
  /** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
186
206
  const LENGTH_PREFIX_HEX = 8
@@ -215,6 +235,9 @@ class FrameReader {
215
235
  break
216
236
  }
217
237
  const header = this.buf.subarray(0, nl).toString('ascii')
238
+ if (!/^[0-9a-fA-F]{8}$/.test(header)) {
239
+ throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
240
+ }
218
241
  const len = Number.parseInt(header, 16)
219
242
  if (!Number.isInteger(len) || len < 0) {
220
243
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
@@ -227,13 +250,17 @@ class FrameReader {
227
250
  }
228
251
  return out
229
252
  }
253
+
254
+ get bufferedBytes(): number {
255
+ return this.buf.length
256
+ }
230
257
  }
231
258
 
232
259
  /**
233
260
  * The transport. One instance per sandbox handle; every request opens
234
261
  * a fresh connection (resume-survivable — no socket lingers across a
235
- * resume to be silently severed). All four ops + the heartbeat go
236
- * through {@link request} / {@link execute}.
262
+ * resume to be silently severed). Execution reservation, data, cancellation,
263
+ * file I/O and heartbeat all use independent calls through this dialer.
237
264
  */
238
265
  export class VsockAgentTransport {
239
266
  private readonly handle: SandboxAgentHandle
@@ -241,6 +268,9 @@ export class VsockAgentTransport {
241
268
  private readonly connectRetryBudgetMs: number
242
269
  private readonly connectRetryIntervalMs: number
243
270
  private readonly readIdleTimeoutMs: number
271
+ private readonly executionController: RemoteExecutionController<
272
+ Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>
273
+ >
244
274
 
245
275
  constructor(handle: SandboxAgentHandle, options: VsockTransportOptions = {}) {
246
276
  this.handle = handle
@@ -249,6 +279,29 @@ export class VsockAgentTransport {
249
279
  this.connectRetryIntervalMs =
250
280
  options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS
251
281
  this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS
282
+ const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
283
+ label: 'framed microVM agent',
284
+ reserve: async (signal) => await this.reserveExecution(signal),
285
+ cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal),
286
+ execute: async (executionId, command, argv, opts, signal, context) =>
287
+ await this.executeRaw(
288
+ {
289
+ ...(executionId ? { executionId } : {}),
290
+ command,
291
+ args: argv ?? [],
292
+ ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
293
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
294
+ ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
295
+ ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
296
+ ...(context?.maxOutputBytes !== undefined
297
+ ? { maxOutputBytes: context.maxOutputBytes }
298
+ : {}),
299
+ },
300
+ opts,
301
+ signal,
302
+ ),
303
+ }
304
+ this.executionController = new RemoteExecutionController(adapter)
252
305
  }
253
306
 
254
307
  /**
@@ -447,10 +500,13 @@ export class VsockAgentTransport {
447
500
  return await new Promise<T>((resolve, reject) => {
448
501
  const reader = new FrameReader()
449
502
  let settled = false
503
+ let response: T | undefined
504
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
450
505
  const finish = (err: Error | null, value?: T) => {
451
506
  if (settled) return
452
507
  settled = true
453
508
  idle.clear()
509
+ if (closeTimer) clearTimeout(closeTimer)
454
510
  signal?.removeEventListener('abort', abort)
455
511
  socket.destroy()
456
512
  if (err) reject(err)
@@ -462,6 +518,10 @@ export class VsockAgentTransport {
462
518
  )
463
519
  socket.on('data', (chunk: Buffer) => {
464
520
  idle.bump()
521
+ if (response !== undefined) {
522
+ finish(new Error('vsock transport: control reply emitted data after its response'))
523
+ return
524
+ }
465
525
  let frames: string[]
466
526
  try {
467
527
  frames = reader.push(chunk)
@@ -469,17 +529,34 @@ export class VsockAgentTransport {
469
529
  finish(err instanceof Error ? err : new Error(String(err)))
470
530
  return
471
531
  }
532
+ if (frames.length > 1) {
533
+ finish(new Error('vsock transport: control reply emitted multiple frames'))
534
+ return
535
+ }
472
536
  const first = frames[0]
473
537
  if (first !== undefined) {
474
538
  try {
475
- finish(null, JSON.parse(first) as T)
539
+ response = JSON.parse(first) as T
540
+ if (reader.bufferedBytes > 0) {
541
+ finish(new Error('vsock transport: control reply has trailing partial data'))
542
+ return
543
+ }
544
+ idle.clear()
545
+ closeTimer = setTimeout(
546
+ () => finish(new Error('vsock transport: control peer did not close after reply')),
547
+ POST_RESPONSE_CLOSE_TIMEOUT_MS,
548
+ )
549
+ closeTimer.unref()
476
550
  } catch (err) {
477
551
  finish(err instanceof Error ? err : new Error(String(err)))
478
552
  }
479
553
  }
480
554
  })
481
555
  socket.once('error', (err) => finish(err))
482
- socket.once('close', () => finish(new Error('vsock transport: socket closed before reply')))
556
+ socket.once('close', () => {
557
+ if (response !== undefined) finish(null, response)
558
+ else finish(new Error('vsock transport: socket closed before reply'))
559
+ })
483
560
  if (signal?.aborted) {
484
561
  abort()
485
562
  return
@@ -495,31 +572,52 @@ export class VsockAgentTransport {
495
572
  * {@link SandboxExecResult} via the shared {@link ExecResultAccumulator}.
496
573
  * The agent terminates the stream with a zero-length frame.
497
574
  */
498
- async execute(body: ExecRequest): Promise<SandboxExecResult> {
499
- const socket = await this.dial()
575
+ private async executeRaw(
576
+ body: ExecRequest,
577
+ opts?: SandboxExecOptions,
578
+ signal?: AbortSignal,
579
+ ): Promise<SandboxExecResult> {
580
+ const socket = await this.dial(signal)
500
581
  const start = Date.now()
501
582
  return await new Promise<SandboxExecResult>((resolve, reject) => {
502
583
  const reader = new FrameReader()
503
- const acc = new ExecResultAccumulator(start)
584
+ const acc = new ExecResultAccumulator(start, opts?.onOutput)
504
585
  let settled = false
505
- const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
506
- if (settled) return
507
- settled = true
508
- socket.destroy()
509
- reject(
510
- new Error(`vsock transport: exec read idle timeout after ${this.readIdleTimeoutMs}ms`),
511
- )
512
- })
586
+ let terminated = false
587
+ let terminalResult: SandboxExecResult | undefined
588
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
589
+ const requestedTimeout =
590
+ typeof body.timeoutMs === 'number' && Number.isFinite(body.timeoutMs) && body.timeoutMs > 0
591
+ ? body.timeoutMs
592
+ : DEFAULT_EXECUTION_TIMEOUT_MS
593
+ const observationTimeoutMs = Math.min(
594
+ MAX_TIMER_DELAY_MS,
595
+ requestedTimeout + EXECUTION_TRANSPORT_GRACE_MS,
596
+ )
513
597
  const finish = (err: Error | null, value?: SandboxExecResult) => {
514
598
  if (settled) return
515
599
  settled = true
516
- idle.clear()
600
+ clearTimeout(observationTimer)
601
+ if (closeTimer) clearTimeout(closeTimer)
602
+ signal?.removeEventListener('abort', abort)
517
603
  socket.destroy()
518
604
  if (err) reject(err)
519
605
  else resolve(value as SandboxExecResult)
520
606
  }
607
+ const abort = () => finish(signalError(signal))
608
+ const observationTimer = setTimeout(
609
+ () =>
610
+ finish(
611
+ new Error(`vsock transport: execution observation exceeded ${observationTimeoutMs}ms`),
612
+ ),
613
+ observationTimeoutMs,
614
+ )
615
+ observationTimer.unref()
521
616
  socket.on('data', (chunk: Buffer) => {
522
- idle.bump()
617
+ if (terminated) {
618
+ finish(new Error('vsock transport: exec stream emitted data after its terminator'))
619
+ return
620
+ }
523
621
  let frames: string[]
524
622
  try {
525
623
  frames = reader.push(chunk)
@@ -528,43 +626,120 @@ export class VsockAgentTransport {
528
626
  return
529
627
  }
530
628
  for (const payload of frames) {
531
- if (payload.length === 0) {
532
- // Zero-length terminator. If a result was seen, we are
533
- // done; otherwise the stream ended without a result.
534
- finish(
535
- acc.done ? null : new Error('exec stream ended without a result event'),
536
- acc.finish(),
537
- )
629
+ if (terminated) {
630
+ finish(new Error('vsock transport: exec stream emitted data after its terminator'))
538
631
  return
539
632
  }
540
- const event = parseExecLine(payload)
541
- if (!event) continue // malformed line — swallow (docker parity)
542
- try {
543
- if (acc.push(event)) {
544
- // Terminal result seen; wait for terminator but we can
545
- // resolve now — the agent closes after the terminator.
633
+ if (payload.length === 0) {
634
+ if (!acc.done) {
635
+ finish(new Error('exec stream ended without a result event'))
636
+ return
546
637
  }
638
+ terminated = true
639
+ continue
640
+ }
641
+ try {
642
+ const event = parseExecLine(payload)
643
+ if (event) acc.push(event)
547
644
  } catch (err) {
548
645
  finish(err instanceof Error ? err : new Error(String(err)))
549
646
  return
550
647
  }
551
648
  }
649
+ if (terminated) {
650
+ if (reader.bufferedBytes > 0) {
651
+ finish(new Error('vsock transport: exec stream has trailing partial data'))
652
+ return
653
+ }
654
+ terminalResult = acc.finish()
655
+ closeTimer = setTimeout(
656
+ () => finish(new Error('vsock transport: exec peer did not close after terminator')),
657
+ POST_RESPONSE_CLOSE_TIMEOUT_MS,
658
+ )
659
+ closeTimer.unref()
660
+ }
552
661
  })
553
662
  socket.once('error', (err) => finish(err))
554
663
  socket.once('close', () => {
555
- // Stream closed. If a result arrived, deliver it (some agents
556
- // close right after the terminator without a separate event);
557
- // otherwise it is a truncated stream.
558
- finish(
559
- acc.done ? null : new Error('vsock transport: socket closed before exec result'),
560
- acc.finish(),
561
- )
664
+ if (terminated && terminalResult) finish(null, terminalResult)
665
+ else finish(new Error('vsock transport: socket closed before exec stream terminator'))
562
666
  })
563
- idle.bump()
667
+ if (signal?.aborted) {
668
+ abort()
669
+ return
670
+ }
671
+ signal?.addEventListener('abort', abort, { once: true })
564
672
  socket.write(frame(JSON.stringify({ op: 'execute', body } satisfies AgentRequest)))
565
673
  })
566
674
  }
567
675
 
676
+ /**
677
+ * Compatibility request-shaped entry point. It now enters the same
678
+ * reserve-before-admission controller as {@link exec}; the raw data-plane
679
+ * primitive is deliberately private so aborting this public method cannot
680
+ * abandon a live guest command.
681
+ */
682
+ async execute(
683
+ body: ExecRequest,
684
+ opts?: SandboxExecOptions,
685
+ signal?: AbortSignal,
686
+ ): Promise<SandboxExecResult> {
687
+ if (body.executionId !== undefined) {
688
+ throw new RemoteProtocolError(
689
+ 'VsockAgentTransport.execute does not accept caller-owned execution ids',
690
+ )
691
+ }
692
+ return await this.executionController.exec(
693
+ body.command,
694
+ body.args ? [...body.args] : undefined,
695
+ {
696
+ ...opts,
697
+ ...(body.cwd !== undefined ? { cwd: body.cwd } : {}),
698
+ ...(body.env !== undefined ? { env: body.env } : {}),
699
+ ...(body.timeoutMs !== undefined ? { timeout: body.timeoutMs } : {}),
700
+ ...(opts?.signal === undefined && signal !== undefined ? { signal } : {}),
701
+ },
702
+ {
703
+ ...(body.stdin !== undefined ? { stdin: body.stdin } : {}),
704
+ ...(body.maxOutputBytes !== undefined ? { maxOutputBytes: body.maxOutputBytes } : {}),
705
+ },
706
+ )
707
+ }
708
+
709
+ async exec(
710
+ command: string,
711
+ argv?: string[],
712
+ opts?: SandboxExecOptions,
713
+ ): Promise<SandboxExecResult> {
714
+ return await this.executionController.exec(command, argv, opts)
715
+ }
716
+
717
+ private async reserveExecution(signal: AbortSignal): Promise<unknown> {
718
+ const response = await this.request<Record<string, unknown>>(
719
+ { op: 'reserve-execution' },
720
+ signal,
721
+ )
722
+ if (
723
+ response.ok === false &&
724
+ typeof response.error === 'string' &&
725
+ response.error.startsWith('unknown_op:')
726
+ ) {
727
+ throw new RemoteCancellationUnsupportedError(
728
+ 'This microVM agent does not support the execution-cancellation lease protocol. Rebuild the guest image before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
729
+ )
730
+ }
731
+ if (response.ok === false && response.error === 'agent_retiring') {
732
+ throw new RemoteCancellationUnknownError(
733
+ 'The microVM agent has fenced itself because an earlier process-group shutdown could not be confirmed; the sandbox must be retired.',
734
+ )
735
+ }
736
+ return response
737
+ }
738
+
739
+ private async cancelExecution(executionId: string, signal: AbortSignal): Promise<unknown> {
740
+ return await this.request<unknown>({ op: 'cancel-execution', body: { executionId } }, signal)
741
+ }
742
+
568
743
  /** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
569
744
  async healthz(signal?: AbortSignal): Promise<boolean> {
570
745
  try {
@@ -0,0 +1,230 @@
1
+ import { type SandboxExecOptions, type SandboxExecResult, withHint } from '@namzu/sdk'
2
+
3
+ import {
4
+ RemoteCancellationUnsupportedError,
5
+ RemoteCommandError,
6
+ type RemoteExecutionAdapter,
7
+ RemoteExecutionController,
8
+ RemoteProtocolError,
9
+ } from './remote-execution-controller.js'
10
+
11
+ type WorkerEvent =
12
+ | { readonly type: 'stdout_delta'; readonly data: string }
13
+ | { readonly type: 'stderr_delta'; readonly data: string }
14
+ | {
15
+ readonly type: 'result'
16
+ readonly exitCode: number
17
+ readonly timedOut: boolean
18
+ readonly durationMs: number
19
+ readonly signal?: string
20
+ readonly stdoutTruncated?: boolean
21
+ readonly stderrTruncated?: boolean
22
+ }
23
+ | { readonly type: 'error'; readonly error: string }
24
+
25
+ function parseWorkerEvent(line: string): WorkerEvent {
26
+ let parsed: unknown
27
+ try {
28
+ parsed = JSON.parse(line)
29
+ } catch (error) {
30
+ throw new RemoteProtocolError(
31
+ `worker emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
32
+ )
33
+ }
34
+ if (!parsed || typeof parsed !== 'object') {
35
+ throw new RemoteProtocolError('worker emitted an event without an object body')
36
+ }
37
+ const event = parsed as Record<string, unknown>
38
+ if (
39
+ (event.type === 'stdout_delta' || event.type === 'stderr_delta') &&
40
+ typeof event.data === 'string'
41
+ ) {
42
+ return event as WorkerEvent
43
+ }
44
+ if (event.type === 'error' && typeof event.error === 'string') return event as WorkerEvent
45
+ if (
46
+ event.type === 'result' &&
47
+ Number.isFinite(event.exitCode) &&
48
+ typeof event.timedOut === 'boolean' &&
49
+ Number.isFinite(event.durationMs) &&
50
+ (event.signal === undefined || typeof event.signal === 'string') &&
51
+ (event.stdoutTruncated === undefined || typeof event.stdoutTruncated === 'boolean') &&
52
+ (event.stderrTruncated === undefined || typeof event.stderrTruncated === 'boolean')
53
+ ) {
54
+ return event as WorkerEvent
55
+ }
56
+ throw new RemoteProtocolError(`worker emitted an invalid ${String(event.type)} event`)
57
+ }
58
+
59
+ async function readExecution(
60
+ baseUrl: string,
61
+ executionId: string | undefined,
62
+ command: string,
63
+ argv: string[] | undefined,
64
+ opts: SandboxExecOptions | undefined,
65
+ transportSignal: AbortSignal,
66
+ ): Promise<SandboxExecResult> {
67
+ let response: Response
68
+ try {
69
+ response = await fetch(`${baseUrl}/execute`, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ signal: transportSignal,
73
+ body: JSON.stringify({
74
+ ...(executionId ? { executionId } : {}),
75
+ command,
76
+ args: argv ?? [],
77
+ cwd: opts?.cwd,
78
+ env: opts?.env,
79
+ timeoutMs: opts?.timeout,
80
+ }),
81
+ })
82
+ } catch (error) {
83
+ const cause = error instanceof Error ? error.cause : undefined
84
+ const causeMessage =
85
+ cause instanceof Error
86
+ ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
87
+ : cause
88
+ ? String(cause)
89
+ : 'unknown'
90
+ throw withHint(
91
+ new Error(
92
+ `namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${error instanceof Error ? error.message : String(error)} — cause: ${causeMessage}`,
93
+ { cause: error },
94
+ ),
95
+ 'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
96
+ )
97
+ }
98
+ if (!response.ok || !response.body) {
99
+ throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
100
+ }
101
+
102
+ const decoder = new TextDecoder()
103
+ const reader = response.body.getReader()
104
+ let buffered = ''
105
+ let stdout = ''
106
+ let stderr = ''
107
+ let terminal: Extract<WorkerEvent, { type: 'result' }> | undefined
108
+ let terminalCount = 0
109
+
110
+ const consume = (rawLine: string): void => {
111
+ if (!rawLine.trim()) return
112
+ const event = parseWorkerEvent(rawLine)
113
+ if (terminalCount > 0) {
114
+ throw new RemoteProtocolError('worker emitted data after its terminal event')
115
+ }
116
+ if (event.type === 'stdout_delta') {
117
+ stdout += event.data
118
+ opts?.onOutput?.({ stream: 'stdout', data: event.data })
119
+ return
120
+ }
121
+ if (event.type === 'stderr_delta') {
122
+ stderr += event.data
123
+ opts?.onOutput?.({ stream: 'stderr', data: event.data })
124
+ return
125
+ }
126
+ terminalCount += 1
127
+ if (event.type === 'error') throw new RemoteCommandError(event.error)
128
+ terminal = event
129
+ }
130
+
131
+ for (;;) {
132
+ const { value, done } = await reader.read()
133
+ if (done) break
134
+ buffered += decoder.decode(value, { stream: true })
135
+ let newline = buffered.indexOf('\n')
136
+ while (newline !== -1) {
137
+ consume(buffered.slice(0, newline))
138
+ buffered = buffered.slice(newline + 1)
139
+ newline = buffered.indexOf('\n')
140
+ }
141
+ }
142
+ buffered += decoder.decode()
143
+ if (buffered.trim()) consume(buffered)
144
+ if (terminalCount !== 1 || !terminal) {
145
+ throw new RemoteProtocolError('worker response ended without exactly one result event')
146
+ }
147
+
148
+ return {
149
+ exitCode: terminal.exitCode,
150
+ stdout,
151
+ stderr,
152
+ ...(terminal.signal ? { signal: terminal.signal } : {}),
153
+ timedOut: terminal.timedOut,
154
+ durationMs: terminal.durationMs,
155
+ ...(terminal.stdoutTruncated !== undefined
156
+ ? { stdoutTruncated: terminal.stdoutTruncated }
157
+ : {}),
158
+ ...(terminal.stderrTruncated !== undefined
159
+ ? { stderrTruncated: terminal.stderrTruncated }
160
+ : {}),
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A per-sandbox HTTP worker client. The controller caches protocol support for
166
+ * that worker and gives every v2 command an identity before it can be admitted.
167
+ */
168
+ export class HttpWorkerClient {
169
+ private readonly controller: RemoteExecutionController
170
+
171
+ constructor(baseUrl: string) {
172
+ const adapter: RemoteExecutionAdapter = {
173
+ label: 'HTTP worker',
174
+ reserve: async (signal) => {
175
+ const response = await fetch(`${baseUrl}/executions/reserve`, {
176
+ method: 'POST',
177
+ signal,
178
+ })
179
+ if (response.status === 404) {
180
+ throw new RemoteCancellationUnsupportedError(
181
+ 'This sandbox worker does not support the execution-cancellation lease protocol. Rebuild the worker image or standby-pool profile before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
182
+ )
183
+ }
184
+ if (!response.ok) {
185
+ throw new Error(
186
+ `execution reservation failed: HTTP ${response.status} ${await response.text()}`,
187
+ )
188
+ }
189
+ return await response.json()
190
+ },
191
+ cancel: async (executionId, signal) => {
192
+ const response = await fetch(`${baseUrl}/cancel`, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify({ executionId }),
196
+ signal,
197
+ })
198
+ if (!response.ok) {
199
+ throw new Error(`cancel failed: HTTP ${response.status} ${await response.text()}`)
200
+ }
201
+ return await response.json()
202
+ },
203
+ execute: async (executionId, command, argv, opts, signal) =>
204
+ await readExecution(baseUrl, executionId, command, argv, opts, signal),
205
+ }
206
+ this.controller = new RemoteExecutionController(adapter)
207
+ }
208
+
209
+ async exec(
210
+ command: string,
211
+ argv: string[] | undefined,
212
+ opts: SandboxExecOptions | undefined,
213
+ ): Promise<SandboxExecResult> {
214
+ return await this.controller.exec(command, argv, opts)
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Compatibility entry point for focused consumers. Sandbox backends keep one
220
+ * {@link HttpWorkerClient} per remote sandbox so capability state is not shared
221
+ * across peers and is not re-probed for every command.
222
+ */
223
+ export async function execViaHttpWorker(
224
+ baseUrl: string,
225
+ command: string,
226
+ argv: string[] | undefined,
227
+ opts: SandboxExecOptions | undefined,
228
+ ): Promise<SandboxExecResult> {
229
+ return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
230
+ }