@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.
- package/CHANGELOG.md +42 -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 +95 -82
- 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 +108 -114
- 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 +117 -52
- 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 +23 -4
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +153 -32
- 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/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/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +101 -88
- package/src/backends/docker/index.ts +101 -132
- package/src/backends/firecracker/index.ts +117 -45
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +214 -39
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- 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).
|
|
236
|
-
*
|
|
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
|
-
|
|
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', () =>
|
|
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
|
|
499
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
532
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
+
}
|