@namzu/sandbox 14.0.0 → 15.0.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 (99) hide show
  1. package/CHANGELOG.md +838 -0
  2. package/README.md +310 -14
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  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 +19 -1
  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 +12 -2
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +459 -8
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +136 -0
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +539 -6
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1171 -24
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1088 -11
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  22. package/dist/backends/kubernetes/egress-policy.js +2173 -29
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  24. package/dist/backends/kubernetes/identity.d.ts +193 -0
  25. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  26. package/dist/backends/kubernetes/identity.js +147 -0
  27. package/dist/backends/kubernetes/identity.js.map +1 -0
  28. package/dist/backends/kubernetes/index.d.ts +678 -33
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  30. package/dist/backends/kubernetes/index.js +1180 -95
  31. package/dist/backends/kubernetes/index.js.map +1 -1
  32. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  33. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  34. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  35. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  36. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  38. package/dist/backends/kubernetes/k8s-client.js +359 -52
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  40. package/dist/backends/kubernetes/lease.d.ts +40 -14
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  42. package/dist/backends/kubernetes/lease.js +68 -18
  43. package/dist/backends/kubernetes/lease.js.map +1 -1
  44. package/dist/backends/kubernetes/objects.d.ts +423 -3
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  46. package/dist/backends/kubernetes/objects.js +364 -2
  47. package/dist/backends/kubernetes/objects.js.map +1 -1
  48. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  52. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  53. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/rbac.js +177 -0
  55. package/dist/backends/kubernetes/rbac.js.map +1 -0
  56. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  57. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  58. package/dist/backends/kubernetes/sandbox.js +149 -15
  59. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  60. package/dist/backends/kubernetes/transport.d.ts +935 -9
  61. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  62. package/dist/backends/kubernetes/transport.js +1958 -62
  63. package/dist/backends/kubernetes/transport.js.map +1 -1
  64. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  65. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  66. package/dist/backends/kubernetes/workspace.js +2825 -186
  67. package/dist/backends/kubernetes/workspace.js.map +1 -1
  68. package/dist/backends/remote-execution-controller.d.ts +14 -0
  69. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  70. package/dist/backends/remote-execution-controller.js.map +1 -1
  71. package/dist/index.d.ts +231 -13
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +247 -5
  74. package/dist/index.js.map +1 -1
  75. package/dist/testing/sandbox-conformance.d.ts +39 -5
  76. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  77. package/dist/testing/sandbox-conformance.js +436 -5
  78. package/dist/testing/sandbox-conformance.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/backends/aci-standby-pool/index.ts +16 -1
  81. package/src/backends/docker/index.ts +22 -1
  82. package/src/backends/firecracker/index.ts +14 -2
  83. package/src/backends/firecracker/protocol.ts +514 -6
  84. package/src/backends/firecracker/transport.ts +1492 -40
  85. package/src/backends/kubernetes/egress-policy.ts +3064 -53
  86. package/src/backends/kubernetes/identity.ts +261 -0
  87. package/src/backends/kubernetes/index.ts +1785 -127
  88. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  89. package/src/backends/kubernetes/k8s-client.ts +444 -54
  90. package/src/backends/kubernetes/lease.ts +75 -19
  91. package/src/backends/kubernetes/objects.ts +626 -6
  92. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  93. package/src/backends/kubernetes/rbac.ts +192 -0
  94. package/src/backends/kubernetes/sandbox.ts +218 -20
  95. package/src/backends/kubernetes/transport.ts +2733 -124
  96. package/src/backends/kubernetes/workspace.ts +4476 -222
  97. package/src/backends/remote-execution-controller.ts +14 -0
  98. package/src/index.ts +595 -14
  99. package/src/testing/sandbox-conformance.ts +540 -5
@@ -53,6 +53,7 @@
53
53
  * transport resume-survivable by construction.
54
54
  */
55
55
 
56
+ import { randomUUID } from 'node:crypto'
56
57
  import net from 'node:net'
57
58
  import tls from 'node:tls'
58
59
 
