@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
@@ -58,7 +58,8 @@ import type {
58
58
  } from '@namzu/sdk'
59
59
 
60
60
  import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
61
- import { resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
61
+ import { OperationDeadline, resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
62
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
62
63
  import type {
63
64
  MtlsClientMaterial,
64
65
  SandboxAgentHandle,
@@ -143,6 +144,7 @@ export interface FirecrackerBackendInternalConfig {
143
144
  const DEFAULT_AGENT_VSOCK_PORT = 1024
144
145
  const DEFAULT_READY_TIMEOUT_MS = 60_000
145
146
  const DEFAULT_READY_POLL_MS = 250
147
+ const RETIREMENT_TIMEOUT_MS = 5_000
146
148
 
147
149
  /**
148
150
  * Build a {@link SandboxBackend} backed by the owned Firecracker
@@ -220,6 +222,7 @@ async function orchestratorCall<T>(
220
222
  body?: unknown,
221
223
  mtls?: MtlsClientMaterial,
222
224
  signal?: AbortSignal,
225
+ acceptedStatuses: readonly number[] = [],
223
226
  ): Promise<T | undefined> {
224
227
  signal?.throwIfAborted()
225
228
  const token = await getToken()
@@ -252,6 +255,11 @@ async function orchestratorCall<T>(
252
255
  )
253
256
  }
254
257
  if (res.status < 200 || res.status >= 300) {
258
+ if (acceptedStatuses.includes(res.status)) {
259
+ await res.text()
260
+ signal?.throwIfAborted()
261
+ return undefined
262
+ }
255
263
  const text = await res.text()
256
264
  signal?.throwIfAborted()
257
265
  throw new Error(`firecracker orchestrator ${method} ${url} → ${res.status}: ${text}`)
@@ -411,7 +419,10 @@ async function spawnFirecrackerSandbox(
411
419
  )
412
420
  const transport = new VsockAgentTransport(handle, config.transport ?? {})
413
421
 
414
- const destroy = async (signal?: AbortSignal): Promise<void> => {
422
+ const destroy = async (
423
+ signal?: AbortSignal,
424
+ acceptedStatuses: readonly number[] = [],
425
+ ): Promise<void> => {
415
426
  await orchestratorCall(
416
427
  endpoint,
417
428
  `/sandboxes/${encodeURIComponent(id)}:delete`,
@@ -420,6 +431,7 @@ async function spawnFirecrackerSandbox(
420
431
  undefined,
421
432
  config.controlPlaneMtls,
422
433
  signal,
434
+ acceptedStatuses,
423
435
  )
424
436
  }
425
437
 
@@ -441,12 +453,88 @@ async function spawnFirecrackerSandbox(
441
453
  throw err
442
454
  }
443
455
 
444
- 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
+ }
445
532
 
446
533
  return {
447
534
  id,
448
535
  get status(): SandboxStatus {
449
- return status
536
+ if (lifecycle !== 'active') return 'destroyed'
537
+ return activeExecutions > 0 ? 'busy' : 'ready'
450
538
  },
451
539
  rootDir,
452
540
  environment: detectEnvironment(),
@@ -456,67 +544,51 @@ async function spawnFirecrackerSandbox(
456
544
  argv?: string[],
457
545
  opts?: SandboxExecOptions,
458
546
  ): Promise<SandboxExecResult> {
459
- // `opts.signal` is deliberately not forwarded, and the reason is
460
- // worth writing down because the obvious "fix" is worse than the
461
- // gap. There is no cancel op on this wire — the guest agent takes
462
- // an execute frame and answers when the command is done. Aborting
463
- // the socket here would abandon the WAIT while the process keeps
464
- // running inside the microVM, which is verbatim the failure
465
- // `SandboxExecOptions.signal` exists to prevent, except it would
466
- // then look honoured. Honouring it means a cancel op in the guest
467
- // protocol; until then, ignoring it is the truthful behaviour the
468
- // option's own contract allows.
469
- status = 'busy'
470
- try {
471
- return await transport.execute({
472
- command,
473
- args: argv ?? [],
474
- ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
475
- ...(opts?.env !== undefined ? { env: opts.env } : {}),
476
- ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
477
- })
478
- } finally {
479
- status = 'ready'
480
- }
547
+ return await runExecution(async () => await transport.exec(command, argv, opts))
481
548
  },
482
549
 
483
550
  async writeFile(path: string, content: string | Buffer): Promise<void> {
551
+ assertActive()
484
552
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
485
553
  await transport.writeFile(path, buf)
486
554
  },
487
555
 
488
556
  async readFile(path: string): Promise<Buffer> {
557
+ assertActive()
489
558
  return await transport.readFile(path)
490
559
  },
491
560
 
492
561
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
493
- // Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
494
- // line-by-line, map a non-zero exit (missing root) to "empty".
495
- const result = await transport.execute({
496
- command: 'find',
497
- 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
498
578
  })
499
- if (result.exitCode !== 0) return []
500
- const entries: SandboxFileEntry[] = []
501
- for (const rawLine of result.stdout.split('\n')) {
502
- if (!rawLine) continue
503
- const tab = rawLine.indexOf('\t')
504
- if (tab < 0) continue
505
- const filePath = rawLine.slice(0, tab)
506
- const size = Number.parseInt(rawLine.slice(tab + 1), 10)
507
- if (!filePath || !Number.isFinite(size)) continue
508
- entries.push({ path: filePath, size })
509
- }
510
- return entries
511
579
  },
512
580
 
513
581
  async destroy(options?: SandboxDestroyOptions): Promise<void> {
514
- status = 'destroyed'
582
+ if (retirementPromise) {
583
+ const observation = await retirementPromise
584
+ if (observation.accepted) return
585
+ retirementPromise = undefined
586
+ }
515
587
  // Let the orchestrator DELETE failure propagate — the
516
588
  // Vandal-side lifecycle wraps this with logging, and a
517
589
  // swallowed error here means orphaned microVMs (and their
518
590
  // netns / UFFD handlers) pile up with no observability handle.
519
- await destroy(options?.signal)
591
+ await teardownSandbox(options?.signal)
520
592
  },
521
593
  }
522
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
  }