@namzu/sandbox 1.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 (69) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE.md +110 -0
  3. package/README.md +148 -0
  4. package/dist/backends/aci-standby-pool/index.d.ts +104 -0
  5. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
  6. package/dist/backends/aci-standby-pool/index.js +425 -0
  7. package/dist/backends/aci-standby-pool/index.js.map +1 -0
  8. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
  9. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
  10. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
  11. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
  12. package/dist/backends/docker/index.d.ts +118 -0
  13. package/dist/backends/docker/index.d.ts.map +1 -0
  14. package/dist/backends/docker/index.js +645 -0
  15. package/dist/backends/docker/index.js.map +1 -0
  16. package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
  17. package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
  18. package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
  19. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
  20. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
  21. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
  22. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
  23. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
  24. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
  25. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
  26. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
  27. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
  28. package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
  29. package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
  30. package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
  31. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
  32. package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
  33. package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
  34. package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
  35. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
  36. package/dist/backends/firecracker/index.d.ts +124 -0
  37. package/dist/backends/firecracker/index.d.ts.map +1 -0
  38. package/dist/backends/firecracker/index.js +334 -0
  39. package/dist/backends/firecracker/index.js.map +1 -0
  40. package/dist/backends/firecracker/protocol.d.ts +132 -0
  41. package/dist/backends/firecracker/protocol.d.ts.map +1 -0
  42. package/dist/backends/firecracker/protocol.js +112 -0
  43. package/dist/backends/firecracker/protocol.js.map +1 -0
  44. package/dist/backends/firecracker/transport.d.ts +251 -0
  45. package/dist/backends/firecracker/transport.d.ts.map +1 -0
  46. package/dist/backends/firecracker/transport.js +524 -0
  47. package/dist/backends/firecracker/transport.js.map +1 -0
  48. package/dist/index.d.ts +611 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +376 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/index.test.d.ts +28 -0
  53. package/dist/index.test.d.ts.map +1 -0
  54. package/dist/index.test.js +670 -0
  55. package/dist/index.test.js.map +1 -0
  56. package/package.json +54 -0
  57. package/src/backends/aci-standby-pool/index.ts +602 -0
  58. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
  59. package/src/backends/docker/index.ts +826 -0
  60. package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
  61. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
  62. package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
  63. package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
  64. package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
  65. package/src/backends/firecracker/index.ts +528 -0
  66. package/src/backends/firecracker/protocol.ts +191 -0
  67. package/src/backends/firecracker/transport.ts +667 -0
  68. package/src/index.test.ts +731 -0
  69. package/src/index.ts +930 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Host-side vsock transport + dialer for the Firecracker in-VM agent.