@@ -60,6 +61,7 @@ import type {
60
61
  OpenTerminalOptions,
61
62
  SandboxExecOptions,
62
63
  SandboxExecResult,
64
+ SandboxReadFileOptions,
63
65
  SandboxTcpConnectOptions,
64
66
  SandboxTcpConnection,
65
67
  TerminalSession,
@@ -74,16 +76,30 @@ import {
74
76
  } from '../remote-execution-controller.js'
75
77
  import {
76
78
  type AgentRequestCredential,
79
+ type AttachSessionRequest,
77
80
  type ExecRequest,
78
81
  ExecResultAccumulator,
82
+ type FlushRequest,
83
+ type GuestReplyIdentity,
84
+ type KillSessionRequest,
85
+ MIN_STREAM_HEARTBEAT_MS,
86
+ type QuiesceRequest,
87
+ READ_FILE_STREAM_FEATURE,
79
88
  type ReadFileRequest,
80
89
  type ReadFileResponse,
90
+ type ReadFileStreamEvent,
91
+ type ReadFileStreamRequest,
92
+ STREAM_HEARTBEAT_MAX_ECHO_FACTOR,
93
+ STREAM_HEARTBEAT_MISS_LIMIT,
94
+ type StartDetachedRequest,
81
95
  type TcpConnectRequest,
82
96
  type TcpInputEvent,
83
97
  type TcpOutputEvent,
84
98
  type TerminalInputEvent,
85
99
  type TerminalOpenRequest,
86
100
  type TerminalOutputEvent,
101
+ type TerminalReadyEvent,
102
+ WRITE_FILE_PARTS_FEATURE,
87
103
  type WriteFileRequest,
88
104
  type WriteFileResponse,
89
105
  parseExecLine,
@@ -195,15 +211,37 @@ export type WireSandboxAgentHandle =
195
211
  */
196
212
  export type AgentRequest = (
197
213
  | { readonly op: 'execute'; readonly body: ExecRequest }
198
- | { readonly op: 'reserve-execution' }
214
+ // `body` is optional and additive: the reservation the shared execution
215
+ // controller sends carries none and is byte-for-byte what it always
216
+ // was, while a caller that owns its own execution ids names one here.
217
+ | {
218
+ readonly op: 'reserve-execution'
219
+ readonly body?: { readonly executionId: string }
220
+ }
199
221
  | {
200
222
  readonly op: 'cancel-execution'
201
223
  readonly body: { readonly executionId: string }
202
224
  }
225
+ | {
226
+ readonly op: 'attach-execution'
227
+ readonly body: { readonly executionId: string; readonly fromOffset?: number }
228
+ }
203
229
  | { readonly op: 'read-file'; readonly body: ReadFileRequest }
230
+ | { readonly op: 'read-file-stream'; readonly body: ReadFileStreamRequest }
204
231
  | { readonly op: 'write-file'; readonly body: WriteFileRequest }
205
232
  | { readonly op: 'terminal'; readonly body: TerminalOpenRequest }
233
+ // The four session ops. Every one of them is additive and every one is
234
+ // sent only to a guest that advertised `sessions` in its `healthz`
235
+ // features — see {@link SESSIONS_FEATURE}.
236
+ | { readonly op: 'attach-session'; readonly body: AttachSessionRequest }
237
+ | { readonly op: 'start-detached'; readonly body: StartDetachedRequest }
238
+ | { readonly op: 'list-sessions' }
239
+ | { readonly op: 'kill-session'; readonly body: KillSessionRequest }
206
240
  | { readonly op: 'tcp-connect'; readonly body: TcpConnectRequest }
241
+ | { readonly op: 'quiesce'; readonly body: QuiesceRequest }
242
+ // Additive in exactly the way `quiesce` is, and sent only to a guest
243
+ // whose `healthz` named {@link FLUSH_FEATURE}.
244
+ | { readonly op: 'flush'; readonly body: FlushRequest }
207
245
  | { readonly op: 'healthz' }
208
246
  ) &
209
247
  // Intersected, not repeated per arm: the credential is orthogonal to
@@ -227,6 +265,23 @@ export interface VsockTransportOptions {
227
265
  * against the agent's fresh listen socket. Default 60000ms.
228
266
  */
229
267
  readonly readIdleTimeoutMs?: number
268
+ /**
269
+ * Interval, in milliseconds, of the per-stream liveness heartbeat on
270
+ * `openTerminal` and `openTcpConnection`. See `protocol.ts`'s
271
+ * `StreamHeartbeat` for the negotiation and what a heartbeat does and
272
+ * does not prove.
273
+ *
274
+ * **Undefined by default, and deliberately so.** This transport is
275
+ * shared with the Firecracker tier, where a default would force-close an
276
+ * existing consumer's quiet-but-alive terminal after three intervals —
277
+ * a changed default for a tier that asked for nothing. The Kubernetes
278
+ * backend opts in (`backends/kubernetes/index.ts`'s
279
+ * `DEFAULT_STREAM_HEARTBEAT_MS`); every other caller that passes nothing
280
+ * sends and expects exactly the frames it always did.
281
+ *
282
+ * A value of `0` or less is the same as leaving it out.
283
+ */
284
+ readonly heartbeatMs?: number
230
285
  /**
231
286
  * Fires once per successful dial with how long the connect took, in
232
287
  * milliseconds. Never fires with the handle's `token` or any request
@@ -236,6 +291,92 @@ export interface VsockTransportOptions {
236
291
  * vsock/mtls/unix arms are free to ignore it.
237
292
  */
238
293
  readonly onDial?: (durationMs: number) => void
294
+ /**
295
+ * Fires once per connect ATTEMPT, immediately before it is made, and
296
+ * carries nothing at all.
297
+ *
298
+ * {@link onDial} above only ever fires for an attempt that SUCCEEDED, and
299
+ * a caller that has to know a socket was never established cannot learn
300
+ * it from the attempt's error either: an attempt can be aborted — by its
301
+ * caller, or by a deadline shorter than {@link connectTimeoutMs} — before
302
+ * it has failed, and the abort is what the caller is then holding. Paired
303
+ * with `onDial`, this says "a dial was attempted and none of them handed
304
+ * back a socket", which on the `tcp` arm is exactly "nothing reached the
305
+ * guest": that arm resolves only on the socket's own `connect` event, so
306
+ * no byte can have been sent before `onDial` fired.
307
+ *
308
+ * Used by the kubernetes backend's `KubernetesAgentTransport` to decide
309
+ * whether a failed `exec()` may be retried against a replaced pod. The
310
+ * vsock/mtls/unix arms are free to ignore it.
311
+ */
312
+ readonly onDialAttempt?: () => void
313
+ /**
314
+ * Fires once for every reply this transport reads that the guest
315
+ * answered on an AUTHENTICATED basis — one control/file reply per
316
+ * `request`, and the opening `ready` frame of a terminal, a session
317
+ * attachment or a TCP stream.
318
+ *
319
+ * It carries the reply itself, read only for the optional identity
320
+ * fields `protocol.ts` documents ({@link GUEST_BOOT_ID_FEATURE}), and it
321
+ * is an OBSERVER: it cannot change the reply, it is called after the
322
+ * reply has been accepted, and a listener that throws is that listener's
323
+ * problem — never the caller's, whose result is already decided.
324
+ *
325
+ * Absent by default, which is what keeps this shared transport's
326
+ * behaviour identical for the Firecracker tier. The kubernetes backend
327
+ * sets it to follow the guest PROCESS behind a handle whose pod uid — its
328
+ * bind token — cannot change when the container is restarted in place.
329
+ */
330
+ readonly onGuestReply?: (reply: GuestReplyIdentity) => void
331
+ /**
332
+ * The largest `writeFile` body this transport will accept, in raw
333
+ * bytes. Default {@link DEFAULT_MAX_WRITE_FILE_BYTES} (1 GiB).
334
+ *
335
+ * A body above one frame is written in parts (see
336
+ * {@link VsockAgentTransport.writeFile}), so nothing about the wire
337
+ * stops a caller handing over a body larger than the guest's disk or
338
+ * this process's heap. This is the bound that says no first, by a
339
+ * number the caller chose, with {@link AgentWriteFileTooLargeError}
340
+ * naming it — rather than by an out-of-memory or an ENOSPC halfway
341
+ * through a sequence of parts.
342
+ *
343
+ * Checked before the route is chosen, so it caps EVERY body — a value
344
+ * set below what one frame carries caps the small single-frame writes
345
+ * too, which is the range a host capping what a caller may push into a
346
+ * workspace would most plausibly set it to.
347
+ */
348
+ readonly maxWriteFileBytes?: number
349
+ /**
350
+ * Raw bytes per part when a `writeFile` body is written in parts.
351
+ * Defaults to the largest part one frame can carry, and is clamped
352
+ * DOWN to that: a value above what a frame admits is not a way to
353
+ * send a bigger frame.
354
+ *
355
+ * Setting it also lowers the size at which a body is split at all,
356
+ * so a suite can exercise a multi-part write without allocating one.
357
+ * Leave it unset in production: the default is the fewest round trips
358
+ * the pre-auth ceiling allows.
359
+ */
360
+ readonly writeFilePartBytes?: number
361
+ /**
362
+ * Say that a failed connect attempt will NOT fix itself, so the retry
363
+ * budget above is not worth spending on it.
364
+ *
365
+ * Absent by default, which keeps every dial retrying for the whole budget
366
+ * exactly as it always has — the budget exists because the common connect
367
+ * failure IS transient (an agent re-listening after a resume answers
368
+ * `ECONNREFUSED` for a moment). The exception is a failure that is about
369
+ * the CALLER's own environment rather than the guest's: the kubernetes
370
+ * backend's Service FQDN does not resolve on a host with no cluster DNS,
371
+ * and re-asking the same resolver the same question for half a minute
372
+ * only ensures the caller's own deadline expires first and reports a
373
+ * timeout in place of the diagnosis.
374
+ *
375
+ * Called with each attempt's error, before the backoff. Returning true
376
+ * ends the loop; the error thrown is the same wrapper as an exhausted
377
+ * budget, carrying that attempt's failure as its `cause`.
378
+ */
379
+ readonly permanentDialFailure?: (error: unknown) => boolean
239
380
  }
240
381
 
241
382
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
@@ -271,6 +412,45 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
271
412
  */
272
413
  export const TCP_PREAUTH_FRAME_LIMIT_BYTES = 8 * 1024 * 1024
273
414
 
415
+ /**
416
+ * The guest agent's default ceiling on ANY frame, pre-auth or not —
417
+ * mirrors `agent.cjs`'s `MAX_FRAME_BYTES` (`NAMZU_AGENT_MAX_FRAME_BYTES`,
418
+ * default 256 MiB). It is the budget the `unix`/`vsock`/`mtls` arms are
419
+ * bounded by, since none of them runs a credential gate and so none of
420
+ * them ever pays the smaller pre-auth price.
421
+ */
422
+ export const GUEST_FRAME_LIMIT_BYTES = 256 * 1024 * 1024
423
+
424
+ /** Default {@link VsockTransportOptions.maxWriteFileBytes} — 1 GiB. */
425
+ export const DEFAULT_MAX_WRITE_FILE_BYTES = 1024 * 1024 * 1024
426
+
427
+ /**
428
+ * Idle time before the kernel sends its first TCP keepalive probe on a
429
+ * `tcp`-arm connection, host side. 15 s, matching the Kubernetes backend's
430
+ * default heartbeat interval, so the two bounds do not disagree about how
431
+ * long a silent connection is allowed to look healthy.
432
+ */
433
+ export const TCP_KEEPALIVE_INITIAL_DELAY_MS = 15_000
434
+
435
+ /**
436
+ * Slack subtracted from a frame budget when sizing a part, over and above
437
+ * the envelope this transport measures exactly. The guest's own accounting
438
+ * is of the framed payload, and a deployment is free to configure a
439
+ * slightly different ceiling than the default this side assumes; a
440
+ * kilobyte of headroom costs one part in a thousand and removes a whole
441
+ * class of off-by-a-few refusals.
442
+ */
443
+ const WRITE_FILE_PART_HEADROOM_BYTES = 1024
444
+
445
+ /**
446
+ * How long the best-effort removal of an abandoned part file may take.
447
+ * Bounded separately from the connect retry budget because it runs AFTER
448
+ * the caller's write has already failed — often because the peer is gone —
449
+ * and a caller waiting on a rejection should not wait out a retry budget
450
+ * for a cleanup whose failure it is never told about.
451
+ */
452
+ const WRITE_FILE_DISCARD_TIMEOUT_MS = 5_000
453
+
274
454
  /**
275
455
  * Thrown when a `tcp`-handle request's framed envelope (op + body +
276
456
  * token) would exceed {@link TCP_PREAUTH_FRAME_LIMIT_BYTES}. Named so a
@@ -284,6 +464,133 @@ export class AgentPreauthFrameTooLargeError extends Error {
284
464
  }
285
465
  }
286
466
 
467
+ /**
468
+ * Thrown when a `writeFile` body exceeds
469
+ * {@link VsockTransportOptions.maxWriteFileBytes}. Distinct from
470
+ * {@link AgentPreauthFrameTooLargeError}: that one says the WIRE cannot
471
+ * carry this in one frame (and, since the part protocol, only ever fires
472
+ * when the guest cannot carry it in several either), this one says the
473
+ * HOST was configured not to send a body this large at all.
474
+ */
475
+ export class AgentWriteFileTooLargeError extends Error {
476
+ constructor(message: string) {
477
+ super(message)
478
+ this.name = 'AgentWriteFileTooLargeError'
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Thrown when a read this transport cannot serve on the old whole-file op
484
+ * is asked of a guest that does not advertise
485
+ * {@link READ_FILE_STREAM_FEATURE} — a ranged `readFile`, or any
486
+ * `readFileStream`.
487
+ *
488
+ * A refusal rather than a fallback, and that is the whole point of the
489
+ * class: an agent that predates the feature IGNORES `offset`/`length` and
490
+ * answers with the WHOLE file, so silently taking the old path would hand
491
+ * a caller the entire file where it asked for a slice — a wrong answer
492
+ * dressed as a degraded one. Named so a host can tell "rebuild the guest
493
+ * image" apart from "that file is not there".
494
+ */
495
+ export class AgentReadFileStreamUnsupportedError extends Error {
496
+ constructor(message: string) {
497
+ super(message)
498
+ this.name = 'AgentReadFileStreamUnsupportedError'
499
+ }
500
+ }
501
+
502
+ /**
503
+ * How many bytes of a `read-file-stream` may sit decoded on this side
504
+ * while the consumer is slow, before the socket is paused.
505
+ *
506
+ * The bound exists because an `AsyncIterable` consumer pulls: without it a
507
+ * fast guest would fill the host's heap with exactly the whole file this
508
+ * op exists to avoid materialising. One chunk is the guest's own
509
+ * `NAMZU_AGENT_READ_FILE_STREAM_CHUNK_BYTES` (256 KiB by default), so this
510
+ * is a few chunks in flight and nothing like a file. A deployment that
511
+ * raises that variable raises the number of BYTES held here, not the
512
+ * number of chunks: this is a byte bound, so it keeps holding.
513
+ *
514
+ * Deliberately not derived from the guest's value. The guest is a
515
+ * different process on a different release train, this side has to bound
516
+ * its own heap before it has asked the guest anything, and a host that
517
+ * trusted a guest-supplied chunk size for its own bound would have no
518
+ * bound at all.
519
+ */
520
+ const READ_FILE_STREAM_HIGH_WATER_BYTES = 4 * 1024 * 1024
521
+
522
+ /**
523
+ * Thrown by {@link VsockAgentTransport}'s dial when it gives up without a
524
+ * socket — the retry budget spent, or the failure declared one waiting cannot
525
+ * cure. The underlying connect failure is its `cause`.
526
+ *
527
+ * A class, and not only a phrase in the message, because callers classify on
528
+ * it: "the failure came out of the dial" means NOTHING was sent to the guest,
529
+ * which is what makes a retry against a replaced pod safe (the kubernetes
530
+ * backend's `agentAddress: 'pod-ip'` re-read). A message a guest can quote
531
+ * back — a command's stderr, a path, a proxy's own error — cannot be allowed
532
+ * to claim that.
533
+ */
534
+ /**
535
+ * The two fields that turn a connection-bound terminal into a session, on
536
+ * the host's side of {@link VsockAgentTransport.openTerminal}.
537
+ *
538
+ * Both are optional and both are ignored by a guest that predates the
539
+ * session registry, which is why the caller — never this transport — is the
540
+ * one that checks the guest advertises the capability first.
541
+ */
542
+ export interface SessionTerminalOpen {
543
+ readonly sessionId?: string
544
+ readonly persistent?: boolean
545
+ }
546
+
547
+ /**
548
+ * One open terminal stream, plus what only a SESSION's reader needs: the
549
+ * guest's opening frame, the byte offset to come back at, and a way to stop
550
+ * reading without signalling the program.
551
+ */
552
+ export interface AgentTerminalStream {
553
+ readonly session: TerminalSession
554
+ /** The guest's `ready` frame. Carries the session fields, when there are any. */
555
+ readonly ready: TerminalReadyEvent
556
+ /** One past the newest retained byte this stream has delivered, if any. */
557
+ nextOffset(): number | undefined
558
+ /**
559
+ * End this attachment locally. Nothing is signalled in the guest: the
560
+ * program goes on running and its output goes on filling the retained
561
+ * log, which is the entire difference between this and `kill`.
562
+ */
563
+ detach(): void
564
+ }
565
+
566
+ /**
567
+ * Thrown — as the rejection of a session terminal's `exited` — when the
568
+ * attachment ended and the program did not.
569
+ *
570
+ * `exited` may not RESOLVE here: a resolved `exited` says the program is
571
+ * over, and reporting `exitCode: -1` for a shell that is still running in
572
+ * the pod is exactly the confusion this whole feature exists to remove. The
573
+ * offset is carried because it is what the next attach resumes from.
574
+ */
575
+ export class AgentSessionDetachedError extends Error {
576
+ override readonly name = 'AgentSessionDetachedError'
577
+
578
+ constructor(
579
+ readonly nextOffset: number | undefined,
580
+ message: string,
581
+ options?: { cause?: unknown },
582
+ ) {
583
+ super(message, options)
584
+ }
585
+ }
586
+
587
+ export class AgentDialFailedError extends Error {
588
+ constructor(message: string, options?: { cause?: unknown }) {
589
+ super(message, options)
590
+ this.name = 'AgentDialFailedError'
591
+ }
592
+ }
593
+
287
594
  /** Exact guest wire version accepted by this Firecracker transport. */
288
595
  export const FIRECRACKER_AGENT_PROTOCOL_VERSION = REMOTE_EXECUTION_PROTOCOL_VERSION
289
596
 
@@ -299,19 +606,40 @@ function frame(payload: string): Buffer {
299
606
  return Buffer.concat([header, body])
300
607
  }
301
608
 
609
+ /**
610
+ * A growable accumulator that frees the buffer it grew past a threshold.
611
+ * Sized once so both numbers are stated where they are read.
612
+ */
613
+ const FRAME_BUFFER_INITIAL_BYTES = 64 * 1024
614
+ const FRAME_BUFFER_RETAIN_BYTES = 1024 * 1024
615
+
302
616
  /**
303
617
  * Incremental frame reader. Feed it socket chunks; it yields complete
304
618
  * payloads. A zero-length frame is the exec stream terminator and is
305
619
  * surfaced as an empty string so the caller can stop.
620
+ *
621
+ * It accumulates into ONE buffer it grows geometrically, with a read
622
+ * cursor, rather than re-`concat`ing every arriving chunk onto a fresh
623
+ * allocation. The distinction only matters for a large frame, where it is
624
+ * the difference between linear and quadratic: a `read-file` reply for a
625
+ * 64 MiB file arrives as ~1400 socket chunks, and copying everything
626
+ * received so far onto each one of them spent half a minute of memcpy on
627
+ * a reply the socket delivered in under a second. Identical framing,
628
+ * identical errors, identical `bufferedBytes` — only the copying changes.
306
629
  */
307
630
  class FrameReader {
308
631
  private buf: Buffer = Buffer.alloc(0)
632
+ /** First byte not yet handed out as part of a frame. */
633
+ private start = 0
634
+ /** One past the last byte received. */
635
+ private end = 0
309
636
 
310
637
  push(chunk: Buffer): string[] {
311
- this.buf = this.buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buf, chunk])
638
+ this.append(chunk)
312
639
  const out: string[] = []
313
640
  for (;;) {
314
- const nl = this.buf.indexOf(0x0a) // '\n'
641
+ const view = this.buf.subarray(this.start, this.end)
642
+ const nl = view.indexOf(0x0a) // '\n'
315
643
  if (nl < 0 || nl < LENGTH_PREFIX_HEX) {
316
644
  // Need at least the hex header + newline.
317
645
  if (nl >= 0 && nl < LENGTH_PREFIX_HEX) {
@@ -319,7 +647,7 @@ class FrameReader {
319
647
  }
320
648
  break
321
649
  }
322
- const header = this.buf.subarray(0, nl).toString('ascii')
650
+ const header = view.subarray(0, nl).toString('ascii')
323
651
  if (!/^[0-9a-fA-F]{8}$/.test(header)) {
324
652
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
325
653
  }
@@ -328,16 +656,50 @@ class FrameReader {
328
656
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
329
657
  }
330
658
  const start = nl + 1
331
- if (this.buf.length < start + len) break // incomplete payload
332
- const payload = this.buf.subarray(start, start + len).toString('utf8')
333
- this.buf = this.buf.subarray(start + len)
334
- out.push(payload)
659
+ if (view.length < start + len) break // incomplete payload
660
+ out.push(view.subarray(start, start + len).toString('utf8'))
661
+ this.consume(start + len)
335
662
  }
336
663
  return out
337
664
  }
338
665
 
339
666
  get bufferedBytes(): number {
340
- return this.buf.length
667
+ return this.end - this.start
668
+ }
669
+
670
+ /** Copy `chunk` in, growing (and first compacting) only when needed. */
671
+ private append(chunk: Buffer): void {
672
+ if (chunk.length === 0) return
673
+ if (this.buf.length - this.end < chunk.length) {
674
+ const needed = this.bufferedBytes + chunk.length
675
+ if (this.buf.length >= needed) {
676
+ // Compacting the unread bytes to the front is enough.
677
+ this.buf.copy(this.buf, 0, this.start, this.end)
678
+ } else {
679
+ let capacity = this.buf.length > 0 ? this.buf.length : FRAME_BUFFER_INITIAL_BYTES
680
+ // Geometric, so the total copying across a whole reply stays
681
+ // proportional to its length rather than to its length squared.
682
+ while (capacity < needed) capacity *= 2
683
+ const grown = Buffer.allocUnsafe(capacity)
684
+ this.buf.copy(grown, 0, this.start, this.end)
685
+ this.buf = grown
686
+ }
687
+ this.end = this.bufferedBytes
688
+ this.start = 0
689
+ }
690
+ chunk.copy(this.buf, this.end)
691
+ this.end += chunk.length
692
+ }
693
+
694
+ /** Mark `bytes` from the read cursor as consumed. */
695
+ private consume(bytes: number): void {
696
+ this.start += bytes
697
+ if (this.start !== this.end) return
698
+ this.start = 0
699
+ this.end = 0
700
+ // A reply that grew the buffer to hundreds of megabytes should not
701
+ // keep holding them for the life of a long-lived connection.
702
+ if (this.buf.length > FRAME_BUFFER_RETAIN_BYTES) this.buf = Buffer.alloc(0)
341
703
  }
342
704
  }
343
705
 
@@ -353,7 +715,22 @@ export class VsockAgentTransport {
353
715
  private readonly connectRetryBudgetMs: number
354
716
  private readonly connectRetryIntervalMs: number
355
717
  private readonly readIdleTimeoutMs: number
718
+ /** Undefined → this transport negotiates no heartbeat at all. */
719
+ private readonly heartbeatMs?: number
356
720
  private readonly onDial?: (durationMs: number) => void
721
+ private readonly onDialAttempt?: () => void
722
+ private readonly onGuestReply?: (reply: GuestReplyIdentity) => void
723
+ private readonly maxWriteFileBytes: number
724
+ private readonly writeFilePartBytes?: number
725
+ /**
726
+ * What the guest advertised in `healthz`, cached for this handle's
727
+ * lifetime. A pod does not swap its agent binary while it is running,
728
+ * so the probe is asked once per transport and only when something
729
+ * actually depends on a capability — an ordinary write, exec, read or
730
+ * terminal pays nothing for it.
731
+ */
732
+ private guestFeatureList?: readonly string[]
733
+ private readonly permanentDialFailure?: (error: unknown) => boolean
357
734
  private readonly executionController: RemoteExecutionController<
358
735
  Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>
359
736
  >
@@ -365,7 +742,17 @@ export class VsockAgentTransport {
365
742
  this.connectRetryIntervalMs =
366
743
  options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS
367
744
  this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS
745
+ if (options.heartbeatMs !== undefined && options.heartbeatMs > 0) {
746
+ this.heartbeatMs = Math.floor(options.heartbeatMs)
747
+ }
368
748
  this.onDial = options.onDial
749
+ this.onDialAttempt = options.onDialAttempt
750
+ this.onGuestReply = options.onGuestReply
751
+ this.maxWriteFileBytes = options.maxWriteFileBytes ?? DEFAULT_MAX_WRITE_FILE_BYTES
752
+ if (options.writeFilePartBytes !== undefined) {
753
+ this.writeFilePartBytes = Math.max(1, Math.floor(options.writeFilePartBytes))
754
+ }
755
+ this.permanentDialFailure = options.permanentDialFailure
369
756
  const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
370
757
  label: 'framed microVM agent',
371
758
  reserve: async (signal) => await this.reserveExecution(signal),
@@ -395,29 +782,48 @@ export class VsockAgentTransport {
395
782
  * Dial the agent with the resume-survival retry budget. Resolves a
396
783
  * connected, post-handshake socket. Retries connect/handshake
397
784
  * failures (ECONNREFUSED while the agent re-listens after a resume,
398
- * a dropped CONNECT ack) until the budget is exhausted.
785
+ * a dropped CONNECT ack) until the budget is exhausted — or until
786
+ * {@link VsockTransportOptions.permanentDialFailure} says this particular
787
+ * failure is not one waiting will cure.
399
788
  */
400
789
  private async dial(signal?: AbortSignal): Promise<net.Socket> {
401
790
  const deadline = Date.now() + this.connectRetryBudgetMs
402
791
  const dialStartedAt = Date.now()
403
792
  let lastErr: unknown
793
+ let permanent = false
404
794
  for (;;) {
405
795
  signal?.throwIfAborted()
406
796
  try {
797
+ // Announced BEFORE the attempt, not after it fails: an attempt
798
+ // aborted mid-connect never reaches the catch below, and that
799
+ // is precisely the case a watcher needs to hear about.
800
+ this.onDialAttempt?.()
407
801
  const socket = await this.connectOnce(signal)
408
802
  this.onDial?.(Date.now() - dialStartedAt)
409
803
  return socket
410
804
  } catch (err) {
411
805
  if (signal?.aborted) throw signal.reason
412
806
  lastErr = err
807
+ if (this.permanentDialFailure?.(err) === true) {
808
+ permanent = true
809
+ break
810
+ }
413
811
  if (Date.now() >= deadline) break
414
812
  await delay(this.connectRetryIntervalMs, signal)
415
813
  }
416
814
  }
417
- throw new Error(
418
- `vsock transport: could not connect to agent within ${this.connectRetryBudgetMs}ms (handle=${describeHandle(
419
- this.handle,
420
- )}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
815
+ throw new AgentDialFailedError(
816
+ // Two wordings, because claiming a 30 s budget was spent on a dial
817
+ // that gave up in 3 ms is a false statement in an error message.
818
+ // Both keep the "could not connect to agent" phrase: that is what
819
+ // callers classifying a dial failure match on.
820
+ permanent
821
+ ? `vsock transport: could not connect to agent, and the failure is not one retrying fixes (handle=${describeHandle(
822
+ this.handle,
823
+ )}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
824
+ : `vsock transport: could not connect to agent within ${this.connectRetryBudgetMs}ms (handle=${describeHandle(
825
+ this.handle,
826
+ )}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
421
827
  { cause: lastErr },
422
828
  )
423
829
  }
@@ -594,6 +1000,13 @@ export class VsockAgentTransport {
594
1000
  ): Promise<net.Socket> {
595
1001
  return new Promise<net.Socket>((resolve, reject) => {
596
1002
  const socket = net.connect({ host: handle.host, port: handle.port })
1003
+ // SO_KEEPALIVE on the ROUTED arm only. It proves only that the
1004
+ // peer's kernel answers — the application heartbeat is what proves
1005
+ // its event loop does — but it is what gets a half-open connection
1006
+ // through a middlebox reported at all, and it costs a probe every
1007
+ // {@link TCP_KEEPALIVE_INITIAL_DELAY_MS}. The unix/vsock/mtls arms
1008
+ // are deliberately untouched: those are the Firecracker tier's.
1009
+ socket.setKeepAlive(true, TCP_KEEPALIVE_INITIAL_DELAY_MS)
597
1010
  let settled = false
598
1011
  const fail = (err: Error) => {
599
1012
  if (settled) return
@@ -651,10 +1064,30 @@ export class VsockAgentTransport {
651
1064
  const size = Buffer.byteLength(payload, 'utf8')
652
1065
  if (size <= TCP_PREAUTH_FRAME_LIMIT_BYTES) return
653
1066
  throw new AgentPreauthFrameTooLargeError(
654
- `kubernetes tcp transport: request envelope is ${size} bytes, which exceeds the ${TCP_PREAUTH_FRAME_LIMIT_BYTES}-byte limit the guest agent enforces on an unauthenticated connection's first frame (NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES, default 8 MiB). Every tcp request dials a fresh connection, so this request WOULD be that connection's first frame. Chunking a large body across multiple frames is not implemented; reduce the payload or raise the deployment's NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES.`,
1067
+ `kubernetes tcp transport: request envelope is ${size} bytes, which exceeds the ${TCP_PREAUTH_FRAME_LIMIT_BYTES}-byte limit the guest agent enforces on an unauthenticated connection's first frame (NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES, default 8 MiB). Every tcp request dials a fresh connection, so this request WOULD be that connection's first frame. A large \`write-file\` body is split across frames automatically (see \`writeFile\`); every other op has to fit, so reduce the payload or raise the deployment's NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES.`,
655
1068
  )
656
1069
  }
657
1070
 
1071
+ /**
1072
+ * Hand one accepted reply to {@link VsockTransportOptions.onGuestReply},
1073
+ * and never let the listener's failure reach the caller.
1074
+ *
1075
+ * The caller's result is already decided by the time this runs — the
1076
+ * reply parsed, the frame accounted for — so a hook that throws must not
1077
+ * turn a successful read into a failed one. Swallowing is the only
1078
+ * behaviour that keeps an optional observer optional.
1079
+ */
1080
+ private observeGuestReply(reply: unknown): void {
1081
+ const observe = this.onGuestReply
1082
+ if (observe === undefined) return
1083
+ if (reply === null || typeof reply !== 'object') return
1084
+ try {
1085
+ observe(reply as GuestReplyIdentity)
1086
+ } catch {
1087
+ // See above: an observer cannot fail an operation.
1088
+ }
1089
+ }
1090
+
658
1091
  /**
659
1092
  * Send one framed request and read one framed JSON reply (file-IO +
660
1093
  * healthz). Applies the read-idle timeout so a post-resume hung read
@@ -705,6 +1138,7 @@ export class VsockAgentTransport {
705
1138
  if (first !== undefined) {
706
1139
  try {
707
1140
  response = JSON.parse(first) as T
1141
+ this.observeGuestReply(response)
708
1142
  if (reader.bufferedBytes > 0) {
709
1143
  finish(new Error('vsock transport: control reply has trailing partial data'))
710
1144
  return
@@ -935,10 +1369,11 @@ export class VsockAgentTransport {
935
1369
  /** Readiness probe. A healthy guest must also speak the exact host protocol. */
936
1370
  async healthz(signal?: AbortSignal): Promise<boolean> {
937
1371
  try {
938
- const res = await this.request<{ ok?: boolean; protocolVersion?: unknown }>(
939
- { op: 'healthz' },
940
- signal,
941
- )
1372
+ const res = await this.request<{
1373
+ ok?: boolean
1374
+ protocolVersion?: unknown
1375
+ features?: unknown
1376
+ }>({ op: 'healthz' }, signal)
942
1377
  if (res.ok !== true) return false
943
1378
  if (res.protocolVersion !== FIRECRACKER_AGENT_PROTOCOL_VERSION) {
944
1379
  const actual =
@@ -947,6 +1382,22 @@ export class VsockAgentTransport {
947
1382
  `Firecracker guest protocol version mismatch: expected ${FIRECRACKER_AGENT_PROTOCOL_VERSION}, received ${actual}. Rebuild the golden image from the same Namzu release.`,
948
1383
  )
949
1384
  }
1385
+ // The reply that answers readiness also answers what the guest can
1386
+ // do, so the capability caches are filled here rather than by a
1387
+ // second probe later. What that saves depends on the tier: the
1388
+ // Firecracker backend fences on `waitForReady` after create, so
1389
+ // its first `readFile` costs one connection, while the Kubernetes
1390
+ // backend fences on the pod's ready condition and never calls
1391
+ // this — so its first read of a transport's life pays one extra
1392
+ // dial to ask, and every read after it is back to one.
1393
+ //
1394
+ // AFTER both checks, not before: a reply this method is about to
1395
+ // reject is not a reply to believe about anything else, and a
1396
+ // guest that is not ready is one whose features are not yet known
1397
+ // rather than one that has none.
1398
+ this.guestFeatureList = Array.isArray(res.features)
1399
+ ? res.features.filter((value): value is string => typeof value === 'string')
1400
+ : []
950
1401
  return true
951
1402
  } catch (error) {
952
1403
  if (signal?.aborted) throw signal.reason
@@ -991,27 +1442,744 @@ export class VsockAgentTransport {
991
1442
  )
992
1443
  }
993
1444
 
994
- async writeFile(path: string, content: Buffer): Promise<void> {
995
- const res = await this.request<WriteFileResponse>({
996
- op: 'write-file',
997
- body: { path, content: content.toString('base64'), encoding: 'base64' },
998
- })
1445
+ /**
1446
+ * Write a whole file into the guest workspace.
1447
+ *
1448
+ * A body that fits one frame goes as it always has: a single
1449
+ * `write-file` envelope carrying the base64 content, one round trip,
1450
+ * byte-for-byte the request this transport has always sent.
1451
+ *
1452
+ * A body that does NOT fit is the case this exists for. On the `tcp`
1453
+ * arm every request dials a fresh connection, so every request is that
1454
+ * connection's first, not-yet-authenticated frame (the credential
1455
+ * rides in the envelope) and is bounded by
1456
+ * {@link TCP_PREAUTH_FRAME_LIMIT_BYTES} — about 5.9 MiB of file
1457
+ * content — on EVERY call, not once. Raising the guest's
1458
+ * `NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES` trades away the pre-auth
1459
+ * budget that ceiling exists to bound, so the fix is on this side:
1460
+ * split the body into parts that each fit, append them to a temporary
1461
+ * SIBLING of the target inside the same workspace jail, and finish
1462
+ * with an atomic `rename` onto the target.
1463
+ *
1464
+ * What that buys, and why the shape is what it is:
1465
+ *
1466
+ * - **A reader never sees a half-written file.** The target changes
1467
+ * exactly once, in the final part's `rename`. A sequence that dies
1468
+ * at part 3 of 9 leaves the target exactly as it was — including
1469
+ * not existing.
1470
+ * - **A lost or duplicated part is detected, not written.** Each part
1471
+ * names the offset it starts at and the guest refuses it unless
1472
+ * that equals the temp file's current size.
1473
+ * - **An abandoned sequence cleans up after itself.** An abort or a
1474
+ * transport failure removes the temp file (best effort — a peer
1475
+ * that has gone away cannot be asked to) and rejects.
1476
+ * - **Parts go out sequentially on fresh connections**, which is what
1477
+ * the offset check assumes and what keeps the guest's pre-auth
1478
+ * connection pool holding one of this caller's sockets at a time.
1479
+ *
1480
+ * The guest must ADVERTISE the capability (`${WRITE_FILE_PARTS_FEATURE}`
1481
+ * in its `healthz` reply) before a single part is sent. An agent that
1482
+ * predates the part protocol would read a part's `content` as a whole
1483
+ * file; it never receives one, and an oversized body against such a
1484
+ * guest still fails with the named
1485
+ * {@link AgentPreauthFrameTooLargeError} it always did.
1486
+ *
1487
+ * {@link VsockTransportOptions.maxWriteFileBytes} is checked FIRST, on
1488
+ * every body and before anything about the wire is considered: it is a
1489
+ * bound on what a caller may push into a workspace, not a bound on
1490
+ * multi-part writes, so a host that lowers it below the frame budget
1491
+ * gets the cap it asked for rather than none.
1492
+ */
1493
+ async writeFile(path: string, content: Buffer, signal?: AbortSignal): Promise<void> {
1494
+ if (content.length > this.maxWriteFileBytes) {
1495
+ throw new AgentWriteFileTooLargeError(
1496
+ `write-file: a body of ${content.length} bytes exceeds this transport's maxWriteFileBytes of ${this.maxWriteFileBytes}. Raise VsockTransportOptions.maxWriteFileBytes to admit it.`,
1497
+ )
1498
+ }
1499
+ const budget = this.singleFrameBudgetBytes()
1500
+ const wholeEnvelopeBytes = this.writeFileEnvelopeBytes(
1501
+ { path, content: '', encoding: 'base64' },
1502
+ content.length,
1503
+ )
1504
+ // The configured part size, when there is one, also decides when a
1505
+ // body is split at all — so that with NO configuration the split
1506
+ // point is exactly the wire's own ceiling and every body that used
1507
+ // to travel in one frame still does.
1508
+ const splitsAnyway =
1509
+ this.writeFilePartBytes !== undefined && content.length > this.writeFilePartBytes
1510
+ if (wholeEnvelopeBytes <= budget && !splitsAnyway) {
1511
+ await this.writeFileWhole(path, content, signal)
1512
+ return
1513
+ }
1514
+ if (!(await this.guestSupportsWriteFileParts(signal))) {
1515
+ throw this.oversizedWriteFileError(content.length, wholeEnvelopeBytes, budget)
1516
+ }
1517
+ await this.writeFileInParts(path, content, budget, signal)
1518
+ }
1519
+
1520
+ /** Today's single-frame write, unchanged — see {@link writeFile}. */
1521
+ private async writeFileWhole(path: string, content: Buffer, signal?: AbortSignal): Promise<void> {
1522
+ const res = await this.request<WriteFileResponse>(
1523
+ {
1524
+ op: 'write-file',
1525
+ body: { path, content: content.toString('base64'), encoding: 'base64' },
1526
+ },
1527
+ signal,
1528
+ )
999
1529
  if (!res.ok) {
1000
1530
  throw new Error(res.error ?? 'write-file failed')
1001
1531
  }
1002
1532
  }
1003
1533
 
1004
- async readFile(path: string): Promise<Buffer> {
1005
- const res = await this.request<ReadFileResponse>({
1006
- op: 'read-file',
1007
- body: { path, encoding: 'base64' },
1534
+ /**
1535
+ * The frame budget one request has on this handle: the pre-auth
1536
+ * ceiling on the credentialed `tcp` arm, the guest's global frame
1537
+ * ceiling on the host-local arms, which authenticate nothing and so
1538
+ * never pay the smaller price.
1539
+ */
1540
+ private singleFrameBudgetBytes(): number {
1541
+ return this.handle.kind === 'tcp' ? TCP_PREAUTH_FRAME_LIMIT_BYTES : GUEST_FRAME_LIMIT_BYTES
1542
+ }
1543
+
1544
+ /**
1545
+ * Exact framed size of a `write-file` envelope whose `content` field
1546
+ * holds `contentBytes` raw bytes base64-encoded — WITHOUT encoding
1547
+ * them, so sizing a 1 GiB body costs nothing and never builds a string
1548
+ * longer than V8 permits.
1549
+ *
1550
+ * Exact rather than approximate because the base64 alphabet contains
1551
+ * no character `JSON.stringify` escapes, so the encoded content
1552
+ * contributes precisely its own length to the envelope and the rest of
1553
+ * the envelope (paths, the token, the part fields) is measured as it
1554
+ * will actually be serialized.
1555
+ */
1556
+ private writeFileEnvelopeBytes(body: WriteFileRequest, contentBytes: number): number {
1557
+ const envelope = this.withCredential({ op: 'write-file', body } satisfies AgentRequest)
1558
+ return Buffer.byteLength(JSON.stringify(envelope), 'utf8') + base64Length(contentBytes)
1559
+ }
1560
+
1561
+ /**
1562
+ * Ask the guest whether it implements the part protocol. Cached for
1563
+ * the transport's lifetime; a transport failure propagates rather than
1564
+ * reading as "not supported", because answering a broken connection
1565
+ * with a too-large error would name the wrong cause.
1566
+ */
1567
+ private async guestSupportsWriteFileParts(signal?: AbortSignal): Promise<boolean> {
1568
+ return (await this.guestFeatures(signal)).includes(WRITE_FILE_PARTS_FEATURE)
1569
+ }
1570
+
1571
+ /**
1572
+ * The capability strings this guest advertises in `healthz`, cached for
1573
+ * this transport's lifetime.
1574
+ *
1575
+ * One list, asked once, for every optional op: the write-file part
1576
+ * protocol and the execution-attach ops both read it, and a second
1577
+ * cache would mean a second probe against a guest that answers both
1578
+ * questions in one reply. A transport FAILURE propagates rather than
1579
+ * reading as "not supported", because answering a broken connection
1580
+ * with a capability refusal would name the wrong cause.
1581
+ */
1582
+ async guestFeatures(signal?: AbortSignal): Promise<readonly string[]> {
1583
+ if (this.guestFeatureList !== undefined) return this.guestFeatureList
1584
+ const reply = await this.request<{ features?: unknown }>({ op: 'healthz' }, signal)
1585
+ const features = Array.isArray(reply.features)
1586
+ ? reply.features.filter((value): value is string => typeof value === 'string')
1587
+ : []
1588
+ this.guestFeatureList = features
1589
+ return features
1590
+ }
1591
+
1592
+ /**
1593
+ * Send one framed request and read a STREAM of framed JSON events until
1594
+ * the agent's zero-length terminator, handing each event to `onEvent`.
1595
+ *
1596
+ * The generic half of what {@link executeRaw} does, without any of its
1597
+ * opinions about what the events mean: `executeRaw` owns the exec
1598
+ * NDJSON union and the {@link ExecResultAccumulator}, and this owns
1599
+ * dial, framing, the terminator, the observation bound and the
1600
+ * post-terminator close. Additive — nothing already shipped calls it —
1601
+ * so the Firecracker tier's behaviour is untouched and a later streamed
1602
+ * op reuses it rather than writing a fourth copy of this loop.
1603
+ *
1604
+ * There is deliberately NO read-idle timeout. A stream that exists to
1605
+ * follow a long, quiet command must not be torn down for being quiet;
1606
+ * the whole observation is bounded by `observationTimeoutMs` instead,
1607
+ * exactly as an `execute` stream is.
1608
+ */
1609
+ async streamFramedRequest(
1610
+ req: AgentRequest,
1611
+ onEvent: (event: Record<string, unknown>) => void,
1612
+ options: { readonly observationTimeoutMs: number },
1613
+ signal?: AbortSignal,
1614
+ ): Promise<void> {
1615
+ const envelope = this.withCredential(req)
1616
+ const payload = JSON.stringify(envelope)
1617
+ this.assertPreauthBudget(payload)
1618
+ const socket = await this.dial(signal)
1619
+ const observationTimeoutMs = Math.min(MAX_TIMER_DELAY_MS, options.observationTimeoutMs)
1620
+ return await new Promise<void>((resolve, reject) => {
1621
+ const reader = new FrameReader()
1622
+ let settled = false
1623
+ let terminated = false
1624
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
1625
+ const finish = (err: Error | null) => {
1626
+ if (settled) return
1627
+ settled = true
1628
+ clearTimeout(observationTimer)
1629
+ if (closeTimer) clearTimeout(closeTimer)
1630
+ signal?.removeEventListener('abort', abort)
1631
+ socket.destroy()
1632
+ if (err) reject(err)
1633
+ else resolve()
1634
+ }
1635
+ const abort = () => finish(signalError(signal))
1636
+ const observationTimer = setTimeout(
1637
+ () =>
1638
+ finish(
1639
+ new Error(`vsock transport: stream observation exceeded ${observationTimeoutMs}ms`),
1640
+ ),
1641
+ observationTimeoutMs,
1642
+ )
1643
+ observationTimer.unref()
1644
+ socket.on('data', (chunk: Buffer) => {
1645
+ let frames: string[]
1646
+ try {
1647
+ frames = reader.push(chunk)
1648
+ } catch (err) {
1649
+ finish(err instanceof Error ? err : new Error(String(err)))
1650
+ return
1651
+ }
1652
+ for (const frameText of frames) {
1653
+ if (terminated) {
1654
+ finish(new Error('vsock transport: stream emitted data after its terminator'))
1655
+ return
1656
+ }
1657
+ if (frameText.length === 0) {
1658
+ terminated = true
1659
+ continue
1660
+ }
1661
+ let event: Record<string, unknown>
1662
+ try {
1663
+ event = JSON.parse(frameText) as Record<string, unknown>
1664
+ } catch (err) {
1665
+ finish(err instanceof Error ? err : new Error(String(err)))
1666
+ return
1667
+ }
1668
+ try {
1669
+ onEvent(event)
1670
+ } catch (err) {
1671
+ finish(err instanceof Error ? err : new Error(String(err)))
1672
+ return
1673
+ }
1674
+ }
1675
+ if (terminated) {
1676
+ if (reader.bufferedBytes > 0) {
1677
+ finish(new Error('vsock transport: stream has trailing partial data'))
1678
+ return
1679
+ }
1680
+ closeTimer = setTimeout(
1681
+ () => finish(new Error('vsock transport: stream peer did not close after terminator')),
1682
+ POST_RESPONSE_CLOSE_TIMEOUT_MS,
1683
+ )
1684
+ closeTimer.unref()
1685
+ }
1686
+ })
1687
+ socket.once('error', (err) => finish(err))
1688
+ socket.once('close', () => {
1689
+ if (terminated) finish(null)
1690
+ else finish(new Error('vsock transport: socket closed before stream terminator'))
1691
+ })
1692
+ if (signal?.aborted) {
1693
+ abort()
1694
+ return
1695
+ }
1696
+ signal?.addEventListener('abort', abort, { once: true })
1697
+ socket.write(frame(payload))
1008
1698
  })
1699
+ }
1700
+
1701
+ /** The refusal for a body no frame can carry and no guest can take in parts. */
1702
+ private oversizedWriteFileError(
1703
+ contentBytes: number,
1704
+ envelopeBytes: number,
1705
+ budget: number,
1706
+ ): Error {
1707
+ const missing = `This guest does not advertise the '${WRITE_FILE_PARTS_FEATURE}' healthz feature, so the body cannot be split across frames either; rebuild the guest image from this Namzu release, or reduce the payload.`
1708
+ if (this.handle.kind === 'tcp') {
1709
+ return new AgentPreauthFrameTooLargeError(
1710
+ `kubernetes tcp transport: a write-file of ${contentBytes} bytes needs a ${envelopeBytes}-byte request envelope, which exceeds the ${TCP_PREAUTH_FRAME_LIMIT_BYTES}-byte limit the guest agent enforces on an unauthenticated connection's first frame (NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES, default 8 MiB). Every tcp request dials a fresh connection, so this request WOULD be that connection's first frame. ${missing} Raising the deployment's NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES also admits it, at the cost of the pre-auth budget that ceiling bounds.`,
1711
+ )
1712
+ }
1713
+ return new Error(
1714
+ `vsock transport: a write-file of ${contentBytes} bytes needs a ${envelopeBytes}-byte request envelope, which exceeds the ${budget}-byte frame ceiling the guest agent enforces (NAMZU_AGENT_MAX_FRAME_BYTES, default 256 MiB). ${missing}`,
1715
+ )
1716
+ }
1717
+
1718
+ /**
1719
+ * Write `content` to `target` as a sequence of parts. See
1720
+ * {@link writeFile} for why this shape.
1721
+ */
1722
+ private async writeFileInParts(
1723
+ target: string,
1724
+ content: Buffer,
1725
+ budget: number,
1726
+ signal?: AbortSignal,
1727
+ ): Promise<void> {
1728
+ const tempPath = writeFilePartTempPath(target)
1729
+ // Sized against the LARGEST part envelope the sequence will send:
1730
+ // the final one, which carries `renameTo` and the largest offset.
1731
+ // Every earlier part is smaller, so none of them can overrun.
1732
+ const envelopeOverhead = this.writeFileEnvelopeBytes(
1733
+ {
1734
+ path: tempPath,
1735
+ content: '',
1736
+ encoding: 'base64',
1737
+ part: { offset: content.length, final: true, renameTo: target },
1738
+ },
1739
+ 0,
1740
+ )
1741
+ const room = budget - envelopeOverhead - WRITE_FILE_PART_HEADROOM_BYTES
1742
+ const framePartBytes = Math.floor(room / 4) * 3
1743
+ if (framePartBytes <= 0) {
1744
+ throw new AgentWriteFileTooLargeError(
1745
+ `write-file: the request envelope for a part of '${target}' is ${envelopeOverhead} bytes, leaving no room for content inside the ${budget}-byte frame budget. The path is too long for this transport to write in parts.`,
1746
+ )
1747
+ }
1748
+ const partBytes = Math.min(framePartBytes, this.writeFilePartBytes ?? framePartBytes)
1749
+
1750
+ let offset = 0
1751
+ try {
1752
+ for (;;) {
1753
+ signal?.throwIfAborted()
1754
+ const end = Math.min(offset + partBytes, content.length)
1755
+ const final = end >= content.length
1756
+ const res = await this.request<WriteFileResponse>(
1757
+ {
1758
+ op: 'write-file',
1759
+ body: {
1760
+ path: tempPath,
1761
+ content: content.subarray(offset, end).toString('base64'),
1762
+ encoding: 'base64',
1763
+ part: { offset, final, ...(final ? { renameTo: target } : {}) },
1764
+ },
1765
+ },
1766
+ signal,
1767
+ )
1768
+ if (!res.ok) {
1769
+ throw new Error(res.error ?? 'write-file part failed')
1770
+ }
1771
+ offset = end
1772
+ if (!final) continue
1773
+ if (typeof res.sizeBytes === 'number' && res.sizeBytes !== content.length) {
1774
+ throw new Error(
1775
+ `vsock transport: write-file assembled ${res.sizeBytes} bytes for '${target}', expected ${content.length}`,
1776
+ )
1777
+ }
1778
+ return
1779
+ }
1780
+ } catch (error) {
1781
+ await this.discardWriteFileTemp(tempPath)
1782
+ throw error
1783
+ }
1784
+ }
1785
+
1786
+ /**
1787
+ * Remove an abandoned part file. Best effort BY CONTRACT: the reason
1788
+ * the sequence failed is frequently that the guest is unreachable, and
1789
+ * a cleanup that threw would replace the caller's real error — the one
1790
+ * that says why the write failed — with a second one about tidying up.
1791
+ */
1792
+ private async discardWriteFileTemp(tempPath: string): Promise<void> {
1793
+ try {
1794
+ await this.request<WriteFileResponse>(
1795
+ {
1796
+ op: 'write-file',
1797
+ body: { path: tempPath, content: '', encoding: 'base64', part: { discard: true } },
1798
+ },
1799
+ AbortSignal.timeout(WRITE_FILE_DISCARD_TIMEOUT_MS),
1800
+ )
1801
+ } catch {
1802
+ // Deliberately swallowed; see the doc comment.
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Read a file out of the guest.
1808
+ *
1809
+ * Three shapes, decided by what the guest advertises and what the
1810
+ * caller asked for:
1811
+ *
1812
+ * - **No options, guest advertises {@link READ_FILE_STREAM_FEATURE}** —
1813
+ * served by {@link readFileStream} and concatenated here. Neither
1814
+ * side ever holds the base64 form or the JSON envelope whole, so the
1815
+ * ~384 MiB ceiling (V8 refuses a string longer than `0x1fffffe8`
1816
+ * characters, which is what a base64-encoded file of that size
1817
+ * needs) is gone and the guest's peak stops tracking the file's
1818
+ * size. The result is still one `Buffer`, because that is what this
1819
+ * method returns; a caller that must not hold even that iterates
1820
+ * {@link readFileStream} directly.
1821
+ * - **No options, guest does not advertise it** — today's single
1822
+ * whole-file reply, byte for byte, with today's ceiling.
1823
+ * - **`offset` and `length`** — one ranged `read-file`: a single round
1824
+ * trip for a single slice, which is the point of asking for one.
1825
+ * `offset` WITHOUT `length` is an unbounded tail, so it goes through
1826
+ * the stream instead; the guest refuses an uncapped range on
1827
+ * `read-file` for exactly that reason.
1828
+ *
1829
+ * A ranged read against a guest that does not advertise the feature is
1830
+ * REFUSED with {@link AgentReadFileStreamUnsupportedError} rather than
1831
+ * downgraded: such an agent ignores `offset`/`length` and answers with
1832
+ * the whole file, which the caller would read as its slice.
1833
+ */
1834
+ async readFile(path: string, options?: SandboxReadFileOptions): Promise<Buffer> {
1835
+ const signal = options?.signal
1836
+ const { offset, length } = options ?? {}
1837
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0)) {
1838
+ throw new Error('readFile: offset must be a non-negative safe integer')
1839
+ }
1840
+ if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) {
1841
+ throw new Error('readFile: length must be a non-negative safe integer')
1842
+ }
1843
+ const ranged = offset !== undefined || length !== undefined
1844
+ if (!(await this.guestSupportsReadFileStream(signal))) {
1845
+ if (ranged) {
1846
+ throw new AgentReadFileStreamUnsupportedError(
1847
+ `read-file: this guest does not advertise the '${READ_FILE_STREAM_FEATURE}' healthz feature, so it would ignore offset/length and answer with the whole file. Rebuild the guest image from this Namzu release, or read the file whole.`,
1848
+ )
1849
+ }
1850
+ return await this.readFileWhole(path, signal)
1851
+ }
1852
+ if (length !== undefined) {
1853
+ return await this.readFileRange(path, offset ?? 0, length, signal)
1854
+ }
1855
+ // Copied into ONE buffer sized from the guest's `meta` frame rather
1856
+ // than collected and `Buffer.concat`ed: concat needs every chunk to
1857
+ // still exist when the whole is built, so it costs twice the file at
1858
+ // the moment it finishes — 2.3x measured on a 256 MiB read, against
1859
+ // the 2x this method is held to. Here the peak is the file plus one
1860
+ // chunk. The guest's own `end` frame is checked against what arrived
1861
+ // (see {@link readFileFrames}), so a short stream rejects rather than
1862
+ // handing back a buffer padded with whatever was in the allocation.
1863
+ let out: Buffer | undefined
1864
+ let at = 0
1865
+ for await (const chunk of this.readFileFrames(path, options, (meta) => {
1866
+ // The one guest-supplied number this side turns into an
1867
+ // allocation, so it is the one worth bounding. The guest derives
1868
+ // `length` and `sizeBytes` from the same `stat` and never
1869
+ // announces more of a file than the file has, so a `length` past
1870
+ // `sizeBytes` is a guest this host should not be sizing a buffer
1871
+ // from. `allocUnsafe` would refuse the extreme values on its own;
1872
+ // this makes the refusal name what was wrong with the frame.
1873
+ if (
1874
+ !Number.isSafeInteger(meta.sizeBytes) ||
1875
+ meta.sizeBytes < 0 ||
1876
+ !Number.isSafeInteger(meta.length) ||
1877
+ meta.length < 0 ||
1878
+ meta.length > meta.sizeBytes
1879
+ ) {
1880
+ throw new Error(
1881
+ `vsock transport: read-file-stream announced ${meta.length} bytes of a ${meta.sizeBytes}-byte file`,
1882
+ )
1883
+ }
1884
+ out = Buffer.allocUnsafe(meta.length)
1885
+ })) {
1886
+ if (out === undefined) {
1887
+ throw new Error('vsock transport: read-file-stream sent data before its meta frame')
1888
+ }
1889
+ if (at + chunk.byteLength > out.length) {
1890
+ throw new Error(
1891
+ `vsock transport: read-file-stream delivered more than the ${out.length} bytes it announced`,
1892
+ )
1893
+ }
1894
+ chunk.copy(out, at)
1895
+ at += chunk.byteLength
1896
+ }
1897
+ return out === undefined ? Buffer.alloc(0) : out.subarray(0, at)
1898
+ }
1899
+
1900
+ /** Today's single whole-file reply, unchanged — see {@link readFile}. */
1901
+ private async readFileWhole(path: string, signal?: AbortSignal): Promise<Buffer> {
1902
+ const res = await this.request<ReadFileResponse>(
1903
+ { op: 'read-file', body: { path, encoding: 'base64' } },
1904
+ signal,
1905
+ )
1009
1906
  if (!res.ok || typeof res.content !== 'string') {
1010
1907
  throw new Error(res.error ?? 'read-file: no content')
1011
1908
  }
1012
1909
  return Buffer.from(res.content, 'base64')
1013
1910
  }
1014
1911
 
1912
+ /**
1913
+ * One bounded slice, in one round trip.
1914
+ *
1915
+ * The guest's own ceiling on a range (`NAMZU_AGENT_READ_FILE_RANGE_BYTES`,
1916
+ * 1 MiB by default) is not mirrored here and deliberately so: it is the
1917
+ * DEPLOYMENT's number, a host that guessed it would refuse ranges the
1918
+ * guest would have served, and the guest's refusal already names the
1919
+ * variable that raises it.
1920
+ */
1921
+ private async readFileRange(
1922
+ path: string,
1923
+ offset: number,
1924
+ length: number,
1925
+ signal?: AbortSignal,
1926
+ ): Promise<Buffer> {
1927
+ const res = await this.request<ReadFileResponse>(
1928
+ { op: 'read-file', body: { path, encoding: 'base64', offset, length } },
1929
+ signal,
1930
+ )
1931
+ if (!res.ok || typeof res.content !== 'string') {
1932
+ throw new Error(res.error ?? 'read-file: no content')
1933
+ }
1934
+ return Buffer.from(res.content, 'base64')
1935
+ }
1936
+
1937
+ /**
1938
+ * Read a file as an ordered sequence of chunks, so neither side holds
1939
+ * the whole of it.
1940
+ *
1941
+ * The guest sends `meta`, then `data` frames, then `end`, then the
1942
+ * zero-length terminator — the same terminated-stream shape `execute`
1943
+ * uses. Two bounds keep this side's heap flat while the guest's stays
1944
+ * flat on its own: the socket is PAUSED once
1945
+ * {@link READ_FILE_STREAM_HIGH_WATER_BYTES} of decoded chunks are
1946
+ * waiting for a slow consumer, and the guest itself waits for each
1947
+ * `data` frame to drain before it reads the next one.
1948
+ *
1949
+ * Leaving the loop early — `break`, an exception, an aborted
1950
+ * `options.signal` — destroys the socket in the generator's `finally`,
1951
+ * which is what makes the guest close its fd: it sees the connection go
1952
+ * and releases the descriptor rather than leaking one per abandoned
1953
+ * read.
1954
+ *
1955
+ * Refuses a guest that does not advertise
1956
+ * {@link READ_FILE_STREAM_FEATURE} before dialing, with
1957
+ * {@link AgentReadFileStreamUnsupportedError}.
1958
+ */
1959
+ readFileStream(
1960
+ path: string,
1961
+ options?: SandboxReadFileOptions,
1962
+ ): AsyncGenerator<Buffer, void, undefined> {
1963
+ return this.readFileFrames(path, options)
1964
+ }
1965
+
1966
+ /**
1967
+ * The stream itself. Private, and one argument wider than
1968
+ * {@link readFileStream}: `onMeta` fires once, with the guest's `meta`
1969
+ * frame, before the first chunk is yielded, which is how
1970
+ * {@link readFile} sizes its destination buffer without a second round
1971
+ * trip and without a public parameter nobody outside this class should
1972
+ * pass.
1973
+ */
1974
+ private async *readFileFrames(
1975
+ path: string,
1976
+ options?: SandboxReadFileOptions,
1977
+ onMeta?: (meta: { sizeBytes: number; offset: number; length: number }) => void,
1978
+ ): AsyncGenerator<Buffer, void, undefined> {
1979
+ const signal = options?.signal
1980
+ signal?.throwIfAborted()
1981
+ if (!(await this.guestSupportsReadFileStream(signal))) {
1982
+ throw new AgentReadFileStreamUnsupportedError(
1983
+ `read-file-stream: this guest does not advertise the '${READ_FILE_STREAM_FEATURE}' healthz feature, so it has no streamed read at all. Rebuild the guest image from this Namzu release, or use readFile for a file small enough to cross the wire in one frame.`,
1984
+ )
1985
+ }
1986
+ const body: ReadFileStreamRequest = {
1987
+ path,
1988
+ ...(options?.offset !== undefined ? { offset: options.offset } : {}),
1989
+ ...(options?.length !== undefined ? { length: options.length } : {}),
1990
+ }
1991
+ const payload = JSON.stringify(
1992
+ this.withCredential({ op: 'read-file-stream', body } satisfies AgentRequest),
1993
+ )
1994
+ this.assertPreauthBudget(payload)
1995
+ const socket = await this.dial(signal)
1996
+
1997
+ const queue: Buffer[] = []
1998
+ let queuedBytes = 0
1999
+ let paused = false
2000
+ let ended = false
2001
+ let failure: Error | undefined
2002
+ let meta: { sizeBytes: number; offset: number; length: number } | undefined
2003
+ let received = 0
2004
+ let declared: number | undefined
2005
+ let wake: (() => void) | undefined
2006
+ const notify = (): void => {
2007
+ const resume = wake
2008
+ wake = undefined
2009
+ resume?.()
2010
+ }
2011
+ const fail = (error: Error): void => {
2012
+ if (failure || ended) return
2013
+ failure = error
2014
+ notify()
2015
+ }
2016
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () =>
2017
+ fail(
2018
+ new Error(
2019
+ `vsock transport: read-file-stream idle timeout after ${this.readIdleTimeoutMs}ms`,
2020
+ ),
2021
+ ),
2022
+ )
2023
+ const reader = new FrameReader()
2024
+ let terminated = false
2025
+
2026
+ const onAbort = (): void => fail(signalError(signal))
2027
+ socket.on('data', (chunk: Buffer) => {
2028
+ // Once this read has failed — an abort, an idle timeout, a frame
2029
+ // the guest should not have sent — nothing more will be yielded,
2030
+ // so decoding what is still in flight only grows a queue no
2031
+ // consumer will ever pull from.
2032
+ if (failure) return
2033
+ idle.bump()
2034
+ let frames: string[]
2035
+ try {
2036
+ frames = reader.push(chunk)
2037
+ } catch (err) {
2038
+ fail(err instanceof Error ? err : new Error(String(err)))
2039
+ return
2040
+ }
2041
+ for (const frameBody of frames) {
2042
+ if (terminated) {
2043
+ fail(new Error('vsock transport: read-file-stream emitted data after its terminator'))
2044
+ return
2045
+ }
2046
+ if (frameBody.length === 0) {
2047
+ terminated = true
2048
+ continue
2049
+ }
2050
+ let event: ReadFileStreamEvent
2051
+ try {
2052
+ event = JSON.parse(frameBody) as ReadFileStreamEvent
2053
+ } catch (err) {
2054
+ fail(err instanceof Error ? err : new Error(String(err)))
2055
+ return
2056
+ }
2057
+ if (event.type === 'meta') {
2058
+ if (meta !== undefined) {
2059
+ fail(new Error('vsock transport: read-file-stream sent a second meta frame'))
2060
+ return
2061
+ }
2062
+ meta = { sizeBytes: event.sizeBytes, offset: event.offset, length: event.length }
2063
+ declared = event.length
2064
+ try {
2065
+ onMeta?.(meta)
2066
+ } catch (err) {
2067
+ fail(err instanceof Error ? err : new Error(String(err)))
2068
+ return
2069
+ }
2070
+ continue
2071
+ }
2072
+ if (event.type === 'data') {
2073
+ if (meta === undefined) {
2074
+ fail(new Error('vsock transport: read-file-stream sent data before its meta frame'))
2075
+ return
2076
+ }
2077
+ const bytes = Buffer.from(event.data, 'base64')
2078
+ received += bytes.byteLength
2079
+ queue.push(bytes)
2080
+ queuedBytes += bytes.byteLength
2081
+ if (!paused && queuedBytes >= READ_FILE_STREAM_HIGH_WATER_BYTES) {
2082
+ paused = true
2083
+ socket.pause()
2084
+ // The idle timer guards a guest that went silent, and
2085
+ // while WE are the reason it is silent it would be
2086
+ // measuring the consumer instead. A host draining a
2087
+ // gigabyte onto slow storage must not have its stream
2088
+ // torn down for reading carefully.
2089
+ idle.clear()
2090
+ }
2091
+ notify()
2092
+ continue
2093
+ }
2094
+ if (event.type === 'end') {
2095
+ // The guest counts what it sent; this side counts what it
2096
+ // decoded. A mismatch is a lost or duplicated frame, and a
2097
+ // truncated file handed back as a whole one is exactly the
2098
+ // silent corruption a streamed read must not introduce.
2099
+ if (event.bytesSent !== received) {
2100
+ fail(
2101
+ new Error(
2102
+ `vsock transport: read-file-stream declared ${event.bytesSent} bytes and delivered ${received}`,
2103
+ ),
2104
+ )
2105
+ return
2106
+ }
2107
+ if (declared !== undefined && received !== declared) {
2108
+ fail(
2109
+ new Error(
2110
+ `vsock transport: read-file-stream announced ${declared} bytes and delivered ${received}`,
2111
+ ),
2112
+ )
2113
+ return
2114
+ }
2115
+ ended = true
2116
+ notify()
2117
+ continue
2118
+ }
2119
+ fail(new Error(event.error))
2120
+ return
2121
+ }
2122
+ })
2123
+ socket.once('error', (err) => fail(err))
2124
+ socket.once('close', () => {
2125
+ if (ended || failure) {
2126
+ notify()
2127
+ return
2128
+ }
2129
+ fail(new Error('vsock transport: read-file-stream socket closed before its end frame'))
2130
+ })
2131
+ if (signal?.aborted) fail(signalError(signal))
2132
+ else signal?.addEventListener('abort', onAbort, { once: true })
2133
+
2134
+ try {
2135
+ socket.write(frame(payload))
2136
+ idle.bump()
2137
+ for (;;) {
2138
+ // Asked BEFORE the queue, not after it: an aborted read that
2139
+ // goes on handing its consumer up to a high-water mark of
2140
+ // already-decoded bytes before surfacing the rejection is not
2141
+ // the prompt refusal `signal` promises. `ended` is the other
2142
+ // way round — a finished stream owes the consumer every byte
2143
+ // that arrived, so the queue drains first.
2144
+ if (failure) throw failure
2145
+ const next = queue.shift()
2146
+ if (next !== undefined) {
2147
+ queuedBytes -= next.byteLength
2148
+ if (paused && queuedBytes < READ_FILE_STREAM_HIGH_WATER_BYTES) {
2149
+ paused = false
2150
+ socket.resume()
2151
+ // Asking for bytes again restarts the clock that
2152
+ // measures whether they come.
2153
+ idle.bump()
2154
+ }
2155
+ yield next
2156
+ continue
2157
+ }
2158
+ if (ended) return
2159
+ await new Promise<void>((resolve) => {
2160
+ wake = resolve
2161
+ })
2162
+ }
2163
+ } finally {
2164
+ idle.clear()
2165
+ signal?.removeEventListener('abort', onAbort)
2166
+ // Destroyed, never `end()`ed: the guest releases the file
2167
+ // descriptor when the connection goes, and a half-close would
2168
+ // leave it holding one for a read nobody is listening to.
2169
+ socket.destroy()
2170
+ }
2171
+ }
2172
+
2173
+ /**
2174
+ * Ask the guest whether it implements ranged and streamed reads.
2175
+ * Cached for the transport's lifetime, exactly as
2176
+ * {@link guestSupportsWriteFileParts} is, and for the same reason: a
2177
+ * pod does not swap its agent binary while it is running.
2178
+ */
2179
+ private async guestSupportsReadFileStream(signal?: AbortSignal): Promise<boolean> {
2180
+ return (await this.guestFeatures(signal)).includes(READ_FILE_STREAM_FEATURE)
2181
+ }
2182
+
1015
2183
  /**
1016
2184
  * Open a real PTY owned by the in-VM agent.
1017
2185
  *
@@ -1021,7 +2189,22 @@ export class VsockAgentTransport {
1021
2189
  * browser never reaches this transport directly; the runtime gateway owns
1022
2190
  * the session and its authenticated WebSocket attachment.
1023
2191
  */
1024
- async openTerminal(options: OpenTerminalOptions): Promise<TerminalSession> {
2192
+ async openTerminal(options: OpenTerminalOptions & SessionTerminalOpen): Promise<TerminalSession> {
2193
+ return (await this.openSessionTerminal(options)).session
2194
+ }
2195
+
2196
+ /**
2197
+ * The same open, handing back the session's own handles as well as the
2198
+ * `TerminalSession` — the offset to come back at, and the detach that
2199
+ * ends the attachment without signalling the program.
2200
+ *
2201
+ * Exactly one code path serves both: a persistent terminal is not a
2202
+ * second kind of terminal, it is the same stream with a different answer
2203
+ * to "what does a closed connection mean".
2204
+ */
2205
+ async openSessionTerminal(
2206
+ options: OpenTerminalOptions & SessionTerminalOpen,
2207
+ ): Promise<AgentTerminalStream> {
1025
2208
  const request: TerminalOpenRequest = {
1026
2209
  ...(options.command !== undefined ? { command: options.command } : {}),
1027
2210
  ...(options.args !== undefined ? { args: options.args } : {}),
@@ -1029,14 +2212,57 @@ export class VsockAgentTransport {
1029
2212
  ...(options.env !== undefined ? { env: { ...options.env } } : {}),
1030
2213
  cols: options.size.cols,
1031
2214
  rows: options.size.rows,
2215
+ // Additive and optional: an agent that predates it ignores the
2216
+ // field and echoes nothing back, and nothing below arms.
2217
+ ...(this.heartbeatMs !== undefined ? { heartbeatMs: this.heartbeatMs } : {}),
2218
+ // Equally additive, and only ever sent by a caller that checked
2219
+ // the guest advertises `sessions` — see `protocol.ts`.
2220
+ ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
2221
+ ...(options.persistent !== undefined ? { persistent: options.persistent } : {}),
1032
2222
  }
1033
- const openPayload = JSON.stringify(
1034
- this.withCredential({ op: 'terminal', body: request } satisfies AgentRequest),
2223
+ return await this.openTerminalStream(
2224
+ { op: 'terminal', body: request },
2225
+ { detachable: options.persistent === true },
1035
2226
  )
2227
+ }
2228
+
2229
+ /**
2230
+ * The framed, bidirectional stream behind every terminal this transport
2231
+ * opens — the one the `terminal` op starts, and the one `attach-session`
2232
+ * joins to a terminal that is already running.
2233
+ *
2234
+ * Parameterised rather than copied, because the two differ in exactly two
2235
+ * places and everything else — the dial, the framing, the ready
2236
+ * handshake, the read-idle timer that is cleared once a shell may
2237
+ * legitimately go quiet, the heartbeat, the output buffering before the
2238
+ * first listener, the kill grace — has to behave identically or a
2239
+ * reattached terminal is a second terminal implementation with its own
2240
+ * bugs. The two differences:
2241
+ *
2242
+ * - **`detachable`.** For a connection-bound terminal a lost stream IS
2243
+ * the end of the program, and `exited` resolves with `exitCode: -1`
2244
+ * exactly as it always has. For a session attachment it is not: the
2245
+ * program is still running in the pod, so `exited` REJECTS with
2246
+ * {@link AgentSessionDetachedError} rather than reporting an exit that
2247
+ * did not happen. The rejection is pre-handled here so a caller that
2248
+ * only reads output cannot take the host process down with an
2249
+ * unhandled rejection.
2250
+ * - **the offsets.** A session stream's frames carry their place in the
2251
+ * guest's retained log, and {@link AgentTerminalStream.nextOffset} is
2252
+ * what a reattach resumes from. It is never computed from the decoded
2253
+ * text: a chunk that ends mid-character decodes wider than the bytes
2254
+ * it replaced.
2255
+ */
2256
+ private async openTerminalStream(
2257
+ req: AgentRequest,
2258
+ init: { readonly detachable: boolean },
2259
+ ): Promise<AgentTerminalStream> {
2260
+ const askedHeartbeatMs = this.heartbeatMs
2261
+ const openPayload = JSON.stringify(this.withCredential(req))
1036
2262
  this.assertPreauthBudget(openPayload)
1037
2263
  const socket = await this.dial()
1038
2264
 
1039
- return await new Promise<TerminalSession>((resolve, reject) => {
2265
+ return await new Promise<AgentTerminalStream>((resolve, reject) => {
1040
2266
  const KILL_GRACE_MS = 5_000
1041
2267
  const reader = new FrameReader()
1042
2268
  const listeners = new Set<(chunk: string) => void>()
@@ -1045,10 +2271,19 @@ export class VsockAgentTransport {
1045
2271
  let ready = false
1046
2272
  let settled = false
1047
2273
  let killTimer: ReturnType<typeof setTimeout> | undefined
2274
+ let readyEvent: TerminalReadyEvent = { type: 'ready' }
2275
+ let nextOffset: number | undefined
2276
+ /** Armed only if the guest echoed the interval — see `protocol.ts`. */
2277
+ let liveness: StreamLiveness | undefined
1048
2278
  let resolveExit!: (event: { exitCode: number; signal?: number }) => void
1049
- const exited = new Promise<{ exitCode: number; signal?: number }>((done) => {
2279
+ let rejectExit!: (error: unknown) => void
2280
+ const exited = new Promise<{ exitCode: number; signal?: number }>((done, fail) => {
1050
2281
  resolveExit = done
2282
+ rejectExit = fail
1051
2283
  })
2284
+ // See the header: a detachable stream's `exited` can reject, and
2285
+ // the caller may legitimately never look at it.
2286
+ if (init.detachable) void exited.catch(() => undefined)
1052
2287
  const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
1053
2288
  finish(
1054
2289
  new Error(
@@ -1057,17 +2292,26 @@ export class VsockAgentTransport {
1057
2292
  )
1058
2293
  })
1059
2294
 
1060
- const finish = (
1061
- error: Error | null,
1062
- exit: { exitCode: number; signal?: number } = { exitCode: -1 },
1063
- ) => {
2295
+ const finish = (error: Error | null, exit?: { exitCode: number; signal?: number }) => {
1064
2296
  if (settled) return
1065
2297
  settled = true
1066
2298
  idle.clear()
2299
+ liveness?.stop()
1067
2300
  if (killTimer) clearTimeout(killTimer)
1068
2301
  socket.destroy()
1069
2302
  listeners.clear()
1070
- resolveExit(exit)
2303
+ if (exit !== undefined) resolveExit(exit)
2304
+ else if (init.detachable) {
2305
+ rejectExit(
2306
+ new AgentSessionDetachedError(
2307
+ nextOffset,
2308
+ `vsock transport: this attachment ended without the session's program exiting${
2309
+ error ? `: ${error.message}` : ''
2310
+ }. The program is still the guest's to run; attach again to go on reading it.`,
2311
+ { cause: error ?? undefined },
2312
+ ),
2313
+ )
2314
+ } else resolveExit({ exitCode: -1 })
1071
2315
  if (!ready) reject(error ?? new Error('terminal exited before readiness'))
1072
2316
  }
1073
2317
 
@@ -1110,6 +2354,10 @@ export class VsockAgentTransport {
1110
2354
 
1111
2355
  socket.on('data', (chunk: Buffer) => {
1112
2356
  if (!ready) idle.bump()
2357
+ // Bytes are proof of life, not only a heartbeat and not only a
2358
+ // whole frame: one large frame can take longer to arrive than the
2359
+ // window, and the peer was plainly there while it was arriving.
2360
+ liveness?.bump()
1113
2361
  let payloads: string[]
1114
2362
  try {
1115
2363
  payloads = reader.push(chunk)
@@ -1118,6 +2366,10 @@ export class VsockAgentTransport {
1118
2366
  return
1119
2367
  }
1120
2368
  for (const payload of payloads) {
2369
+ // The guest ends a session stream with the same zero-length
2370
+ // terminator every other streamed op uses. Nothing follows it,
2371
+ // and the close below is what settles this stream.
2372
+ if (payload.length === 0) continue
1121
2373
  let event: TerminalOutputEvent
1122
2374
  try {
1123
2375
  event = JSON.parse(payload) as TerminalOutputEvent
@@ -1128,15 +2380,44 @@ export class VsockAgentTransport {
1128
2380
  if (event.type === 'ready') {
1129
2381
  if (!ready) {
1130
2382
  ready = true
2383
+ readyEvent = event
2384
+ this.observeGuestReply(event)
2385
+ if (typeof event.nextOffset === 'number') nextOffset = event.nextOffset
1131
2386
  // Once ready, an interactive shell may legitimately sit silent
1132
2387
  // for hours. Runtime/session TTL owns idle cleanup; a transport
1133
2388
  // read timer would incorrectly kill a healthy quiet terminal.
2389
+ // The heartbeat below is what tells that shell from a peer
2390
+ // that vanished — armed only if the guest echoed an interval.
1134
2391
  idle.clear()
1135
- resolve(session)
2392
+ const beat = negotiatedHeartbeatMs(askedHeartbeatMs, event.heartbeatMs)
2393
+ if (beat !== undefined) {
2394
+ liveness = new StreamLiveness(
2395
+ beat,
2396
+ () => send({ type: 'heartbeat' }),
2397
+ () =>
2398
+ finish(
2399
+ new Error(
2400
+ `vsock transport: terminal peer sent nothing for ${
2401
+ beat * STREAM_HEARTBEAT_MISS_LIMIT
2402
+ }ms and is treated as gone`,
2403
+ ),
2404
+ ),
2405
+ )
2406
+ }
2407
+ resolve({
2408
+ session,
2409
+ get ready() {
2410
+ return readyEvent
2411
+ },
2412
+ nextOffset: () => nextOffset,
2413
+ detach: () => finish(new Error('vsock transport: attachment released by the host')),
2414
+ })
1136
2415
  }
1137
2416
  continue
1138
2417
  }
2418
+ if (event.type === 'heartbeat') continue
1139
2419
  if (event.type === 'data') {
2420
+ if (typeof event.nextOffset === 'number') nextOffset = event.nextOffset
1140
2421
  if (listeners.size === 0) {
1141
2422
  buffered.push(event.data)
1142
2423
  bufferedBytes += Buffer.byteLength(event.data)
@@ -1149,12 +2430,20 @@ export class VsockAgentTransport {
1149
2430
  continue
1150
2431
  }
1151
2432
  if (event.type === 'exit') {
2433
+ if (typeof event.nextOffset === 'number') nextOffset = event.nextOffset
1152
2434
  finish(null, {
1153
2435
  exitCode: event.exitCode,
1154
2436
  ...(event.signal !== undefined ? { signal: event.signal } : {}),
1155
2437
  })
1156
2438
  return
1157
2439
  }
2440
+ if (event.type === 'detached') {
2441
+ // The program did not exit: another attachment took the
2442
+ // session, or this one stopped draining. Either way this
2443
+ // stream ends and nothing in the guest was signalled.
2444
+ finish(new Error(`the guest ended this attachment (${event.reason})`))
2445
+ return
2446
+ }
1158
2447
  finish(new Error(event.error))
1159
2448
  return
1160
2449
  }
@@ -1168,6 +2457,28 @@ export class VsockAgentTransport {
1168
2457
  })
1169
2458
  }
1170
2459
 
2460
+ /**
2461
+ * Join a terminal session that is already running in the guest, replaying
2462
+ * what it printed from `fromOffset` before following it live.
2463
+ *
2464
+ * The guest allows ONE attachment per session and ends the previous one
2465
+ * by name, so two host processes cannot interleave keystrokes into one
2466
+ * shell. Nothing here signals the program: releasing this stream is a
2467
+ * detach, and ending the session is `kill-session`.
2468
+ */
2469
+ async attachSessionTerminal(request: AttachSessionRequest): Promise<AgentTerminalStream> {
2470
+ return await this.openTerminalStream(
2471
+ {
2472
+ op: 'attach-session',
2473
+ body: {
2474
+ ...request,
2475
+ ...(this.heartbeatMs !== undefined ? { heartbeatMs: this.heartbeatMs } : {}),
2476
+ },
2477
+ },
2478
+ { detachable: true },
2479
+ )
2480
+ }
2481
+
1171
2482
  /** Open one TCP stream to a service listening on guest loopback. */
1172
2483
  async openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection> {
1173
2484
  if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65_535) {
@@ -1177,7 +2488,13 @@ export class VsockAgentTransport {
1177
2488
  if (host !== '127.0.0.1' && host !== '::1') {
1178
2489
  throw new Error('firecracker TCP connections are restricted to guest loopback')
1179
2490
  }
1180
- const request: TcpConnectRequest = { host, port: options.port }
2491
+ const askedHeartbeatMs = this.heartbeatMs
2492
+ const request: TcpConnectRequest = {
2493
+ host,
2494
+ port: options.port,
2495
+ // Additive and optional, exactly as on `terminal` — see `openTerminal`.
2496
+ ...(askedHeartbeatMs !== undefined ? { heartbeatMs: askedHeartbeatMs } : {}),
2497
+ }
1181
2498
  const openPayload = JSON.stringify(
1182
2499
  this.withCredential({ op: 'tcp-connect', body: request } satisfies AgentRequest),
1183
2500
  )
@@ -1191,6 +2508,8 @@ export class VsockAgentTransport {
1191
2508
  let bufferedBytes = 0
1192
2509
  let ready = false
1193
2510
  let settled = false
2511
+ /** Armed only if the guest echoed the interval — see `protocol.ts`. */
2512
+ let liveness: StreamLiveness | undefined
1194
2513
  let resolveClosed!: () => void
1195
2514
  const closed = new Promise<void>((done) => {
1196
2515
  resolveClosed = done
@@ -1205,6 +2524,7 @@ export class VsockAgentTransport {
1205
2524
  if (settled) return
1206
2525
  settled = true
1207
2526
  idle.clear()
2527
+ liveness?.stop()
1208
2528
  socket.destroy()
1209
2529
  listeners.clear()
1210
2530
  resolveClosed()
@@ -1229,9 +2549,13 @@ export class VsockAgentTransport {
1229
2549
  },
1230
2550
  pause() {
1231
2551
  socket.pause()
2552
+ // Paused by this caller, so nothing arriving is this caller's
2553
+ // doing and not the peer's — see `StreamLiveness.suspend`.
2554
+ liveness?.suspend()
1232
2555
  },
1233
2556
  resume() {
1234
2557
  if (!settled) socket.resume()
2558
+ liveness?.resume()
1235
2559
  },
1236
2560
  onData(listener) {
1237
2561
  listeners.add(listener)
@@ -1254,6 +2578,8 @@ export class VsockAgentTransport {
1254
2578
 
1255
2579
  socket.on('data', (chunk: Buffer) => {
1256
2580
  if (!ready) idle.bump()
2581
+ // Bytes, not frames — see the reader in `openTerminal` above.
2582
+ liveness?.bump()
1257
2583
  let payloads: string[]
1258
2584
  try {
1259
2585
  payloads = reader.push(chunk)
@@ -1272,11 +2598,23 @@ export class VsockAgentTransport {
1272
2598
  if (event.type === 'ready') {
1273
2599
  if (!ready) {
1274
2600
  ready = true
2601
+ this.observeGuestReply(event)
1275
2602
  idle.clear()
2603
+ const beat = negotiatedHeartbeatMs(askedHeartbeatMs, event.heartbeatMs)
2604
+ if (beat !== undefined) {
2605
+ liveness = new StreamLiveness(
2606
+ beat,
2607
+ () => {
2608
+ send({ type: 'heartbeat' })
2609
+ },
2610
+ () => finish(null),
2611
+ )
2612
+ }
1276
2613
  resolve(connection)
1277
2614
  }
1278
2615
  continue
1279
2616
  }
2617
+ if (event.type === 'heartbeat') continue
1280
2618
  if (event.type === 'data') {
1281
2619
  const bytes = Buffer.from(event.data, 'base64')
1282
2620
  if (listeners.size === 0) {
@@ -1332,6 +2670,95 @@ class LineReader {
1332
2670
  }
1333
2671
  }
1334
2672
 
2673
+ /**
2674
+ * The host half of the negotiated stream heartbeat (`protocol.ts`'s
2675
+ * `StreamHeartbeat`): send one every interval, and give up on a peer that
2676
+ * has sent nothing for {@link STREAM_HEARTBEAT_MISS_LIMIT} of them.
2677
+ *
2678
+ * Constructed only once the guest ECHOED an interval, so a transport that
2679
+ * asked for no heartbeat, or one talking to an agent that predates the
2680
+ * field, never builds one and writes no frame an older peer could not read.
2681
+ *
2682
+ * The watchdog polls at a quarter of the interval rather than at the
2683
+ * interval, so a dead stream is noticed within the three intervals plus at
2684
+ * most one poll tick rather than within four. Both timers are unref'd: a
2685
+ * host process with nothing else to do should exit, not be held open by a
2686
+ * terminal it forgot about.
2687
+ */
2688
+ class StreamLiveness {
2689
+ private sendTimer: ReturnType<typeof setInterval> | undefined
2690
+ private watchTimer: ReturnType<typeof setInterval> | undefined
2691
+ private lastSeen = Date.now()
2692
+ private watching = true
2693
+ private stopped = false
2694
+
2695
+ constructor(
2696
+ private readonly intervalMs: number,
2697
+ private readonly send: () => void,
2698
+ private readonly onDead: () => void,
2699
+ ) {
2700
+ this.sendTimer = setInterval(() => {
2701
+ if (!this.stopped) this.send()
2702
+ }, intervalMs)
2703
+ this.sendTimer.unref?.()
2704
+ this.watchTimer = setInterval(() => this.check(), Math.max(10, Math.floor(intervalMs / 4)))
2705
+ this.watchTimer.unref?.()
2706
+ }
2707
+
2708
+ /** Any BYTES from the peer count, not just a heartbeat and not a whole frame. */
2709
+ bump(): void {
2710
+ this.lastSeen = Date.now()
2711
+ }
2712
+
2713
+ /**
2714
+ * This side has paused reading for backpressure, so silence is its own
2715
+ * doing and the peer's frames are waiting in the kernel. Not counted.
2716
+ */
2717
+ suspend(): void {
2718
+ this.watching = false
2719
+ }
2720
+
2721
+ resume(): void {
2722
+ if (this.stopped) return
2723
+ this.watching = true
2724
+ this.lastSeen = Date.now()
2725
+ }
2726
+
2727
+ stop(): void {
2728
+ this.stopped = true
2729
+ if (this.sendTimer) clearInterval(this.sendTimer)
2730
+ if (this.watchTimer) clearInterval(this.watchTimer)
2731
+ this.sendTimer = undefined
2732
+ this.watchTimer = undefined
2733
+ }
2734
+
2735
+ private check(): void {
2736
+ if (this.stopped || !this.watching) return
2737
+ if (Date.now() - this.lastSeen < this.intervalMs * STREAM_HEARTBEAT_MISS_LIMIT) return
2738
+ this.stop()
2739
+ this.onDead()
2740
+ }
2741
+ }
2742
+
2743
+ /**
2744
+ * The interval the guest echoed back, or undefined when this side asked for
2745
+ * no heartbeat or the guest did not answer with one. An agent that predates
2746
+ * the field echoes nothing, which is exactly what keeps an older image's
2747
+ * streams behaving as they always did.
2748
+ *
2749
+ * The echo is clamped into `[MIN_STREAM_HEARTBEAT_MS, asked x
2750
+ * STREAM_HEARTBEAT_MAX_ECHO_FACTOR]`, because it is a number from the pod and
2751
+ * this side times its own watchdog with it. A guest that clamps to the same
2752
+ * floor — which is what this repository's agent does — always echoes a value
2753
+ * already inside the band, so nothing about the honest case changes.
2754
+ */
2755
+ function negotiatedHeartbeatMs(asked: number | undefined, echoed: unknown): number | undefined {
2756
+ if (asked === undefined) return undefined
2757
+ if (typeof echoed !== 'number' || !Number.isFinite(echoed) || echoed <= 0) return undefined
2758
+ const ceiling = Math.max(asked, MIN_STREAM_HEARTBEAT_MS) * STREAM_HEARTBEAT_MAX_ECHO_FACTOR
2759
+ return Math.min(ceiling, Math.max(MIN_STREAM_HEARTBEAT_MS, Math.floor(echoed)))
2760
+ }
2761
+
1335
2762
  /** Resets a timer on every byte; fires `onIdle` after `ms` of silence. */
1336
2763
  class IdleTimer {
1337
2764
  private timer: NodeJS.Timeout | undefined
@@ -1369,6 +2796,31 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
1369
2796
  })
1370
2797
  }
1371
2798
 
2799
+ /** Length of `n` raw bytes base64-encoded, padding included. Exact. */
2800
+ function base64Length(n: number): number {
2801
+ return 4 * Math.ceil(n / 3)
2802
+ }
2803
+
2804
+ /**
2805
+ * The temp file a part sequence for `target` writes into: a SIBLING of the
2806
+ * target, so the finishing `rename` is a within-directory rename on one
2807
+ * filesystem (atomic) rather than a cross-device copy, and so the path
2808
+ * passes the guest's workspace jail exactly as the target does.
2809
+ *
2810
+ * Split on `/` rather than through `node:path` because the path is the
2811
+ * GUEST's, which is always POSIX — a host running the orchestrator on
2812
+ * Windows must not rewrite it with backslashes. The target's own basename
2813
+ * rides along, truncated, so an operator who finds one of these knows what
2814
+ * it was becoming; the uuid is what makes two concurrent writers to the
2815
+ * same target use two different temp files.
2816
+ */
2817
+ function writeFilePartTempPath(target: string): string {
2818
+ const slash = target.lastIndexOf('/')
2819
+ const dir = slash < 0 ? '' : target.slice(0, slash + 1)
2820
+ const base = (slash < 0 ? target : target.slice(slash + 1)).slice(0, 96)
2821
+ return `${dir}.namzu-write-${randomUUID()}-${base}.part`
2822
+ }
2823
+
1372
2824
  function signalError(signal: AbortSignal | undefined): Error {
1373
2825
  if (signal?.reason instanceof Error) return signal.reason
1374
2826
  return new Error(signal?.reason === undefined ? 'operation aborted' : String(signal.reason))