@namzu/sandbox 6.1.0 → 7.1.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 (49) hide show
  1. package/CHANGELOG.md +62 -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 +163 -111
  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 +212 -149
  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 +154 -71
  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 +26 -7
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +235 -72
  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/readiness.d.ts +43 -0
  25. package/dist/backends/readiness.d.ts.map +1 -0
  26. package/dist/backends/readiness.js +150 -0
  27. package/dist/backends/readiness.js.map +1 -0
  28. package/dist/backends/remote-execution-controller.d.ts +76 -0
  29. package/dist/backends/remote-execution-controller.d.ts.map +1 -0
  30. package/dist/backends/remote-execution-controller.js +294 -0
  31. package/dist/backends/remote-execution-controller.js.map +1 -0
  32. package/dist/egress/proxy.d.ts.map +1 -1
  33. package/dist/egress/proxy.js +20 -3
  34. package/dist/egress/proxy.js.map +1 -1
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/backends/aci-standby-pool/index.ts +199 -117
  41. package/src/backends/docker/index.ts +235 -175
  42. package/src/backends/firecracker/index.ts +163 -63
  43. package/src/backends/firecracker/protocol.ts +67 -33
  44. package/src/backends/firecracker/transport.ts +301 -77
  45. package/src/backends/http-worker-client.ts +230 -0
  46. package/src/backends/readiness.ts +173 -0
  47. package/src/backends/remote-execution-controller.ts +462 -0
  48. package/src/egress/proxy.ts +18 -3
  49. package/src/index.ts +27 -0
@@ -48,6 +48,7 @@ import https from 'node:https'
48
48
 