3
+ *
4
+ * This is the NEW code the §2.2 decision calls for. The docker/ACI
5
+ * backends reach the agent over HTTP with `fetch`; **Node `fetch`
6
+ * cannot dial `AF_VSOCK`**, and across an FC snapshot resume a TCP
7
+ * control channel is dead-on-arrival (FC `snapshot-support.md`: TCP
8
+ * connection state does not survive a resume; the **vsock LISTEN
9
+ * socket** does). So the FC control channel is a framed stream over
10
+ * vsock, and that framing + the resume-survival hardening live here.
11
+ *
12
+ * ## One wire, two transports
13
+ * The message FORMAT (NDJSON exec events + base64 file-IO) is shared
14
+ * with the HTTP backends via `protocol.ts`. This module owns only the
15
+ * TRANSPORT: how a request crosses the wire and how a response is
16
+ * framed back.
17
+ *
18
+ * Framing: a length-prefixed envelope per message —
19
+ * `<8-hex-digit big-endian byte length>\n<utf8 JSON payload>`
20
+ * The newline after the hex length lets a reader find the boundary
21
+ * without a fixed header struct, and the explicit length means a
22
+ * payload that itself contains newlines (NDJSON exec output) is read
23
+ * whole, not split. Exec replies are a SEQUENCE of framed NDJSON
24
+ * lines terminated by a zero-length frame; file-IO replies are a
25
+ * single framed JSON object.
26
+ *
27
+ * ## How vsock is actually dialed from Node
28
+ * Node has no `AF_VSOCK` socket family. The production path therefore
29
+ * follows exactly what the in-situ bench already proved: the guest
30
+ * agent's vsock stream is bridged to a **host-side unix-domain
31
+ * socket** (the bench relays guest `AF_VSOCK` → host CID:port → a host
32
+ * unix socket; FC's own vsock device exposes a host-side unix socket
33
+ * rendezvous, `UDS + "CONNECT <port>"`). So the host dialer ALWAYS
34
+ * terminates on a `net.connect({ path })` unix socket:
35
+ * - `kind: 'unix'` — connect directly to `path` (local/dev + tests).
36
+ * - `kind: 'vsock'` — connect to the FC vsock device's host unix
37
+ * socket `udsPath`, then send the firecracker hybrid-vsock
38
+ * handshake line `CONNECT <port>\n` and await the `OK <hostport>`
39
+ * ack before framing application traffic.
40
+ * Both land on the same `net.Socket`, so the unix-socket stand-in in
41
+ * the tests exercises the identical framing/heartbeat/reconnect code
42
+ * the vsock path runs — the only delta is the one-line CONNECT
43
+ * handshake, which is covered by its own assertion.
44
+ *
45
+ * ## Resume survival (the hard invariant, FC #4713 / loopholelabs)
46
+ * On resume the guest vsock driver closes all existing connections and
47
+ * the `TRANSPORT_RESET` event may NOT be delivered, so a host read can
48
+ * hang. The agent re-LISTENs after every resume; the host dialer
49
+ * carries a per-attempt connect/handshake **timeout + retry budget**
50
+ * so a dropped reset cannot wedge first-exec — the dialer simply
51
+ * re-dials. Every request opens a fresh connection (no long-lived
52
+ * socket to be silently severed by a resume), which makes the
53
+ * transport resume-survivable by construction.
54
+ */
55
+
56
+ import net from 'node:net'
57
+ import tls from 'node:tls'
58
+
59
+ import type { SandboxExecResult } from '@namzu/sdk'
60
+ import {
61
+ type ExecRequest,
62
+ ExecResultAccumulator,
63
+ type ReadFileRequest,
64
+ type ReadFileResponse,
65
+ type WriteFileRequest,
66
+ type WriteFileResponse,
67
+ parseExecLine,
68
+ } from './protocol.js'
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Handle — how a single sandbox's agent is addressed
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * An addressable agent endpoint. The orchestrator hands one of these
76
+ * back per sandbox (`create()` response → `vsock endpoint`).
77
+ *
78
+ * - `unix` — a host unix-domain socket the agent (or a relay) is
79
+ * listening on. The local/dev path and the test stand-in.
80
+ * - `vsock` — a Firecracker hybrid-vsock device exposed as a host
81
+ * unix socket at `udsPath`; `port` is the guest AF_VSOCK port the
82
+ * agent listens on (the fixed contract port baked into the golden
83
+ * rootfs). The dialer connects to `udsPath` then issues the
84
+ * `CONNECT <port>` handshake.
85
+ * - `mtls` — a per-FC-host mTLS RELAY daemon reachable over the
86
+ * network at `host:port` (the owning host's private VNet IP + the
87
+ * bridge port). The dialer `tls.connect`s the relay presenting the
88
+ * fleet client cert, verifies the relay's server cert
89
+ * (`rejectUnauthorized: true`), then writes a single routing
90
+ * preamble line `SANDBOX <sandboxId>\n`. The relay terminates mTLS,
91
+ * resolves `sandboxId` to the host-local jailed `v.sock`, dials it,
92
+ * and issues the guest `CONNECT 1024` handshake ITSELF — so the
93
+ * caller does NOT write the `CONNECT` line. After the preamble the
94
+ * relay is a verbatim byte pump, so the IDENTICAL 8-hex/NDJSON
95
+ * framing + heartbeat + retry runs unchanged over the TLS socket
96
+ * (`tls.TLSSocket` is a `net.Socket`). The container-app NEVER sees
97
+ * a host-local `udsPath`; the cert material is injected by the
98
+ * Vandal host layer, never returned by the orchestrator.
99
+ */
100
+ export type SandboxAgentHandle =
101
+ | { readonly kind: 'unix'; readonly path: string }
102
+ | { readonly kind: 'vsock'; readonly udsPath: string; readonly port: number }
103
+ | {
104
+ readonly kind: 'mtls'
105
+ readonly host: string
106
+ readonly port: number
107
+ readonly sandboxId: string
108
+ readonly tls: {
109
+ readonly ca: string | Buffer
110
+ readonly cert: string | Buffer
111
+ readonly key: string | Buffer
112
+ readonly servername?: string
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The mTLS cert material the consumer injects onto a wire `mtls` handle (the
118
+ * `tls` block of the transport handle). Read from the consumer's runtime (the
119
+ * Vandal host layer's `VANDAL_SANDBOX_FC_TLS_*`), NEVER returned by the
120
+ * orchestrator — the leak-prevention boundary.
121
+ */
122
+ export interface MtlsClientMaterial {
123
+ readonly ca: string | Buffer
124
+ readonly cert: string | Buffer
125
+ readonly key: string | Buffer
126
+ readonly servername?: string
127
+ }
128
+
129
+ /**
130
+ * The WIRE shape of an agent handle as the orchestrator returns it. Identical
131
+ * to {@link SandboxAgentHandle} EXCEPT the `mtls` arm omits the `tls` cert
132
+ * block: the orchestrator returns only host/port/sandboxId, and the consumer
133
+ * (Vandal host layer) merges the cert material in (see `normalizeHandle`)
134
+ * before constructing the transport. The `unix`/`vsock` arms are unchanged
135
+ * (they carry no cert material).
136
+ */
137
+ export type WireSandboxAgentHandle =
138
+ | { readonly kind: 'unix'; readonly path: string }
139
+ | { readonly kind: 'vsock'; readonly udsPath: string; readonly port: number }
140
+ | {
141
+ readonly kind: 'mtls'
142
+ readonly host: string
143
+ readonly port: number
144
+ readonly sandboxId: string
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Request envelope — the one method dimension on top of the shared wire
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * A framed request. `op` selects the agent handler; the HTTP worker
153
+ * used the URL path (`/execute`, `/read-file`, `/write-file`,
154
+ * `/healthz`) — over vsock the same selector rides in the framed JSON.
155
+ */
156
+ export type AgentRequest =
157
+ | { readonly op: 'execute'; readonly body: ExecRequest }
158
+ | { readonly op: 'read-file'; readonly body: ReadFileRequest }
159
+ | { readonly op: 'write-file'; readonly body: WriteFileRequest }
160
+ | { readonly op: 'healthz' }
161
+
162
+ export interface VsockTransportOptions {
163
+ /** Per-attempt connect + handshake timeout. Default 5000ms. */
164
+ readonly connectTimeoutMs?: number
165
+ /** Total time budget for connect retries (resume survival). Default 30000ms. */
166
+ readonly connectRetryBudgetMs?: number
167
+ /** Backoff between connect retries. Default 100ms. */
168
+ readonly connectRetryIntervalMs?: number
169
+ /**
170
+ * Idle read timeout once connected and the request is sent. Guards
171
+ * the FC #4713 "read hangs because TRANSPORT_RESET was not
172
+ * delivered" case: if no byte arrives within this window the
173
+ * transport tears the socket down and the caller's retry re-dials
174
+ * against the agent's fresh listen socket. Default 60000ms.
175
+ */
176
+ readonly readIdleTimeoutMs?: number
177
+ }
178
+
179
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
180
+ const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000
181
+ const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100
182
+ const DEFAULT_READ_IDLE_TIMEOUT_MS = 60_000
183
+
184
+ /** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
185
+ const LENGTH_PREFIX_HEX = 8
186
+
187
+ function frame(payload: string): Buffer {
188
+ const body = Buffer.from(payload, 'utf8')
189
+ const header = Buffer.from(
190
+ `${body.length.toString(16).padStart(LENGTH_PREFIX_HEX, '0')}\n`,
191
+ 'ascii',
192
+ )
193
+ return Buffer.concat([header, body])
194
+ }
195
+
196
+ /**
197
+ * Incremental frame reader. Feed it socket chunks; it yields complete
198
+ * payloads. A zero-length frame is the exec stream terminator and is
199
+ * surfaced as an empty string so the caller can stop.
200
+ */
201
+ class FrameReader {
202
+ private buf: Buffer = Buffer.alloc(0)
203
+
204
+ push(chunk: Buffer): string[] {
205
+ this.buf = this.buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buf, chunk])
206
+ const out: string[] = []
207
+ for (;;) {
208
+ const nl = this.buf.indexOf(0x0a) // '\n'
209
+ if (nl < 0 || nl < LENGTH_PREFIX_HEX) {
210
+ // Need at least the hex header + newline.
211
+ if (nl >= 0 && nl < LENGTH_PREFIX_HEX) {
212
+ throw new Error(`vsock transport: malformed frame header (newline at ${nl})`)
213
+ }
214
+ break
215
+ }
216
+ const header = this.buf.subarray(0, nl).toString('ascii')
217
+ const len = Number.parseInt(header, 16)
218
+ if (!Number.isInteger(len) || len < 0) {
219
+ throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
220
+ }
221
+ const start = nl + 1
222
+ if (this.buf.length < start + len) break // incomplete payload
223
+ const payload = this.buf.subarray(start, start + len).toString('utf8')
224
+ this.buf = this.buf.subarray(start + len)
225
+ out.push(payload)
226
+ }
227
+ return out
228
+ }
229
+ }
230
+
231
+ /**
232
+ * The transport. One instance per sandbox handle; every request opens
233
+ * a fresh connection (resume-survivable — no socket lingers across a
234
+ * resume to be silently severed). All four ops + the heartbeat go
235
+ * through {@link request} / {@link execute}.
236
+ */
237
+ export class VsockAgentTransport {
238
+ private readonly handle: SandboxAgentHandle
239
+ private readonly connectTimeoutMs: number
240
+ private readonly connectRetryBudgetMs: number
241
+ private readonly connectRetryIntervalMs: number
242
+ private readonly readIdleTimeoutMs: number
243
+
244
+ constructor(handle: SandboxAgentHandle, options: VsockTransportOptions = {}) {
245
+ this.handle = handle
246
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
247
+ this.connectRetryBudgetMs = options.connectRetryBudgetMs ?? DEFAULT_CONNECT_RETRY_BUDGET_MS
248
+ this.connectRetryIntervalMs =
249
+ options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS
250
+ this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS
251
+ }
252
+
253
+ /**
254
+ * Dial the agent with the resume-survival retry budget. Resolves a
255
+ * connected, post-handshake socket. Retries connect/handshake
256
+ * failures (ECONNREFUSED while the agent re-listens after a resume,
257
+ * a dropped CONNECT ack) until the budget is exhausted.
258
+ */
259
+ private async dial(): Promise<net.Socket> {
260
+ const deadline = Date.now() + this.connectRetryBudgetMs
261
+ let lastErr: unknown
262
+ for (;;) {
263
+ try {
264
+ return await this.connectOnce()
265
+ } catch (err) {
266
+ lastErr = err
267
+ if (Date.now() >= deadline) break
268
+ await delay(this.connectRetryIntervalMs)
269
+ }
270
+ }
271
+ throw new Error(
272
+ `vsock transport: could not connect to agent within ${this.connectRetryBudgetMs}ms (handle=${describeHandle(
273
+ this.handle,
274
+ )}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
275
+ { cause: lastErr },
276
+ )
277
+ }
278
+
279
+ private connectOnce(): Promise<net.Socket> {
280
+ const handle = this.handle
281
+ if (handle.kind === 'mtls') return this.connectOnceMtls(handle)
282
+ return new Promise<net.Socket>((resolve, reject) => {
283
+ const path = handle.kind === 'unix' ? handle.path : handle.udsPath
284
+ const socket = net.connect({ path })
285
+ let settled = false
286
+ const timer = setTimeout(() => {
287
+ if (settled) return
288
+ settled = true
289
+ socket.destroy()
290
+ reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
291
+ }, this.connectTimeoutMs)
292
+ timer.unref()
293
+
294
+ const fail = (err: Error) => {
295
+ if (settled) return
296
+ settled = true
297
+ clearTimeout(timer)
298
+ socket.destroy()
299
+ reject(err)
300
+ }
301
+
302
+ socket.once('error', fail)
303
+
304
+ socket.once('connect', () => {
305
+ if (handle.kind === 'unix') {
306
+ if (settled) return
307
+ settled = true
308
+ clearTimeout(timer)
309
+ socket.removeListener('error', fail)
310
+ resolve(socket)
311
+ return
312
+ }
313
+ // vsock: issue the firecracker hybrid-vsock CONNECT handshake
314
+ // and wait for the `OK <hostport>` ack line before handing the
315
+ // socket up for framed traffic.
316
+ const port = handle.port
317
+ socket.write(`CONNECT ${port}\n`)
318
+ const ackReader = new LineReader()
319
+ const onData = (chunk: Buffer) => {
320
+ const line = ackReader.push(chunk)
321
+ if (line === undefined) return
322
+ socket.removeListener('data', onData)
323
+ if (!/^OK\b/.test(line)) {
324
+ fail(new Error(`vsock CONNECT ${port} rejected: ${JSON.stringify(line)}`))
325
+ return
326
+ }
327
+ if (settled) return
328
+ settled = true
329
+ clearTimeout(timer)
330
+ socket.removeListener('error', fail)
331
+ // Any bytes the ackReader over-read after the ack line are
332
+ // application framing; replay them into the caller.
333
+ const leftover = ackReader.takeRemainder()
334
+ if (leftover.length > 0) socket.unshift(leftover)
335
+ resolve(socket)
336
+ }
337
+ socket.on('data', onData)
338
+ })
339
+ })
340
+ }
341
+
342
+ /**
343
+ * Dial the per-FC-host mTLS relay for an `mtls` handle.
344
+ *
345
+ * This is a pure TRANSPORT substitution for the `net.connect` arms:
346
+ * it `tls.connect`s the relay (presenting the fleet client cert and
347
+ * verifying the relay's server cert, `rejectUnauthorized: true`),
348
+ * asserts `socket.authorized`, then writes the single routing
349
+ * preamble line `SANDBOX <sandboxId>\n`. It does NOT write the guest
350
+ * `CONNECT 1024` line — the relay issues that host-side toward the
351
+ * jailed `v.sock`. The relay does NOT send an ack line: after the
352
+ * preamble it is a verbatim byte pump, so the caller hands the
353
+ * post-preamble socket straight up to the IDENTICAL framing loop the
354
+ * unix/vsock arms use (a `tls.TLSSocket` IS a `net.Socket`). The
355
+ * connect-retry budget, idle timeout, and the "fresh connection per
356
+ * request" resume-survival invariant are inherited unchanged.
357
+ *
358
+ * INTEGRATE CONTRACT (must match the relay, Track B): NO ack line.
359
+ * The relay reads `SANDBOX <id>\n`, then bridges; it writes nothing
360
+ * back until the agent does. If the relay is ever changed to emit an
361
+ * `OK` ack first, this arm must await that line (mirroring the vsock
362
+ * CONNECT-ack path) before resolving — today it does not.
363
+ */
364
+ private connectOnceMtls(
365
+ handle: Extract<SandboxAgentHandle, { kind: 'mtls' }>,
366
+ ): Promise<net.Socket> {
367
+ return new Promise<net.Socket>((resolve, reject) => {
368
+ const socket = tls.connect({
369
+ host: handle.host,
370
+ port: handle.port,
371
+ ca: handle.tls.ca,
372
+ cert: handle.tls.cert,
373
+ key: handle.tls.key,
374
+ servername: handle.tls.servername,
375
+ rejectUnauthorized: true,
376
+ minVersion: 'TLSv1.3',
377
+ })
378
+ let settled = false
379
+ const timer = setTimeout(() => {
380
+ if (settled) return
381
+ settled = true
382
+ socket.destroy()
383
+ reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
384
+ }, this.connectTimeoutMs)
385
+ timer.unref()
386
+
387
+ const fail = (err: Error) => {
388
+ if (settled) return
389
+ settled = true
390
+ clearTimeout(timer)
391
+ socket.destroy()
392
+ reject(err)
393
+ }
394
+
395
+ socket.once('error', fail)
396
+
397
+ // `secureConnect` fires only after the cert chain is verified
398
+ // (rejectUnauthorized rejects a bad/missing-CA server via 'error'
399
+ // before this). Belt-and-suspenders: assert `authorized` too.
400
+ socket.once('secureConnect', () => {
401
+ if (settled) return
402
+ if (!socket.authorized) {
403
+ fail(
404
+ new Error(
405
+ `mtls transport: relay server cert not authorized: ${
406
+ socket.authorizationError ?? 'unknown'
407
+ }`,
408
+ ),
409
+ )
410
+ return
411
+ }
412
+ settled = true
413
+ clearTimeout(timer)
414
+ socket.removeListener('error', fail)
415
+ // Routing preamble — the host-relay analogue of the vsock
416
+ // `CONNECT <port>` line. The relay consumes it, resolves the
417
+ // jailed v.sock, and issues the guest CONNECT itself; the
418
+ // caller writes NOTHING further until the framing loop.
419
+ socket.write(`SANDBOX ${handle.sandboxId}\n`)
420
+ resolve(socket)
421
+ })
422
+ })
423
+ }
424
+
425
+ /**
426
+ * Send one framed request and read one framed JSON reply (file-IO +
427
+ * healthz). Applies the read-idle timeout so a post-resume hung read
428
+ * is torn down rather than wedging the caller.
429
+ */
430
+ async request<T>(req: AgentRequest): Promise<T> {
431
+ const socket = await this.dial()
432
+ return await new Promise<T>((resolve, reject) => {
433
+ const reader = new FrameReader()
434
+ let settled = false
435
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
436
+ if (settled) return
437
+ settled = true
438
+ socket.destroy()
439
+ reject(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`))
440
+ })
441
+ const finish = (err: Error | null, value?: T) => {
442
+ if (settled) return
443
+ settled = true
444
+ idle.clear()
445
+ socket.destroy()
446
+ if (err) reject(err)
447
+ else resolve(value as T)
448
+ }
449
+ socket.on('data', (chunk: Buffer) => {
450
+ idle.bump()
451
+ let frames: string[]
452
+ try {
453
+ frames = reader.push(chunk)
454
+ } catch (err) {
455
+ finish(err instanceof Error ? err : new Error(String(err)))
456
+ return
457
+ }
458
+ const first = frames[0]
459
+ if (first !== undefined) {
460
+ try {
461
+ finish(null, JSON.parse(first) as T)
462
+ } catch (err) {
463
+ finish(err instanceof Error ? err : new Error(String(err)))
464
+ }
465
+ }
466
+ })
467
+ socket.once('error', (err) => finish(err))
468
+ socket.once('close', () => finish(new Error('vsock transport: socket closed before reply')))
469
+ idle.bump()
470
+ socket.write(frame(JSON.stringify(req)))
471
+ })
472
+ }
473
+
474
+ /**
475
+ * Send an `/execute` and accumulate the streamed NDJSON frames into a
476
+ * {@link SandboxExecResult} via the shared {@link ExecResultAccumulator}.
477
+ * The agent terminates the stream with a zero-length frame.
478
+ */
479
+ async execute(body: ExecRequest): Promise<SandboxExecResult> {
480
+ const socket = await this.dial()
481
+ const start = Date.now()
482
+ return await new Promise<SandboxExecResult>((resolve, reject) => {
483
+ const reader = new FrameReader()
484
+ const acc = new ExecResultAccumulator(start)
485
+ let settled = false
486
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
487
+ if (settled) return
488
+ settled = true
489
+ socket.destroy()
490
+ reject(
491
+ new Error(`vsock transport: exec read idle timeout after ${this.readIdleTimeoutMs}ms`),
492
+ )
493
+ })
494
+ const finish = (err: Error | null, value?: SandboxExecResult) => {
495
+ if (settled) return
496
+ settled = true
497
+ idle.clear()
498
+ socket.destroy()
499
+ if (err) reject(err)
500
+ else resolve(value as SandboxExecResult)
501
+ }
502
+ socket.on('data', (chunk: Buffer) => {
503
+ idle.bump()
504
+ let frames: string[]
505
+ try {
506
+ frames = reader.push(chunk)
507
+ } catch (err) {
508
+ finish(err instanceof Error ? err : new Error(String(err)))
509
+ return
510
+ }
511
+ for (const payload of frames) {
512
+ if (payload.length === 0) {
513
+ // Zero-length terminator. If a result was seen, we are
514
+ // done; otherwise the stream ended without a result.
515
+ finish(
516
+ acc.done ? null : new Error('exec stream ended without a result event'),
517
+ acc.finish(),
518
+ )
519
+ return
520
+ }
521
+ const event = parseExecLine(payload)
522
+ if (!event) continue // malformed line — swallow (docker parity)
523
+ try {
524
+ if (acc.push(event)) {
525
+ // Terminal result seen; wait for terminator but we can
526
+ // resolve now — the agent closes after the terminator.
527
+ }
528
+ } catch (err) {
529
+ finish(err instanceof Error ? err : new Error(String(err)))
530
+ return
531
+ }
532
+ }
533
+ })
534
+ socket.once('error', (err) => finish(err))
535
+ socket.once('close', () => {
536
+ // Stream closed. If a result arrived, deliver it (some agents
537
+ // close right after the terminator without a separate event);
538
+ // otherwise it is a truncated stream.
539
+ finish(
540
+ acc.done ? null : new Error('vsock transport: socket closed before exec result'),
541
+ acc.finish(),
542
+ )
543
+ })
544
+ idle.bump()
545
+ socket.write(frame(JSON.stringify({ op: 'execute', body } satisfies AgentRequest)))
546
+ })
547
+ }
548
+
549
+ /** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
550
+ async healthz(): Promise<boolean> {
551
+ try {
552
+ const res = await this.request<{ ok?: boolean }>({ op: 'healthz' })
553
+ return res.ok === true
554
+ } catch {
555
+ return false
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Poll the agent until a healthz succeeds or the timeout elapses.
561
+ * Mirrors the HTTP `waitForWorkerReady`, but over the vsock dialer
562
+ * (which already carries connect retry) — used by the backend's
563
+ * post-create readiness fence.
564
+ */
565
+ async waitForReady(timeoutMs: number, pollIntervalMs: number): Promise<void> {
566
+ const deadline = Date.now() + timeoutMs
567
+ let lastErr: unknown
568
+ while (Date.now() < deadline) {
569
+ try {
570
+ if (await this.healthz()) return
571
+ lastErr = new Error('healthz returned not-ok')
572
+ } catch (err) {
573
+ lastErr = err
574
+ }
575
+ await delay(pollIntervalMs)
576
+ }
577
+ throw new Error(
578
+ `vsock transport: agent did not become ready within ${timeoutMs}ms: ${
579
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
580
+ }`,
581
+ )
582
+ }
583
+
584
+ async writeFile(path: string, content: Buffer): Promise<void> {
585
+ const res = await this.request<WriteFileResponse>({
586
+ op: 'write-file',
587
+ body: { path, content: content.toString('base64'), encoding: 'base64' },
588
+ })
589
+ if (!res.ok) {
590
+ throw new Error(res.error ?? 'write-file failed')
591
+ }
592
+ }
593
+
594
+ async readFile(path: string): Promise<Buffer> {
595
+ const res = await this.request<ReadFileResponse>({
596
+ op: 'read-file',
597
+ body: { path, encoding: 'base64' },
598
+ })
599
+ if (!res.ok || typeof res.content !== 'string') {
600
+ throw new Error(res.error ?? 'read-file: no content')
601
+ }
602
+ return Buffer.from(res.content, 'base64')
603
+ }
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Small helpers
608
+ // ---------------------------------------------------------------------------
609
+
610
+ /** Reads exactly one `\n`-terminated line (used for the CONNECT ack). */
611
+ class LineReader {
612
+ private buf: Buffer = Buffer.alloc(0)
613
+ private remainder: Buffer = Buffer.alloc(0)
614
+
615
+ push(chunk: Buffer): string | undefined {
616
+ this.buf = Buffer.concat([this.buf, chunk])
617
+ const nl = this.buf.indexOf(0x0a)
618
+ if (nl < 0) return undefined
619
+ const line = this.buf.subarray(0, nl).toString('utf8')
620
+ this.remainder = Buffer.from(this.buf.subarray(nl + 1))
621
+ return line
622
+ }
623
+
624
+ takeRemainder(): Buffer {
625
+ const r = this.remainder
626
+ this.remainder = Buffer.alloc(0)
627
+ return r
628
+ }
629
+ }
630
+
631
+ /** Resets a timer on every byte; fires `onIdle` after `ms` of silence. */
632
+ class IdleTimer {
633
+ private timer: NodeJS.Timeout | undefined
634
+ constructor(
635
+ private readonly ms: number,
636
+ private readonly onIdle: () => void,
637
+ ) {}
638
+ bump(): void {
639
+ if (this.ms <= 0) return
640
+ this.clear()
641
+ this.timer = setTimeout(this.onIdle, this.ms)
642
+ this.timer.unref()
643
+ }
644
+ clear(): void {
645
+ if (this.timer) clearTimeout(this.timer)
646
+ this.timer = undefined
647
+ }
648
+ }
649
+
650
+ function delay(ms: number): Promise<void> {
651
+ return new Promise((resolve) => setTimeout(resolve, ms))
652
+ }
653
+
654
+ function describeHandle(handle: SandboxAgentHandle): string {
655
+ switch (handle.kind) {
656
+ case 'unix':
657
+ return `unix:${handle.path}`
658
+ case 'vsock':
659
+ return `vsock:${handle.udsPath}#${handle.port}`
660
+ case 'mtls':
661
+ return `mtls:${handle.host}:${handle.port}/${handle.sandboxId}`
662
+ }
663
+ }
664
+
665
+ // Internal framing helpers exported for the transport unit tests so the
666
+ // agent stand-in and the round-trip assertions share one framing impl.
667
+ export const __framing = { frame, FrameReader }