@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.
- package/CHANGELOG.md +62 -0
- package/README.md +29 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +163 -111
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +212 -149
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +154 -71
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +19 -27
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +57 -32
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +26 -7
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +235 -72
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +17 -0
- package/dist/backends/http-worker-client.d.ts.map +1 -0
- package/dist/backends/http-worker-client.js +173 -0
- package/dist/backends/http-worker-client.js.map +1 -0
- package/dist/backends/readiness.d.ts +43 -0
- package/dist/backends/readiness.d.ts.map +1 -0
- package/dist/backends/readiness.js +150 -0
- package/dist/backends/readiness.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +76 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -0
- package/dist/backends/remote-execution-controller.js +294 -0
- package/dist/backends/remote-execution-controller.js.map +1 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +20 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +199 -117
- package/src/backends/docker/index.ts +235 -175
- package/src/backends/firecracker/index.ts +163 -63
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +301 -77
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/readiness.ts +173 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- package/src/egress/proxy.ts +18 -3
- 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
|
-
|
|
242
|
-
|
|
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'))
|
|
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 (
|
|
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
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
591
|
+
await teardownSandbox(options?.signal)
|
|
492
592
|
},
|
|
493
593
|
}
|
|
494
594
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
117
|
-
*
|
|
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
|
|
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
|
|
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
|
|
177
|
-
*
|
|
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
|
-
|
|
187
|
-
} catch (
|
|
188
|
-
|
|
189
|
-
|
|
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
|
}
|