49
49
  import type {
50
50
  Sandbox,
51
+ SandboxDestroyOptions,
51
52
  SandboxEnvironment,
52
53
  SandboxExecOptions,
53
54
  SandboxExecResult,
@@ -57,6 +58,8 @@ import type {
57
58
  } from '@namzu/sdk'
58
59
 
59
60
  import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
61
+ import { OperationDeadline, resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
62
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
60
63
  import type {
61
64
  MtlsClientMaterial,
62
65
  SandboxAgentHandle,
@@ -141,6 +144,7 @@ export interface FirecrackerBackendInternalConfig {
141
144
  const DEFAULT_AGENT_VSOCK_PORT = 1024
142
145
  const DEFAULT_READY_TIMEOUT_MS = 60_000
143
146
  const DEFAULT_READY_POLL_MS = 250
147
+ const RETIREMENT_TIMEOUT_MS = 5_000
144
148
 
145
149
  /**
146
150
  * Build a {@link SandboxBackend} backed by the owned Firecracker
@@ -148,11 +152,20 @@ const DEFAULT_READY_POLL_MS = 250
148
152
  * on the first `create()`.
149
153
  */
150
154
  export function buildFirecrackerBackend(config: FirecrackerBackendInternalConfig): SandboxBackend {
155
+ const readiness = resolveReadinessOptions(
156
+ 'firecracker',
157
+ config.readyTimeoutMs,
158
+ config.readyPollIntervalMs,
159
+ {
160
+ timeoutMs: DEFAULT_READY_TIMEOUT_MS,
161
+ pollIntervalMs: DEFAULT_READY_POLL_MS,
162
+ },
163
+ )
151
164
  return {
152
165
  tier: 'microvm',
153
166
  name: 'firecracker',
154
167
  async create(options: SandboxBackendOptions): Promise<Sandbox> {
155
- return await spawnFirecrackerSandbox(config, options)
168
+ return await spawnFirecrackerSandbox(config, options, readiness)
156
169
  },
157
170
  }
158
171
  }
@@ -208,8 +221,12 @@ async function orchestratorCall<T>(
208
221
  getToken: OrchestratorTokenProvider,
209
222
  body?: unknown,
210
223
  mtls?: MtlsClientMaterial,
224
+ signal?: AbortSignal,
225
+ acceptedStatuses: readonly number[] = [],
211
226
  ): Promise<T | undefined> {
227
+ signal?.throwIfAborted()
212
228
  const token = await getToken()
229
+ signal?.throwIfAborted()
213
230
  const url = `${stripTrailingSlashes(endpoint)}${pathSuffix}`
214
231
  const payload = body !== undefined ? JSON.stringify(body) : undefined
215
232
  const headers: Record<string, string> = {
@@ -226,8 +243,8 @@ async function orchestratorCall<T>(
226
243
  // than fetch+undici-dispatcher because the package declares no undici
227
244
  // dependency — node:https is always importable and needs nothing added.
228
245
  res = mtls
229
- ? await httpsOrchestratorRequest(url, method, headers, payload, mtls)
230
- : await fetchOrchestratorRequest(url, method, headers, payload)
246
+ ? await httpsOrchestratorRequest(url, method, headers, payload, mtls, signal)
247
+ : await fetchOrchestratorRequest(url, method, headers, payload, signal)
231
248
  } catch (err) {
232
249
  const cause = err instanceof Error ? err.cause : undefined
233
250
  throw new Error(
@@ -238,12 +255,21 @@ async function orchestratorCall<T>(
238
255
  )
239
256
  }
240
257
  if (res.status < 200 || res.status >= 300) {
241
- throw new Error(
242
- `firecracker orchestrator ${method} ${url} → ${res.status}: ${await res.text()}`,
243
- )
258
+ if (acceptedStatuses.includes(res.status)) {
259
+ await res.text()
260
+ signal?.throwIfAborted()
261
+ return undefined
262
+ }
263
+ const text = await res.text()
264
+ signal?.throwIfAborted()
265
+ throw new Error(`firecracker orchestrator ${method} ${url} → ${res.status}: ${text}`)
244
266
  }
245
267
  if (res.status === 204) return undefined
246
- if (res.contentType.includes('application/json')) return JSON.parse(await res.text()) as T
268
+ if (res.contentType.includes('application/json')) {
269
+ const text = await res.text()
270
+ signal?.throwIfAborted()
271
+ return JSON.parse(text) as T
272
+ }
247
273
  return undefined
248
274
  }
249
275
 
@@ -253,9 +279,11 @@ async function fetchOrchestratorRequest(
253
279
  method: 'POST' | 'DELETE',
254
280
  headers: Record<string, string>,
255
281
  payload: string | undefined,
282
+ signal?: AbortSignal,
256
283
  ): Promise<OrchestratorRawResponse> {
257
284
  const init: RequestInit = { method, headers }
258
285
  if (payload !== undefined) init.body = payload
286
+ if (signal !== undefined) init.signal = signal
259
287
  const res = await fetch(url, init)
260
288
  return {
261
289
  status: res.status,
@@ -276,6 +304,7 @@ function httpsOrchestratorRequest(
276
304
  headers: Record<string, string>,
277
305
  payload: string | undefined,
278
306
  mtls: MtlsClientMaterial,
307
+ signal?: AbortSignal,
279
308
  ): Promise<OrchestratorRawResponse> {
280
309
  const target = new URL(url)
281
310
  return new Promise<OrchestratorRawResponse>((resolve, reject) => {
@@ -293,6 +322,7 @@ function httpsOrchestratorRequest(
293
322
  rejectUnauthorized: true,
294
323
  minVersion: 'TLSv1.3',
295
324
  ...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
325
+ ...(signal !== undefined ? { signal } : {}),
296
326
  },
297
327
  (res) => {
298
328
  const chunks: Buffer[] = []
@@ -321,9 +351,12 @@ function httpsOrchestratorRequest(
321
351
  async function spawnFirecrackerSandbox(
322
352
  config: FirecrackerBackendInternalConfig,
323
353
  options: SandboxBackendOptions,
354
+ readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
324
355
  ): Promise<Sandbox> {
356
+ options.signal?.throwIfAborted()
325
357
  const endpoint = config.orchestratorEndpoint
326
358
  const egressAllowlist = await resolveEgressAllowlist(options)
359
+ options.signal?.throwIfAborted()
327
360
  const createBody: OrchestratorCreateRequest = {
328
361
  ...(config.template !== undefined ? { template: config.template } : {}),
329
362
  ...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
@@ -345,11 +378,17 @@ async function spawnFirecrackerSandbox(
345
378
  config.getToken,
346
379
  createBody,
347
380
  config.controlPlaneMtls,
381
+ options.signal,
348
382
  )
349
383
  } catch (err) {
384
+ const interrupted = options.signal?.aborted
350
385
  throw new Error(
351
386
  `firecracker: failed to create microVM sandbox — ${
352
387
  err instanceof Error ? err.message : String(err)
388
+ }${
389
+ interrupted
390
+ ? '; allocation outcome is unresolved because the orchestrator did not return a sandboxId — the deployment fleet reaper must reconcile it'
391
+ : ''
353
392
  }`,
354
393
  { cause: err },
355
394
  )
@@ -380,7 +419,10 @@ async function spawnFirecrackerSandbox(
380
419
  )
381
420
  const transport = new VsockAgentTransport(handle, config.transport ?? {})
382
421
 
383
- const destroy = async (): Promise<void> => {
422
+ const destroy = async (
423
+ signal?: AbortSignal,
424
+ acceptedStatuses: readonly number[] = [],
425
+ ): Promise<void> => {
384
426
  await orchestratorCall(
385
427
  endpoint,
386
428
  `/sandboxes/${encodeURIComponent(id)}:delete`,
@@ -388,37 +430,111 @@ async function spawnFirecrackerSandbox(
388
430
  config.getToken,
389
431
  undefined,
390
432
  config.controlPlaneMtls,
433
+ signal,
434
+ acceptedStatuses,
391
435
  )
392
436
  }
393
437
 
394
438
  try {
439
+ options.signal?.throwIfAborted()
395
440
  // Readiness fence: the orchestrator's resume returns BEFORE the
396
441
  // guest agent has reseeded entropy and re-listened on vsock. Stop
397
442
  // the clock on the agent's healthz, exactly as the HTTP backends
398
443
  // wait on `/healthz` — never on the orchestrator's 2xx, which
399
444
  // fires before the guest runs (§5 clock semantics).
400
- await transport.waitForReady(
401
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
402
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
403
- )
445
+ await transport.waitForReady(readiness.timeoutMs, readiness.pollIntervalMs, options.signal)
404
446
  } catch (err) {
405
447
  // Best-effort orchestrator teardown so a readiness failure does not
406
- // orphan a microVM/netns/UFFD handler (the reaper backstops, but
407
- // surfacing the delete failure keeps the leak observable).
408
- try {
409
- await destroy()
410
- } catch {
411
- // Preserve the readiness error as primary.
412
- }
448
+ // orphan a microVM/netns/UFFD handler. The fleet reaper backstops a
449
+ // failed delete; this call gets a separate short grace so cleanup
450
+ // cannot turn the captured readiness error back into an unbounded
451
+ // create operation.
452
+ await runFailureCleanup(async (signal) => destroy(signal))
413
453
  throw err
414
454
  }
415
455
 
416
- let status: SandboxStatus = 'ready'
456
+ type Lifecycle = 'active' | 'retiring' | 'destroyed'
457
+ let activeExecutions = 0
458
+ let lifecycle: Lifecycle = 'active'
459
+ let retirementPromise: Promise<{ readonly accepted: boolean; readonly error?: Error }> | undefined
460
+ let teardownPromise: Promise<void> | undefined
461
+ let teardownComplete = false
462
+
463
+ const assertActive = (): void => {
464
+ if (lifecycle !== 'active') {
465
+ throw new Error(`Sandbox ${id} is ${lifecycle}; no new guest operation can be admitted`)
466
+ }
467
+ }
468
+
469
+ const teardownSandbox = (signal?: AbortSignal): Promise<void> => {
470
+ lifecycle = 'retiring'
471
+ if (teardownComplete) return Promise.resolve()
472
+ if (teardownPromise) return teardownPromise
473
+ const attempt = destroy(signal, [404, 410])
474
+ const shared = attempt.then(
475
+ () => {
476
+ teardownComplete = true
477
+ lifecycle = 'destroyed'
478
+ },
479
+ (error: unknown) => {
480
+ if (teardownPromise === shared) teardownPromise = undefined
481
+ throw error
482
+ },
483
+ )
484
+ teardownPromise = shared
485
+ return shared
486
+ }
487
+ const retire = (): Promise<{ readonly accepted: boolean; readonly error?: Error }> => {
488
+ lifecycle = 'retiring'
489
+ if (retirementPromise) return retirementPromise
490
+ const deadline = new OperationDeadline(
491
+ RETIREMENT_TIMEOUT_MS,
492
+ `firecracker sandbox ${id} retirement`,
493
+ )
494
+ retirementPromise = deadline
495
+ .run(async (signal) => {
496
+ const joinedExistingAttempt = teardownPromise !== undefined
497
+ try {
498
+ await teardownSandbox(signal)
499
+ } catch (error) {
500
+ if (!joinedExistingAttempt || signal.aborted) throw error
501
+ await teardownSandbox(signal)
502
+ }
503
+ })
504
+ .then(() => {
505
+ return { accepted: true as const }
506
+ })
507
+ .catch((error: unknown) => {
508
+ const normalized = error instanceof Error ? error : new Error(String(error))
509
+ return { accepted: false as const, error: normalized }
510
+ })
511
+ return retirementPromise
512
+ }
513
+
514
+ const classifyExecutionFailure = async (error: unknown): Promise<never> => {
515
+ if (error instanceof RemoteCancellationUnknownError) {
516
+ error.retirement = await retire()
517
+ }
518
+ throw error
519
+ }
520
+
521
+ const runExecution = async <T>(operation: () => Promise<T>): Promise<T> => {
522
+ assertActive()
523
+ activeExecutions += 1
524
+ try {
525
+ return await operation()
526
+ } catch (error) {
527
+ return await classifyExecutionFailure(error)
528
+ } finally {
529
+ activeExecutions = Math.max(0, activeExecutions - 1)
530
+ }
531
+ }
417
532
 
418
533
  return {
419
534
  id,
420
535
  get status(): SandboxStatus {
421
- return status
536
+ if (lifecycle !== 'active') return 'destroyed'
537
+ return activeExecutions > 0 ? 'busy' : 'ready'
422
538
  },
423
539
  rootDir,
424
540
  environment: detectEnvironment(),
@@ -428,67 +544,51 @@ async function spawnFirecrackerSandbox(
428
544
  argv?: string[],
429
545
  opts?: SandboxExecOptions,
430
546
  ): Promise<SandboxExecResult> {
431
- // `opts.signal` is deliberately not forwarded, and the reason is
432
- // worth writing down because the obvious "fix" is worse than the
433
- // gap. There is no cancel op on this wire — the guest agent takes
434
- // an execute frame and answers when the command is done. Aborting
435
- // the socket here would abandon the WAIT while the process keeps
436
- // running inside the microVM, which is verbatim the failure
437
- // `SandboxExecOptions.signal` exists to prevent, except it would
438
- // then look honoured. Honouring it means a cancel op in the guest
439
- // protocol; until then, ignoring it is the truthful behaviour the
440
- // option's own contract allows.
441
- status = 'busy'
442
- try {
443
- return await transport.execute({
444
- command,
445
- args: argv ?? [],
446
- ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
447
- ...(opts?.env !== undefined ? { env: opts.env } : {}),
448
- ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
449
- })
450
- } finally {
451
- status = 'ready'
452
- }
547
+ return await runExecution(async () => await transport.exec(command, argv, opts))
453
548
  },
454
549
 
455
550
  async writeFile(path: string, content: string | Buffer): Promise<void> {
551
+ assertActive()
456
552
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
457
553
  await transport.writeFile(path, buf)
458
554
  },
459
555
 
460
556
  async readFile(path: string): Promise<Buffer> {
557
+ assertActive()
461
558
  return await transport.readFile(path)
462
559
  },
463
560
 
464
561
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
465
- // Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
466
- // line-by-line, map a non-zero exit (missing root) to "empty".
467
- const result = await transport.execute({
468
- command: 'find',
469
- args: [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
562
+ return await runExecution(async () => {
563
+ // Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
564
+ // line-by-line, map a non-zero exit (missing root) to "empty".
565
+ const result = await transport.exec('find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'])
566
+ if (result.exitCode !== 0) return []
567
+ const entries: SandboxFileEntry[] = []
568
+ for (const rawLine of result.stdout.split('\n')) {
569
+ if (!rawLine) continue
570
+ const tab = rawLine.indexOf('\t')
571
+ if (tab < 0) continue
572
+ const filePath = rawLine.slice(0, tab)
573
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10)
574
+ if (!filePath || !Number.isFinite(size)) continue
575
+ entries.push({ path: filePath, size })
576
+ }
577
+ return entries
470
578
  })
471
- if (result.exitCode !== 0) return []
472
- const entries: SandboxFileEntry[] = []
473
- for (const rawLine of result.stdout.split('\n')) {
474
- if (!rawLine) continue
475
- const tab = rawLine.indexOf('\t')
476
- if (tab < 0) continue
477
- const filePath = rawLine.slice(0, tab)
478
- const size = Number.parseInt(rawLine.slice(tab + 1), 10)
479
- if (!filePath || !Number.isFinite(size)) continue
480
- entries.push({ path: filePath, size })
481
- }
482
- return entries
483
579
  },
484
580
 
485
- async destroy(): Promise<void> {
486
- status = 'destroyed'
581
+ async destroy(options?: SandboxDestroyOptions): Promise<void> {
582
+ if (retirementPromise) {
583
+ const observation = await retirementPromise
584
+ if (observation.accepted) return
585
+ retirementPromise = undefined
586
+ }
487
587
  // Let the orchestrator DELETE failure propagate — the
488
588
  // Vandal-side lifecycle wraps this with logging, and a
489
589
  // swallowed error here means orphaned microVMs (and their
490
590
  // netns / UFFD handlers) pile up with no observability handle.
491
- await destroy()
591
+ await teardownSandbox(options?.signal)
492
592
  },
493
593
  }
494
594
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The ONE wire contract, shared across both transports.
2
+ * The execution-data codec shared by both transports.
3
3
  *
4
4
  * The docker (`backends/docker/`) and ACI (`backends/aci-standby-pool/`)
5
5
  * backends speak this contract over **HTTP**: a streaming NDJSON
@@ -9,23 +9,15 @@
9
9
  * shapes** — only the transport changes from HTTP-over-TCP to
10
10
  * framed-over-vsock (see `transport.ts`).
11
11
  *
12
- * This module is the single source of truth for the message shapes
13
- * and the streaming exec-line parser, so the two transports cannot
14
- * drift. It deliberately carries NO transport concern (no `fetch`, no
15
- * socket) — it is pure codec. The HTTP backends keep their own inline
16
- * copies (they predate this module and stay UNTOUCHED per the P0
17
- * scope); this module mirrors those shapes verbatim and is the
18
- * canonical definition the vsock path consumes.
19
- *
20
- * Why mirror rather than refactor docker/aci onto it: the P0 scope is
21
- * "do NOT touch docker or aci". Centralising the shapes here, with the
22
- * inline docker copy pinned by `backends/docker/__tests__` and this
23
- * module pinned by `backends/firecracker/__tests__`, keeps both honest
24
- * without editing the frozen HTTP path. A later cleanup pass can fold
25
- * docker/aci onto this module once it is no longer release-frozen.
12
+ * This module remains the pure codec consumed by the framed guest transport.
13
+ * The two HTTP backends share one strict HTTP client, and both transports put
14
+ * the same reserve-before-admission + idempotent-cancel state machine around
15
+ * these data events. HTTP uses endpoints; the framed guest uses dedicated ops.
26
16
  */
27
17
 
28
- import type { SandboxExecResult } from '@namzu/sdk'
18
+ import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
19
+
20
+ import { RemoteCommandError, RemoteProtocolError } from '../remote-execution-controller.js'
29
21
 
30
22
  // ---------------------------------------------------------------------------
31
23
  // Exec — request + the NDJSON event shapes (verbatim from worker/server.js)
@@ -38,6 +30,7 @@ import type { SandboxExecResult } from '@namzu/sdk'
38
30
  * `SandboxExecOptions.timeout`.
39
31
  */
40
32
  export interface ExecRequest {
33
+ readonly executionId?: string
41
34
  readonly command: string
42
35
  readonly args?: readonly string[]
43
36
  readonly cwd?: string
@@ -62,7 +55,8 @@ export type ExecEvent =
62
55
  readonly type: 'result'
63
56
  readonly exitCode: number
64
57
  readonly timedOut: boolean
65
- readonly durationMs?: number
58
+ readonly durationMs: number
59
+ readonly signal?: string
66
60
  readonly stdoutTruncated?: boolean
67
61
  readonly stderrTruncated?: boolean
68
62
  }
@@ -113,9 +107,8 @@ export interface ReadFileResponse {
113
107
  * terminal `result`, and **throw** on an `error` event.
114
108
  *
115
109
  * Transport-agnostic: feed it whole parsed {@link ExecEvent}s (the
116
- * transport owns newline-framing → JSON.parse → here). Malformed lines
117
- * are dropped by the transport's `JSON.parse` guard before they reach
118
- * this accumulator, matching the docker loop's `SyntaxError` swallow.
110
+ * transport owns framing → strict JSON validation → here). Malformed or
111
+ * trailing events are protocol failures rather than silently discarded data.
119
112
  */
120
113
  export class ExecResultAccumulator {
121
114
  private stdout = ''
@@ -123,11 +116,16 @@ export class ExecResultAccumulator {
123
116
  private exitCode = -1
124
117
  private timedOut = false
125
118
  private signal: string | undefined
119
+ private durationMs: number | undefined
120
+ private stdoutTruncated: boolean | undefined
121
+ private stderrTruncated: boolean | undefined
126
122
  private settled = false
127
123
  private readonly start: number
124
+ private readonly onOutput: SandboxExecOptions['onOutput']
128
125
 
129
- constructor(start: number = Date.now()) {
126
+ constructor(start: number = Date.now(), onOutput?: SandboxExecOptions['onOutput']) {
130
127
  this.start = start
128
+ this.onOutput = onOutput
131
129
  }
132
130
 
133
131
  /**
@@ -137,55 +135,91 @@ export class ExecResultAccumulator {
137
135
  * docker loop uses (`throw new Error(event.error)`).
138
136
  */
139
137
  push(event: ExecEvent): boolean {
138
+ if (this.settled) {
139
+ throw new RemoteProtocolError('exec stream emitted data after its terminal event')
140
+ }
140
141
  if (event.type === 'stdout_delta') {
141
142
  this.stdout += event.data
143
+ this.onOutput?.({ stream: 'stdout', data: event.data })
142
144
  return false
143
145
  }
144
146
  if (event.type === 'stderr_delta') {
145
147
  this.stderr += event.data
148
+ this.onOutput?.({ stream: 'stderr', data: event.data })
146
149
  return false
147
150
  }
148
151
  if (event.type === 'result') {
149
152
  this.exitCode = event.exitCode
150
153
  this.timedOut = event.timedOut
154
+ this.durationMs = event.durationMs
155
+ this.signal = event.signal
156
+ this.stdoutTruncated = event.stdoutTruncated
157
+ this.stderrTruncated = event.stderrTruncated
151
158
  this.settled = true
152
159
  return true
153
160
  }
154
161
  // event.type === 'error'
155
- throw new Error(event.error)
162
+ throw new RemoteCommandError(event.error)
156
163
  }
157
164
 
158
165
  get done(): boolean {
159
166
  return this.settled
160
167
  }
161
168
 
162
- /** Build the SDK-shaped result. `durationMs` measured host-side. */
169
+ /** Build the SDK-shaped result from the guest's terminal metadata. */
163
170
  finish(): SandboxExecResult {
171
+ if (!this.settled || this.durationMs === undefined) {
172
+ throw new RemoteProtocolError('exec stream ended without exactly one result event')
173
+ }
164
174
  return {
165
175
  exitCode: this.exitCode,
166
176
  stdout: this.stdout,
167
177
  stderr: this.stderr,
168
178
  ...(this.signal ? { signal: this.signal } : {}),
169
179
  timedOut: this.timedOut,
170
- durationMs: Date.now() - this.start,
180
+ durationMs: this.durationMs ?? Date.now() - this.start,
181
+ ...(this.stdoutTruncated !== undefined ? { stdoutTruncated: this.stdoutTruncated } : {}),
182
+ ...(this.stderrTruncated !== undefined ? { stderrTruncated: this.stderrTruncated } : {}),
171
183
  }
172
184
  }
173
185
  }
174
186
 
175
187
  /**
176
- * Parse a single NDJSON line into an {@link ExecEvent}, or `undefined`
177
- * if the line is blank or not valid JSON (the docker loop's
178
- * `SyntaxError` swallow). Non-`SyntaxError` problems are impossible
179
- * here because we only `JSON.parse`; structural validation is by the
180
- * `type` discriminator at the call site.
188
+ * Parse and structurally validate a single NDJSON event. Blank padding is
189
+ * ignored; malformed JSON and unknown/partial event shapes are refused.
181
190
  */
182
191
  export function parseExecLine(line: string): ExecEvent | undefined {
183
192
  const trimmed = line.trim()
184
193
  if (!trimmed) return undefined
194
+ let parsed: unknown
185
195
  try {
186
- return JSON.parse(trimmed) as ExecEvent
187
- } catch (err) {
188
- if (err instanceof SyntaxError) return undefined
189
- throw err
196
+ parsed = JSON.parse(trimmed)
197
+ } catch (error) {
198
+ throw new RemoteProtocolError(
199
+ `agent emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
200
+ )
201
+ }
202
+ if (!parsed || typeof parsed !== 'object') {
203
+ throw new RemoteProtocolError('agent emitted an event without an object body')
204
+ }
205
+ const event = parsed as Record<string, unknown>
206
+ if (
207
+ (event.type === 'stdout_delta' || event.type === 'stderr_delta') &&
208
+ typeof event.data === 'string'
209
+ ) {
210
+ return event as ExecEvent
211
+ }
212
+ if (event.type === 'error' && typeof event.error === 'string') return event as ExecEvent
213
+ if (
214
+ event.type === 'result' &&
215
+ Number.isFinite(event.exitCode) &&
216
+ typeof event.timedOut === 'boolean' &&
217
+ Number.isFinite(event.durationMs) &&
218
+ (event.signal === undefined || typeof event.signal === 'string') &&
219
+ (event.stdoutTruncated === undefined || typeof event.stdoutTruncated === 'boolean') &&
220
+ (event.stderrTruncated === undefined || typeof event.stderrTruncated === 'boolean')
221
+ ) {
222
+ return event as ExecEvent
190
223
  }
224
+ throw new RemoteProtocolError(`agent emitted an invalid ${String(event.type)} event`)
191
225
  }