@namzu/sandbox 13.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 (104) hide show
  1. package/CHANGELOG.md +1147 -0
  2. package/README.md +447 -0
  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 +481 -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 +642 -14
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1307 -34
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1296 -0
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  22. package/dist/backends/kubernetes/egress-policy.js +2458 -0
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  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 +1019 -0
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  30. package/dist/backends/kubernetes/index.js +1756 -0
  31. package/dist/backends/kubernetes/index.js.map +1 -0
  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 +334 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  38. package/dist/backends/kubernetes/k8s-client.js +553 -0
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  40. package/dist/backends/kubernetes/lease.d.ts +145 -0
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  42. package/dist/backends/kubernetes/lease.js +201 -0
  43. package/dist/backends/kubernetes/lease.js.map +1 -0
  44. package/dist/backends/kubernetes/objects.d.ts +702 -0
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  46. package/dist/backends/kubernetes/objects.js +518 -0
  47. package/dist/backends/kubernetes/objects.js.map +1 -0
  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/privilege-probe.d.ts +136 -0
  53. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  55. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  56. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  57. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  58. package/dist/backends/kubernetes/rbac.js +177 -0
  59. package/dist/backends/kubernetes/rbac.js.map +1 -0
  60. package/dist/backends/kubernetes/sandbox.d.ts +190 -0
  61. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  62. package/dist/backends/kubernetes/sandbox.js +433 -0
  63. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  64. package/dist/backends/kubernetes/transport.d.ts +1048 -0
  65. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  66. package/dist/backends/kubernetes/transport.js +2093 -0
  67. package/dist/backends/kubernetes/transport.js.map +1 -0
  68. package/dist/backends/kubernetes/workspace.d.ts +1512 -0
  69. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  70. package/dist/backends/kubernetes/workspace.js +3703 -0
  71. package/dist/backends/kubernetes/workspace.js.map +1 -0
  72. package/dist/backends/remote-execution-controller.d.ts +14 -0
  73. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  74. package/dist/backends/remote-execution-controller.js.map +1 -1
  75. package/dist/index.d.ts +350 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +344 -34
  78. package/dist/index.js.map +1 -1
  79. package/dist/testing/sandbox-conformance.d.ts +227 -0
  80. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  81. package/dist/testing/sandbox-conformance.js +896 -0
  82. package/dist/testing/sandbox-conformance.js.map +1 -0
  83. package/package.json +5 -4
  84. package/src/backends/aci-standby-pool/index.ts +16 -1
  85. package/src/backends/docker/index.ts +22 -1
  86. package/src/backends/firecracker/index.ts +14 -2
  87. package/src/backends/firecracker/protocol.ts +541 -6
  88. package/src/backends/firecracker/transport.ts +1687 -64
  89. package/src/backends/kubernetes/egress-policy.ts +3448 -0
  90. package/src/backends/kubernetes/identity.ts +261 -0
  91. package/src/backends/kubernetes/index.ts +2670 -0
  92. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  93. package/src/backends/kubernetes/k8s-client.ts +742 -0
  94. package/src/backends/kubernetes/lease.ts +254 -0
  95. package/src/backends/kubernetes/objects.ts +983 -0
  96. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  97. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  98. package/src/backends/kubernetes/rbac.ts +192 -0
  99. package/src/backends/kubernetes/sandbox.ts +593 -0
  100. package/src/backends/kubernetes/transport.ts +2895 -0
  101. package/src/backends/kubernetes/workspace.ts +5640 -0
  102. package/src/backends/remote-execution-controller.ts +14 -0
  103. package/src/index.ts +838 -35
  104. package/src/testing/sandbox-conformance.ts +1202 -0
@@ -52,11 +52,12 @@
52
52
  * socket to be silently severed by a resume), which makes the
53
53
  * transport resume-survivable by construction.
54
54
  */
55
+ import { randomUUID } from 'node:crypto';
55
56
  import net from 'node:net';
56
57
  import tls from 'node:tls';
57
58
  import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js';
58
59
  import { REMOTE_EXECUTION_PROTOCOL_VERSION, RemoteCancellationUnknownError, RemoteExecutionController, RemoteProtocolError, } from '../remote-execution-controller.js';
59
- import { ExecResultAccumulator, parseExecLine, } from './protocol.js';
60
+ import { ExecResultAccumulator, MIN_STREAM_HEARTBEAT_MS, READ_FILE_STREAM_FEATURE, STREAM_HEARTBEAT_MAX_ECHO_FACTOR, STREAM_HEARTBEAT_MISS_LIMIT, WRITE_FILE_PARTS_FEATURE, parseExecLine, } from './protocol.js';
60
61
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
61
62
  const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000;
62
63
  const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100;
@@ -69,6 +70,146 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000;
69
70
  const EXECUTION_TRANSPORT_GRACE_MS = 10_000;
70
71
  const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000;
71
72
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
73
+ /**
74
+ * The guest agent's default pre-auth frame ceiling for a routed (`tcp`)
75
+ * connection — mirrors `agent.cjs`'s `MAX_PREAUTH_FRAME_BYTES`
76
+ * (`NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES`, default 8 MiB). The gate on
77
+ * that side cannot run until a whole frame is parsed (the credential
78
+ * rides inside the envelope), so it bounds what an UNAUTHENTICATED
79
+ * connection's first frame may announce. This transport dials fresh per
80
+ * call — there is no persistent, already-authenticated connection to
81
+ * reuse — so EVERY `tcp` request is that connection's first frame, and
82
+ * this ceiling is therefore the effective per-request budget, not just
83
+ * a one-time cost paid on first use.
84
+ *
85
+ * Checked here, client-side, BEFORE dialing: an oversized request fails
86
+ * fast with a clear error instead of opening a connection the guest is
87
+ * going to refuse anyway. Chunking a `write-file` body across multiple
88
+ * frames would lift this ceiling; it is a documented follow-up, not
89
+ * implemented by this transport.
90
+ */
91
+ export const TCP_PREAUTH_FRAME_LIMIT_BYTES = 8 * 1024 * 1024;
92
+ /**
93
+ * The guest agent's default ceiling on ANY frame, pre-auth or not —
94
+ * mirrors `agent.cjs`'s `MAX_FRAME_BYTES` (`NAMZU_AGENT_MAX_FRAME_BYTES`,
95
+ * default 256 MiB). It is the budget the `unix`/`vsock`/`mtls` arms are
96
+ * bounded by, since none of them runs a credential gate and so none of
97
+ * them ever pays the smaller pre-auth price.
98
+ */
99
+ export const GUEST_FRAME_LIMIT_BYTES = 256 * 1024 * 1024;
100
+ /** Default {@link VsockTransportOptions.maxWriteFileBytes} — 1 GiB. */
101
+ export const DEFAULT_MAX_WRITE_FILE_BYTES = 1024 * 1024 * 1024;
102
+ /**
103
+ * Idle time before the kernel sends its first TCP keepalive probe on a
104
+ * `tcp`-arm connection, host side. 15 s, matching the Kubernetes backend's
105
+ * default heartbeat interval, so the two bounds do not disagree about how
106
+ * long a silent connection is allowed to look healthy.
107
+ */
108
+ export const TCP_KEEPALIVE_INITIAL_DELAY_MS = 15_000;
109
+ /**
110
+ * Slack subtracted from a frame budget when sizing a part, over and above
111
+ * the envelope this transport measures exactly. The guest's own accounting
112
+ * is of the framed payload, and a deployment is free to configure a
113
+ * slightly different ceiling than the default this side assumes; a
114
+ * kilobyte of headroom costs one part in a thousand and removes a whole
115
+ * class of off-by-a-few refusals.
116
+ */
117
+ const WRITE_FILE_PART_HEADROOM_BYTES = 1024;
118
+ /**
119
+ * How long the best-effort removal of an abandoned part file may take.
120
+ * Bounded separately from the connect retry budget because it runs AFTER
121
+ * the caller's write has already failed — often because the peer is gone —
122
+ * and a caller waiting on a rejection should not wait out a retry budget
123
+ * for a cleanup whose failure it is never told about.
124
+ */
125
+ const WRITE_FILE_DISCARD_TIMEOUT_MS = 5_000;
126
+ /**
127
+ * Thrown when a `tcp`-handle request's framed envelope (op + body +
128
+ * token) would exceed {@link TCP_PREAUTH_FRAME_LIMIT_BYTES}. Named so a
129
+ * caller can distinguish "this body needs chunking" from every other
130
+ * transport failure.
131
+ */
132
+ export class AgentPreauthFrameTooLargeError extends Error {
133
+ constructor(message) {
134
+ super(message);
135
+ this.name = 'AgentPreauthFrameTooLargeError';
136
+ }
137
+ }
138
+ /**
139
+ * Thrown when a `writeFile` body exceeds
140
+ * {@link VsockTransportOptions.maxWriteFileBytes}. Distinct from
141
+ * {@link AgentPreauthFrameTooLargeError}: that one says the WIRE cannot
142
+ * carry this in one frame (and, since the part protocol, only ever fires
143
+ * when the guest cannot carry it in several either), this one says the
144
+ * HOST was configured not to send a body this large at all.
145
+ */
146
+ export class AgentWriteFileTooLargeError extends Error {
147
+ constructor(message) {
148
+ super(message);
149
+ this.name = 'AgentWriteFileTooLargeError';
150
+ }
151
+ }
152
+ /**
153
+ * Thrown when a read this transport cannot serve on the old whole-file op
154
+ * is asked of a guest that does not advertise
155
+ * {@link READ_FILE_STREAM_FEATURE} — a ranged `readFile`, or any
156
+ * `readFileStream`.
157
+ *
158
+ * A refusal rather than a fallback, and that is the whole point of the
159
+ * class: an agent that predates the feature IGNORES `offset`/`length` and
160
+ * answers with the WHOLE file, so silently taking the old path would hand
161
+ * a caller the entire file where it asked for a slice — a wrong answer
162
+ * dressed as a degraded one. Named so a host can tell "rebuild the guest
163
+ * image" apart from "that file is not there".
164
+ */
165
+ export class AgentReadFileStreamUnsupportedError extends Error {
166
+ constructor(message) {
167
+ super(message);
168
+ this.name = 'AgentReadFileStreamUnsupportedError';
169
+ }
170
+ }
171
+ /**
172
+ * How many bytes of a `read-file-stream` may sit decoded on this side
173
+ * while the consumer is slow, before the socket is paused.
174
+ *
175
+ * The bound exists because an `AsyncIterable` consumer pulls: without it a
176
+ * fast guest would fill the host's heap with exactly the whole file this
177
+ * op exists to avoid materialising. One chunk is the guest's own
178
+ * `NAMZU_AGENT_READ_FILE_STREAM_CHUNK_BYTES` (256 KiB by default), so this
179
+ * is a few chunks in flight and nothing like a file. A deployment that
180
+ * raises that variable raises the number of BYTES held here, not the
181
+ * number of chunks: this is a byte bound, so it keeps holding.
182
+ *
183
+ * Deliberately not derived from the guest's value. The guest is a
184
+ * different process on a different release train, this side has to bound
185
+ * its own heap before it has asked the guest anything, and a host that
186
+ * trusted a guest-supplied chunk size for its own bound would have no
187
+ * bound at all.
188
+ */
189
+ const READ_FILE_STREAM_HIGH_WATER_BYTES = 4 * 1024 * 1024;
190
+ /**
191
+ * Thrown — as the rejection of a session terminal's `exited` — when the
192
+ * attachment ended and the program did not.
193
+ *
194
+ * `exited` may not RESOLVE here: a resolved `exited` says the program is
195
+ * over, and reporting `exitCode: -1` for a shell that is still running in
196
+ * the pod is exactly the confusion this whole feature exists to remove. The
197
+ * offset is carried because it is what the next attach resumes from.
198
+ */
199
+ export class AgentSessionDetachedError extends Error {
200
+ nextOffset;
201
+ name = 'AgentSessionDetachedError';
202
+ constructor(nextOffset, message, options) {
203
+ super(message, options);
204
+ this.nextOffset = nextOffset;
205
+ }
206
+ }
207
+ export class AgentDialFailedError extends Error {
208
+ constructor(message, options) {
209
+ super(message, options);
210
+ this.name = 'AgentDialFailedError';
211
+ }
212
+ }
72
213
  /** Exact guest wire version accepted by this Firecracker transport. */
73
214
  export const FIRECRACKER_AGENT_PROTOCOL_VERSION = REMOTE_EXECUTION_PROTOCOL_VERSION;
74
215
  /** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
@@ -78,18 +219,38 @@ function frame(payload) {
78
219
  const header = Buffer.from(`${body.length.toString(16).padStart(LENGTH_PREFIX_HEX, '0')}\n`, 'ascii');
79
220
  return Buffer.concat([header, body]);
80
221
  }
222
+ /**
223
+ * A growable accumulator that frees the buffer it grew past a threshold.
224
+ * Sized once so both numbers are stated where they are read.
225
+ */
226
+ const FRAME_BUFFER_INITIAL_BYTES = 64 * 1024;
227
+ const FRAME_BUFFER_RETAIN_BYTES = 1024 * 1024;
81
228
  /**
82
229
  * Incremental frame reader. Feed it socket chunks; it yields complete
83
230
  * payloads. A zero-length frame is the exec stream terminator and is
84
231
  * surfaced as an empty string so the caller can stop.
232
+ *
233
+ * It accumulates into ONE buffer it grows geometrically, with a read
234
+ * cursor, rather than re-`concat`ing every arriving chunk onto a fresh
235
+ * allocation. The distinction only matters for a large frame, where it is
236
+ * the difference between linear and quadratic: a `read-file` reply for a
237
+ * 64 MiB file arrives as ~1400 socket chunks, and copying everything
238
+ * received so far onto each one of them spent half a minute of memcpy on
239
+ * a reply the socket delivered in under a second. Identical framing,
240
+ * identical errors, identical `bufferedBytes` — only the copying changes.
85
241
  */
86
242
  class FrameReader {
87
243
  buf = Buffer.alloc(0);
244
+ /** First byte not yet handed out as part of a frame. */
245
+ start = 0;
246
+ /** One past the last byte received. */
247
+ end = 0;
88
248
  push(chunk) {
89
- this.buf = this.buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buf, chunk]);
249
+ this.append(chunk);
90
250
  const out = [];
91
251
  for (;;) {
92
- const nl = this.buf.indexOf(0x0a); // '\n'
252
+ const view = this.buf.subarray(this.start, this.end);
253
+ const nl = view.indexOf(0x0a); // '\n'
93
254
  if (nl < 0 || nl < LENGTH_PREFIX_HEX) {
94
255
  // Need at least the hex header + newline.
95
256
  if (nl >= 0 && nl < LENGTH_PREFIX_HEX) {
@@ -97,7 +258,7 @@ class FrameReader {
97
258
  }
98
259
  break;
99
260
  }
100
- const header = this.buf.subarray(0, nl).toString('ascii');
261
+ const header = view.subarray(0, nl).toString('ascii');
101
262
  if (!/^[0-9a-fA-F]{8}$/.test(header)) {
102
263
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`);
103
264
  }
@@ -106,16 +267,53 @@ class FrameReader {
106
267
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`);
107
268
  }
108
269
  const start = nl + 1;
109
- if (this.buf.length < start + len)
270
+ if (view.length < start + len)
110
271
  break; // incomplete payload
111
- const payload = this.buf.subarray(start, start + len).toString('utf8');
112
- this.buf = this.buf.subarray(start + len);
113
- out.push(payload);
272
+ out.push(view.subarray(start, start + len).toString('utf8'));
273
+ this.consume(start + len);
114
274
  }
115
275
  return out;
116
276
  }
117
277
  get bufferedBytes() {
118
- return this.buf.length;
278
+ return this.end - this.start;
279
+ }
280
+ /** Copy `chunk` in, growing (and first compacting) only when needed. */
281
+ append(chunk) {
282
+ if (chunk.length === 0)
283
+ return;
284
+ if (this.buf.length - this.end < chunk.length) {
285
+ const needed = this.bufferedBytes + chunk.length;
286
+ if (this.buf.length >= needed) {
287
+ // Compacting the unread bytes to the front is enough.
288
+ this.buf.copy(this.buf, 0, this.start, this.end);
289
+ }
290
+ else {
291
+ let capacity = this.buf.length > 0 ? this.buf.length : FRAME_BUFFER_INITIAL_BYTES;
292
+ // Geometric, so the total copying across a whole reply stays
293
+ // proportional to its length rather than to its length squared.
294
+ while (capacity < needed)
295
+ capacity *= 2;
296
+ const grown = Buffer.allocUnsafe(capacity);
297
+ this.buf.copy(grown, 0, this.start, this.end);
298
+ this.buf = grown;
299
+ }
300
+ this.end = this.bufferedBytes;
301
+ this.start = 0;
302
+ }
303
+ chunk.copy(this.buf, this.end);
304
+ this.end += chunk.length;
305
+ }
306
+ /** Mark `bytes` from the read cursor as consumed. */
307
+ consume(bytes) {
308
+ this.start += bytes;
309
+ if (this.start !== this.end)
310
+ return;
311
+ this.start = 0;
312
+ this.end = 0;
313
+ // A reply that grew the buffer to hundreds of megabytes should not
314
+ // keep holding them for the life of a long-lived connection.
315
+ if (this.buf.length > FRAME_BUFFER_RETAIN_BYTES)
316
+ this.buf = Buffer.alloc(0);
119
317
  }
120
318
  }
121
319
  /**
@@ -130,6 +328,22 @@ export class VsockAgentTransport {
130
328
  connectRetryBudgetMs;
131
329
  connectRetryIntervalMs;
132
330
  readIdleTimeoutMs;
331
+ /** Undefined → this transport negotiates no heartbeat at all. */
332
+ heartbeatMs;
333
+ onDial;
334
+ onDialAttempt;
335
+ onGuestReply;
336
+ maxWriteFileBytes;
337
+ writeFilePartBytes;
338
+ /**
339
+ * What the guest advertised in `healthz`, cached for this handle's
340
+ * lifetime. A pod does not swap its agent binary while it is running,
341
+ * so the probe is asked once per transport and only when something
342
+ * actually depends on a capability — an ordinary write, exec, read or
343
+ * terminal pays nothing for it.
344
+ */
345
+ guestFeatureList;
346
+ permanentDialFailure;
133
347
  executionController;
134
348
  constructor(handle, options = {}) {
135
349
  this.handle = handle;
@@ -138,6 +352,17 @@ export class VsockAgentTransport {
138
352
  this.connectRetryIntervalMs =
139
353
  options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS;
140
354
  this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS;
355
+ if (options.heartbeatMs !== undefined && options.heartbeatMs > 0) {
356
+ this.heartbeatMs = Math.floor(options.heartbeatMs);
357
+ }
358
+ this.onDial = options.onDial;
359
+ this.onDialAttempt = options.onDialAttempt;
360
+ this.onGuestReply = options.onGuestReply;
361
+ this.maxWriteFileBytes = options.maxWriteFileBytes ?? DEFAULT_MAX_WRITE_FILE_BYTES;
362
+ if (options.writeFilePartBytes !== undefined) {
363
+ this.writeFilePartBytes = Math.max(1, Math.floor(options.writeFilePartBytes));
364
+ }
365
+ this.permanentDialFailure = options.permanentDialFailure;
141
366
  const adapter = {
142
367
  label: 'framed microVM agent',
143
368
  reserve: async (signal) => await this.reserveExecution(signal),
@@ -161,31 +386,54 @@ export class VsockAgentTransport {
161
386
  * Dial the agent with the resume-survival retry budget. Resolves a
162
387
  * connected, post-handshake socket. Retries connect/handshake
163
388
  * failures (ECONNREFUSED while the agent re-listens after a resume,
164
- * a dropped CONNECT ack) until the budget is exhausted.
389
+ * a dropped CONNECT ack) until the budget is exhausted — or until
390
+ * {@link VsockTransportOptions.permanentDialFailure} says this particular
391
+ * failure is not one waiting will cure.
165
392
  */
166
393
  async dial(signal) {
167
394
  const deadline = Date.now() + this.connectRetryBudgetMs;
395
+ const dialStartedAt = Date.now();
168
396
  let lastErr;
397
+ let permanent = false;
169
398
  for (;;) {
170
399
  signal?.throwIfAborted();
171
400
  try {
172
- return await this.connectOnce(signal);
401
+ // Announced BEFORE the attempt, not after it fails: an attempt
402
+ // aborted mid-connect never reaches the catch below, and that
403
+ // is precisely the case a watcher needs to hear about.
404
+ this.onDialAttempt?.();
405
+ const socket = await this.connectOnce(signal);
406
+ this.onDial?.(Date.now() - dialStartedAt);
407
+ return socket;
173
408
  }
174
409
  catch (err) {
175
410
  if (signal?.aborted)
176
411
  throw signal.reason;
177
412
  lastErr = err;
413
+ if (this.permanentDialFailure?.(err) === true) {
414
+ permanent = true;
415
+ break;
416
+ }
178
417
  if (Date.now() >= deadline)
179
418
  break;
180
419
  await delay(this.connectRetryIntervalMs, signal);
181
420
  }
182
421
  }
183
- throw new Error(`vsock transport: could not connect to agent within ${this.connectRetryBudgetMs}ms (handle=${describeHandle(this.handle)}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, { cause: lastErr });
422
+ throw new AgentDialFailedError(
423
+ // Two wordings, because claiming a 30 s budget was spent on a dial
424
+ // that gave up in 3 ms is a false statement in an error message.
425
+ // Both keep the "could not connect to agent" phrase: that is what
426
+ // callers classifying a dial failure match on.
427
+ permanent
428
+ ? `vsock transport: could not connect to agent, and the failure is not one retrying fixes (handle=${describeHandle(this.handle)}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
429
+ : `vsock transport: could not connect to agent within ${this.connectRetryBudgetMs}ms (handle=${describeHandle(this.handle)}): ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, { cause: lastErr });
184
430
  }
185
431
  connectOnce(signal) {
186
432
  const handle = this.handle;
187
433
  if (handle.kind === 'mtls')
188
434
  return this.connectOnceMtls(handle, signal);
435
+ if (handle.kind === 'tcp')
436
+ return this.connectOnceTcp(handle, signal);
189
437
  return new Promise((resolve, reject) => {
190
438
  const path = handle.kind === 'unix' ? handle.path : handle.udsPath;
191
439
  const socket = net.connect({ path });
@@ -327,12 +575,109 @@ export class VsockAgentTransport {
327
575
  });
328
576
  });
329
577
  }
578
+ /**
579
+ * Dial a `tcp` handle: a plain `net.connect({ host, port })`. NO
580
+ * routing preamble and NO ack — there is no relay to route through
581
+ * and no handshake line the guest expects, so the socket is handed
582
+ * to the framing loop the instant it connects, exactly like the
583
+ * `unix` arm above (whose body this deliberately does not touch).
584
+ */
585
+ connectOnceTcp(handle, signal) {
586
+ return new Promise((resolve, reject) => {
587
+ const socket = net.connect({ host: handle.host, port: handle.port });
588
+ // SO_KEEPALIVE on the ROUTED arm only. It proves only that the
589
+ // peer's kernel answers — the application heartbeat is what proves
590
+ // its event loop does — but it is what gets a half-open connection
591
+ // through a middlebox reported at all, and it costs a probe every
592
+ // {@link TCP_KEEPALIVE_INITIAL_DELAY_MS}. The unix/vsock/mtls arms
593
+ // are deliberately untouched: those are the Firecracker tier's.
594
+ socket.setKeepAlive(true, TCP_KEEPALIVE_INITIAL_DELAY_MS);
595
+ let settled = false;
596
+ const fail = (err) => {
597
+ if (settled)
598
+ return;
599
+ settled = true;
600
+ clearTimeout(timer);
601
+ signal?.removeEventListener('abort', abort);
602
+ socket.destroy();
603
+ reject(err);
604
+ };
605
+ const abort = () => fail(signalError(signal));
606
+ const timer = setTimeout(() => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)), this.connectTimeoutMs);
607
+ timer.unref();
608
+ socket.once('error', fail);
609
+ if (signal?.aborted) {
610
+ abort();
611
+ return;
612
+ }
613
+ signal?.addEventListener('abort', abort, { once: true });
614
+ socket.once('connect', () => {
615
+ if (settled)
616
+ return;
617
+ settled = true;
618
+ clearTimeout(timer);
619
+ signal?.removeEventListener('abort', abort);
620
+ socket.removeListener('error', fail);
621
+ resolve(socket);
622
+ });
623
+ });
624
+ }
625
+ /**
626
+ * Fold the handle's credential into a request envelope. Only the
627
+ * `tcp` arm carries a token — the vsock/unix/mtls control channels
628
+ * are host↔guest only and the agent authenticates nothing there, so
629
+ * a request built for those arms passes through unchanged.
630
+ */
631
+ withCredential(req) {
632
+ if (this.handle.kind === 'tcp' && this.handle.token !== undefined) {
633
+ return { ...req, token: this.handle.token };
634
+ }
635
+ return req;
636
+ }
637
+ /**
638
+ * Refuse an oversized `tcp` envelope BEFORE dialing. See
639
+ * {@link TCP_PREAUTH_FRAME_LIMIT_BYTES}. A no-op for every other
640
+ * handle kind, which the guest never gates on frame size pre-auth.
641
+ */
642
+ assertPreauthBudget(payload) {
643
+ if (this.handle.kind !== 'tcp')
644
+ return;
645
+ const size = Buffer.byteLength(payload, 'utf8');
646
+ if (size <= TCP_PREAUTH_FRAME_LIMIT_BYTES)
647
+ return;
648
+ throw new AgentPreauthFrameTooLargeError(`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.`);
649
+ }
650
+ /**
651
+ * Hand one accepted reply to {@link VsockTransportOptions.onGuestReply},
652
+ * and never let the listener's failure reach the caller.
653
+ *
654
+ * The caller's result is already decided by the time this runs — the
655
+ * reply parsed, the frame accounted for — so a hook that throws must not
656
+ * turn a successful read into a failed one. Swallowing is the only
657
+ * behaviour that keeps an optional observer optional.
658
+ */
659
+ observeGuestReply(reply) {
660
+ const observe = this.onGuestReply;
661
+ if (observe === undefined)
662
+ return;
663
+ if (reply === null || typeof reply !== 'object')
664
+ return;
665
+ try {
666
+ observe(reply);
667
+ }
668
+ catch {
669
+ // See above: an observer cannot fail an operation.
670
+ }
671
+ }
330
672
  /**
331
673
  * Send one framed request and read one framed JSON reply (file-IO +
332
674
  * healthz). Applies the read-idle timeout so a post-resume hung read
333
675
  * is torn down rather than wedging the caller.
334
676
  */
335
677
  async request(req, signal) {
678
+ const envelope = this.withCredential(req);
679
+ const payload = JSON.stringify(envelope);
680
+ this.assertPreauthBudget(payload);
336
681
  const socket = await this.dial(signal);
337
682
  return await new Promise((resolve, reject) => {
338
683
  const reader = new FrameReader();
@@ -377,6 +722,7 @@ export class VsockAgentTransport {
377
722
  if (first !== undefined) {
378
723
  try {
379
724
  response = JSON.parse(first);
725
+ this.observeGuestReply(response);
380
726
  if (reader.bufferedBytes > 0) {
381
727
  finish(new Error('vsock transport: control reply has trailing partial data'));
382
728
  return;
@@ -403,7 +749,7 @@ export class VsockAgentTransport {
403
749
  }
404
750
  signal?.addEventListener('abort', abort, { once: true });
405
751
  idle.bump();
406
- socket.write(frame(JSON.stringify(req)));
752
+ socket.write(frame(payload));
407
753
  });
408
754
  }
409
755
  /**
@@ -412,6 +758,9 @@ export class VsockAgentTransport {
412
758
  * The agent terminates the stream with a zero-length frame.
413
759
  */
414
760
  async executeRaw(body, opts, signal) {
761
+ const envelope = this.withCredential({ op: 'execute', body });
762
+ const payload = JSON.stringify(envelope);
763
+ this.assertPreauthBudget(payload);
415
764
  const socket = await this.dial(signal);
416
765
  const start = Date.now();
417
766
  return await new Promise((resolve, reject) => {
@@ -500,7 +849,7 @@ export class VsockAgentTransport {
500
849
  return;
501
850
  }
502
851
  signal?.addEventListener('abort', abort, { once: true });
503
- socket.write(frame(JSON.stringify({ op: 'execute', body })));
852
+ socket.write(frame(payload));
504
853
  });
505
854
  }
506
855
  /**
@@ -527,6 +876,22 @@ export class VsockAgentTransport {
527
876
  async exec(command, argv, opts) {
528
877
  return await this.executionController.exec(command, argv, opts);
529
878
  }
879
+ /**
880
+ * The raw `/execute` primitive with NO admission/reservation
881
+ * semantics: dial, send one framed request, accumulate the streamed
882
+ * NDJSON reply. Public (unlike the identical-in-spirit
883
+ * {@link reserveExecution}/{@link cancelExecution}, reached through
884
+ * the already-public {@link request}) so a caller running its OWN
885
+ * {@link RemoteExecutionController} — the kubernetes backend's
886
+ * adapter — can reuse this exact dial + framing rather than
887
+ * reimplementing it, while still supplying that controller its own
888
+ * `reserve`/`cancel`/`execute` triple as {@link RemoteExecutionAdapter}
889
+ * requires. Callers that just want a reserve-before-admission `exec`
890
+ * should use {@link execute} or {@link exec} instead.
891
+ */
892
+ async executeStreamed(body, opts, signal) {
893
+ return await this.executeRaw(body, opts, signal);
894
+ }
530
895
  async reserveExecution(signal) {
531
896
  const response = await this.request({ op: 'reserve-execution' }, signal);
532
897
  if (response.ok === false &&
@@ -552,6 +917,22 @@ export class VsockAgentTransport {
552
917
  const actual = res.protocolVersion === undefined ? 'missing' : JSON.stringify(res.protocolVersion);
553
918
  throw new RemoteProtocolError(`Firecracker guest protocol version mismatch: expected ${FIRECRACKER_AGENT_PROTOCOL_VERSION}, received ${actual}. Rebuild the golden image from the same Namzu release.`);
554
919
  }
920
+ // The reply that answers readiness also answers what the guest can
921
+ // do, so the capability caches are filled here rather than by a
922
+ // second probe later. What that saves depends on the tier: the
923
+ // Firecracker backend fences on `waitForReady` after create, so
924
+ // its first `readFile` costs one connection, while the Kubernetes
925
+ // backend fences on the pod's ready condition and never calls
926
+ // this — so its first read of a transport's life pays one extra
927
+ // dial to ask, and every read after it is back to one.
928
+ //
929
+ // AFTER both checks, not before: a reply this method is about to
930
+ // reject is not a reply to believe about anything else, and a
931
+ // guest that is not ready is one whose features are not yet known
932
+ // rather than one that has none.
933
+ this.guestFeatureList = Array.isArray(res.features)
934
+ ? res.features.filter((value) => typeof value === 'string')
935
+ : [];
555
936
  return true;
556
937
  }
557
938
  catch (error) {
@@ -595,25 +976,654 @@ export class VsockAgentTransport {
595
976
  }
596
977
  throw new Error(`vsock transport: agent did not become ready within ${timeoutMs}ms: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
597
978
  }
598
- async writeFile(path, content) {
979
+ /**
980
+ * Write a whole file into the guest workspace.
981
+ *
982
+ * A body that fits one frame goes as it always has: a single
983
+ * `write-file` envelope carrying the base64 content, one round trip,
984
+ * byte-for-byte the request this transport has always sent.
985
+ *
986
+ * A body that does NOT fit is the case this exists for. On the `tcp`
987
+ * arm every request dials a fresh connection, so every request is that
988
+ * connection's first, not-yet-authenticated frame (the credential
989
+ * rides in the envelope) and is bounded by
990
+ * {@link TCP_PREAUTH_FRAME_LIMIT_BYTES} — about 5.9 MiB of file
991
+ * content — on EVERY call, not once. Raising the guest's
992
+ * `NAMZU_AGENT_MAX_PREAUTH_FRAME_BYTES` trades away the pre-auth
993
+ * budget that ceiling exists to bound, so the fix is on this side:
994
+ * split the body into parts that each fit, append them to a temporary
995
+ * SIBLING of the target inside the same workspace jail, and finish
996
+ * with an atomic `rename` onto the target.
997
+ *
998
+ * What that buys, and why the shape is what it is:
999
+ *
1000
+ * - **A reader never sees a half-written file.** The target changes
1001
+ * exactly once, in the final part's `rename`. A sequence that dies
1002
+ * at part 3 of 9 leaves the target exactly as it was — including
1003
+ * not existing.
1004
+ * - **A lost or duplicated part is detected, not written.** Each part
1005
+ * names the offset it starts at and the guest refuses it unless
1006
+ * that equals the temp file's current size.
1007
+ * - **An abandoned sequence cleans up after itself.** An abort or a
1008
+ * transport failure removes the temp file (best effort — a peer
1009
+ * that has gone away cannot be asked to) and rejects.
1010
+ * - **Parts go out sequentially on fresh connections**, which is what
1011
+ * the offset check assumes and what keeps the guest's pre-auth
1012
+ * connection pool holding one of this caller's sockets at a time.
1013
+ *
1014
+ * The guest must ADVERTISE the capability (`${WRITE_FILE_PARTS_FEATURE}`
1015
+ * in its `healthz` reply) before a single part is sent. An agent that
1016
+ * predates the part protocol would read a part's `content` as a whole
1017
+ * file; it never receives one, and an oversized body against such a
1018
+ * guest still fails with the named
1019
+ * {@link AgentPreauthFrameTooLargeError} it always did.
1020
+ *
1021
+ * {@link VsockTransportOptions.maxWriteFileBytes} is checked FIRST, on
1022
+ * every body and before anything about the wire is considered: it is a
1023
+ * bound on what a caller may push into a workspace, not a bound on
1024
+ * multi-part writes, so a host that lowers it below the frame budget
1025
+ * gets the cap it asked for rather than none.
1026
+ */
1027
+ async writeFile(path, content, signal) {
1028
+ if (content.length > this.maxWriteFileBytes) {
1029
+ throw new AgentWriteFileTooLargeError(`write-file: a body of ${content.length} bytes exceeds this transport's maxWriteFileBytes of ${this.maxWriteFileBytes}. Raise VsockTransportOptions.maxWriteFileBytes to admit it.`);
1030
+ }
1031
+ const budget = this.singleFrameBudgetBytes();
1032
+ const wholeEnvelopeBytes = this.writeFileEnvelopeBytes({ path, content: '', encoding: 'base64' }, content.length);
1033
+ // The configured part size, when there is one, also decides when a
1034
+ // body is split at all — so that with NO configuration the split
1035
+ // point is exactly the wire's own ceiling and every body that used
1036
+ // to travel in one frame still does.
1037
+ const splitsAnyway = this.writeFilePartBytes !== undefined && content.length > this.writeFilePartBytes;
1038
+ if (wholeEnvelopeBytes <= budget && !splitsAnyway) {
1039
+ await this.writeFileWhole(path, content, signal);
1040
+ return;
1041
+ }
1042
+ if (!(await this.guestSupportsWriteFileParts(signal))) {
1043
+ throw this.oversizedWriteFileError(content.length, wholeEnvelopeBytes, budget);
1044
+ }
1045
+ await this.writeFileInParts(path, content, budget, signal);
1046
+ }
1047
+ /** Today's single-frame write, unchanged — see {@link writeFile}. */
1048
+ async writeFileWhole(path, content, signal) {
599
1049
  const res = await this.request({
600
1050
  op: 'write-file',
601
1051
  body: { path, content: content.toString('base64'), encoding: 'base64' },
602
- });
1052
+ }, signal);
603
1053
  if (!res.ok) {
604
1054
  throw new Error(res.error ?? 'write-file failed');
605
1055
  }
606
1056
  }
607
- async readFile(path) {
608
- const res = await this.request({
609
- op: 'read-file',
610
- body: { path, encoding: 'base64' },
1057
+ /**
1058
+ * The frame budget one request has on this handle: the pre-auth
1059
+ * ceiling on the credentialed `tcp` arm, the guest's global frame
1060
+ * ceiling on the host-local arms, which authenticate nothing and so
1061
+ * never pay the smaller price.
1062
+ */
1063
+ singleFrameBudgetBytes() {
1064
+ return this.handle.kind === 'tcp' ? TCP_PREAUTH_FRAME_LIMIT_BYTES : GUEST_FRAME_LIMIT_BYTES;
1065
+ }
1066
+ /**
1067
+ * Exact framed size of a `write-file` envelope whose `content` field
1068
+ * holds `contentBytes` raw bytes base64-encoded — WITHOUT encoding
1069
+ * them, so sizing a 1 GiB body costs nothing and never builds a string
1070
+ * longer than V8 permits.
1071
+ *
1072
+ * Exact rather than approximate because the base64 alphabet contains
1073
+ * no character `JSON.stringify` escapes, so the encoded content
1074
+ * contributes precisely its own length to the envelope and the rest of
1075
+ * the envelope (paths, the token, the part fields) is measured as it
1076
+ * will actually be serialized.
1077
+ */
1078
+ writeFileEnvelopeBytes(body, contentBytes) {
1079
+ const envelope = this.withCredential({ op: 'write-file', body });
1080
+ return Buffer.byteLength(JSON.stringify(envelope), 'utf8') + base64Length(contentBytes);
1081
+ }
1082
+ /**
1083
+ * Ask the guest whether it implements the part protocol. Cached for
1084
+ * the transport's lifetime; a transport failure propagates rather than
1085
+ * reading as "not supported", because answering a broken connection
1086
+ * with a too-large error would name the wrong cause.
1087
+ */
1088
+ async guestSupportsWriteFileParts(signal) {
1089
+ return (await this.guestFeatures(signal)).includes(WRITE_FILE_PARTS_FEATURE);
1090
+ }
1091
+ /**
1092
+ * The capability strings this guest advertises in `healthz`, cached for
1093
+ * this transport's lifetime.
1094
+ *
1095
+ * One list, asked once, for every optional op: the write-file part
1096
+ * protocol and the execution-attach ops both read it, and a second
1097
+ * cache would mean a second probe against a guest that answers both
1098
+ * questions in one reply. A transport FAILURE propagates rather than
1099
+ * reading as "not supported", because answering a broken connection
1100
+ * with a capability refusal would name the wrong cause.
1101
+ */
1102
+ async guestFeatures(signal) {
1103
+ if (this.guestFeatureList !== undefined)
1104
+ return this.guestFeatureList;
1105
+ const reply = await this.request({ op: 'healthz' }, signal);
1106
+ const features = Array.isArray(reply.features)
1107
+ ? reply.features.filter((value) => typeof value === 'string')
1108
+ : [];
1109
+ this.guestFeatureList = features;
1110
+ return features;
1111
+ }
1112
+ /**
1113
+ * Send one framed request and read a STREAM of framed JSON events until
1114
+ * the agent's zero-length terminator, handing each event to `onEvent`.
1115
+ *
1116
+ * The generic half of what {@link executeRaw} does, without any of its
1117
+ * opinions about what the events mean: `executeRaw` owns the exec
1118
+ * NDJSON union and the {@link ExecResultAccumulator}, and this owns
1119
+ * dial, framing, the terminator, the observation bound and the
1120
+ * post-terminator close. Additive — nothing already shipped calls it —
1121
+ * so the Firecracker tier's behaviour is untouched and a later streamed
1122
+ * op reuses it rather than writing a fourth copy of this loop.
1123
+ *
1124
+ * There is deliberately NO read-idle timeout. A stream that exists to
1125
+ * follow a long, quiet command must not be torn down for being quiet;
1126
+ * the whole observation is bounded by `observationTimeoutMs` instead,
1127
+ * exactly as an `execute` stream is.
1128
+ */
1129
+ async streamFramedRequest(req, onEvent, options, signal) {
1130
+ const envelope = this.withCredential(req);
1131
+ const payload = JSON.stringify(envelope);
1132
+ this.assertPreauthBudget(payload);
1133
+ const socket = await this.dial(signal);
1134
+ const observationTimeoutMs = Math.min(MAX_TIMER_DELAY_MS, options.observationTimeoutMs);
1135
+ return await new Promise((resolve, reject) => {
1136
+ const reader = new FrameReader();
1137
+ let settled = false;
1138
+ let terminated = false;
1139
+ let closeTimer;
1140
+ const finish = (err) => {
1141
+ if (settled)
1142
+ return;
1143
+ settled = true;
1144
+ clearTimeout(observationTimer);
1145
+ if (closeTimer)
1146
+ clearTimeout(closeTimer);
1147
+ signal?.removeEventListener('abort', abort);
1148
+ socket.destroy();
1149
+ if (err)
1150
+ reject(err);
1151
+ else
1152
+ resolve();
1153
+ };
1154
+ const abort = () => finish(signalError(signal));
1155
+ const observationTimer = setTimeout(() => finish(new Error(`vsock transport: stream observation exceeded ${observationTimeoutMs}ms`)), observationTimeoutMs);
1156
+ observationTimer.unref();
1157
+ socket.on('data', (chunk) => {
1158
+ let frames;
1159
+ try {
1160
+ frames = reader.push(chunk);
1161
+ }
1162
+ catch (err) {
1163
+ finish(err instanceof Error ? err : new Error(String(err)));
1164
+ return;
1165
+ }
1166
+ for (const frameText of frames) {
1167
+ if (terminated) {
1168
+ finish(new Error('vsock transport: stream emitted data after its terminator'));
1169
+ return;
1170
+ }
1171
+ if (frameText.length === 0) {
1172
+ terminated = true;
1173
+ continue;
1174
+ }
1175
+ let event;
1176
+ try {
1177
+ event = JSON.parse(frameText);
1178
+ }
1179
+ catch (err) {
1180
+ finish(err instanceof Error ? err : new Error(String(err)));
1181
+ return;
1182
+ }
1183
+ try {
1184
+ onEvent(event);
1185
+ }
1186
+ catch (err) {
1187
+ finish(err instanceof Error ? err : new Error(String(err)));
1188
+ return;
1189
+ }
1190
+ }
1191
+ if (terminated) {
1192
+ if (reader.bufferedBytes > 0) {
1193
+ finish(new Error('vsock transport: stream has trailing partial data'));
1194
+ return;
1195
+ }
1196
+ closeTimer = setTimeout(() => finish(new Error('vsock transport: stream peer did not close after terminator')), POST_RESPONSE_CLOSE_TIMEOUT_MS);
1197
+ closeTimer.unref();
1198
+ }
1199
+ });
1200
+ socket.once('error', (err) => finish(err));
1201
+ socket.once('close', () => {
1202
+ if (terminated)
1203
+ finish(null);
1204
+ else
1205
+ finish(new Error('vsock transport: socket closed before stream terminator'));
1206
+ });
1207
+ if (signal?.aborted) {
1208
+ abort();
1209
+ return;
1210
+ }
1211
+ signal?.addEventListener('abort', abort, { once: true });
1212
+ socket.write(frame(payload));
611
1213
  });
1214
+ }
1215
+ /** The refusal for a body no frame can carry and no guest can take in parts. */
1216
+ oversizedWriteFileError(contentBytes, envelopeBytes, budget) {
1217
+ 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.`;
1218
+ if (this.handle.kind === 'tcp') {
1219
+ return new AgentPreauthFrameTooLargeError(`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.`);
1220
+ }
1221
+ return new Error(`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}`);
1222
+ }
1223
+ /**
1224
+ * Write `content` to `target` as a sequence of parts. See
1225
+ * {@link writeFile} for why this shape.
1226
+ */
1227
+ async writeFileInParts(target, content, budget, signal) {
1228
+ const tempPath = writeFilePartTempPath(target);
1229
+ // Sized against the LARGEST part envelope the sequence will send:
1230
+ // the final one, which carries `renameTo` and the largest offset.
1231
+ // Every earlier part is smaller, so none of them can overrun.
1232
+ const envelopeOverhead = this.writeFileEnvelopeBytes({
1233
+ path: tempPath,
1234
+ content: '',
1235
+ encoding: 'base64',
1236
+ part: { offset: content.length, final: true, renameTo: target },
1237
+ }, 0);
1238
+ const room = budget - envelopeOverhead - WRITE_FILE_PART_HEADROOM_BYTES;
1239
+ const framePartBytes = Math.floor(room / 4) * 3;
1240
+ if (framePartBytes <= 0) {
1241
+ throw new AgentWriteFileTooLargeError(`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.`);
1242
+ }
1243
+ const partBytes = Math.min(framePartBytes, this.writeFilePartBytes ?? framePartBytes);
1244
+ let offset = 0;
1245
+ try {
1246
+ for (;;) {
1247
+ signal?.throwIfAborted();
1248
+ const end = Math.min(offset + partBytes, content.length);
1249
+ const final = end >= content.length;
1250
+ const res = await this.request({
1251
+ op: 'write-file',
1252
+ body: {
1253
+ path: tempPath,
1254
+ content: content.subarray(offset, end).toString('base64'),
1255
+ encoding: 'base64',
1256
+ part: { offset, final, ...(final ? { renameTo: target } : {}) },
1257
+ },
1258
+ }, signal);
1259
+ if (!res.ok) {
1260
+ throw new Error(res.error ?? 'write-file part failed');
1261
+ }
1262
+ offset = end;
1263
+ if (!final)
1264
+ continue;
1265
+ if (typeof res.sizeBytes === 'number' && res.sizeBytes !== content.length) {
1266
+ throw new Error(`vsock transport: write-file assembled ${res.sizeBytes} bytes for '${target}', expected ${content.length}`);
1267
+ }
1268
+ return;
1269
+ }
1270
+ }
1271
+ catch (error) {
1272
+ await this.discardWriteFileTemp(tempPath);
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Remove an abandoned part file. Best effort BY CONTRACT: the reason
1278
+ * the sequence failed is frequently that the guest is unreachable, and
1279
+ * a cleanup that threw would replace the caller's real error — the one
1280
+ * that says why the write failed — with a second one about tidying up.
1281
+ */
1282
+ async discardWriteFileTemp(tempPath) {
1283
+ try {
1284
+ await this.request({
1285
+ op: 'write-file',
1286
+ body: { path: tempPath, content: '', encoding: 'base64', part: { discard: true } },
1287
+ }, AbortSignal.timeout(WRITE_FILE_DISCARD_TIMEOUT_MS));
1288
+ }
1289
+ catch {
1290
+ // Deliberately swallowed; see the doc comment.
1291
+ }
1292
+ }
1293
+ /**
1294
+ * Read a file out of the guest.
1295
+ *
1296
+ * Three shapes, decided by what the guest advertises and what the
1297
+ * caller asked for:
1298
+ *
1299
+ * - **No options, guest advertises {@link READ_FILE_STREAM_FEATURE}** —
1300
+ * served by {@link readFileStream} and concatenated here. Neither
1301
+ * side ever holds the base64 form or the JSON envelope whole, so the
1302
+ * ~384 MiB ceiling (V8 refuses a string longer than `0x1fffffe8`
1303
+ * characters, which is what a base64-encoded file of that size
1304
+ * needs) is gone and the guest's peak stops tracking the file's
1305
+ * size. The result is still one `Buffer`, because that is what this
1306
+ * method returns; a caller that must not hold even that iterates
1307
+ * {@link readFileStream} directly.
1308
+ * - **No options, guest does not advertise it** — today's single
1309
+ * whole-file reply, byte for byte, with today's ceiling.
1310
+ * - **`offset` and `length`** — one ranged `read-file`: a single round
1311
+ * trip for a single slice, which is the point of asking for one.
1312
+ * `offset` WITHOUT `length` is an unbounded tail, so it goes through
1313
+ * the stream instead; the guest refuses an uncapped range on
1314
+ * `read-file` for exactly that reason.
1315
+ *
1316
+ * A ranged read against a guest that does not advertise the feature is
1317
+ * REFUSED with {@link AgentReadFileStreamUnsupportedError} rather than
1318
+ * downgraded: such an agent ignores `offset`/`length` and answers with
1319
+ * the whole file, which the caller would read as its slice.
1320
+ */
1321
+ async readFile(path, options) {
1322
+ const signal = options?.signal;
1323
+ const { offset, length } = options ?? {};
1324
+ if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0)) {
1325
+ throw new Error('readFile: offset must be a non-negative safe integer');
1326
+ }
1327
+ if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) {
1328
+ throw new Error('readFile: length must be a non-negative safe integer');
1329
+ }
1330
+ const ranged = offset !== undefined || length !== undefined;
1331
+ if (!(await this.guestSupportsReadFileStream(signal))) {
1332
+ if (ranged) {
1333
+ throw new AgentReadFileStreamUnsupportedError(`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.`);
1334
+ }
1335
+ return await this.readFileWhole(path, signal);
1336
+ }
1337
+ if (length !== undefined) {
1338
+ return await this.readFileRange(path, offset ?? 0, length, signal);
1339
+ }
1340
+ // Copied into ONE buffer sized from the guest's `meta` frame rather
1341
+ // than collected and `Buffer.concat`ed: concat needs every chunk to
1342
+ // still exist when the whole is built, so it costs twice the file at
1343
+ // the moment it finishes — 2.3x measured on a 256 MiB read, against
1344
+ // the 2x this method is held to. Here the peak is the file plus one
1345
+ // chunk. The guest's own `end` frame is checked against what arrived
1346
+ // (see {@link readFileFrames}), so a short stream rejects rather than
1347
+ // handing back a buffer padded with whatever was in the allocation.
1348
+ let out;
1349
+ let at = 0;
1350
+ for await (const chunk of this.readFileFrames(path, options, (meta) => {
1351
+ // The one guest-supplied number this side turns into an
1352
+ // allocation, so it is the one worth bounding. The guest derives
1353
+ // `length` and `sizeBytes` from the same `stat` and never
1354
+ // announces more of a file than the file has, so a `length` past
1355
+ // `sizeBytes` is a guest this host should not be sizing a buffer
1356
+ // from. `allocUnsafe` would refuse the extreme values on its own;
1357
+ // this makes the refusal name what was wrong with the frame.
1358
+ if (!Number.isSafeInteger(meta.sizeBytes) ||
1359
+ meta.sizeBytes < 0 ||
1360
+ !Number.isSafeInteger(meta.length) ||
1361
+ meta.length < 0 ||
1362
+ meta.length > meta.sizeBytes) {
1363
+ throw new Error(`vsock transport: read-file-stream announced ${meta.length} bytes of a ${meta.sizeBytes}-byte file`);
1364
+ }
1365
+ out = Buffer.allocUnsafe(meta.length);
1366
+ })) {
1367
+ if (out === undefined) {
1368
+ throw new Error('vsock transport: read-file-stream sent data before its meta frame');
1369
+ }
1370
+ if (at + chunk.byteLength > out.length) {
1371
+ throw new Error(`vsock transport: read-file-stream delivered more than the ${out.length} bytes it announced`);
1372
+ }
1373
+ chunk.copy(out, at);
1374
+ at += chunk.byteLength;
1375
+ }
1376
+ return out === undefined ? Buffer.alloc(0) : out.subarray(0, at);
1377
+ }
1378
+ /** Today's single whole-file reply, unchanged — see {@link readFile}. */
1379
+ async readFileWhole(path, signal) {
1380
+ const res = await this.request({ op: 'read-file', body: { path, encoding: 'base64' } }, signal);
612
1381
  if (!res.ok || typeof res.content !== 'string') {
613
1382
  throw new Error(res.error ?? 'read-file: no content');
614
1383
  }
615
1384
  return Buffer.from(res.content, 'base64');
616
1385
  }
1386
+ /**
1387
+ * One bounded slice, in one round trip.
1388
+ *
1389
+ * The guest's own ceiling on a range (`NAMZU_AGENT_READ_FILE_RANGE_BYTES`,
1390
+ * 1 MiB by default) is not mirrored here and deliberately so: it is the
1391
+ * DEPLOYMENT's number, a host that guessed it would refuse ranges the
1392
+ * guest would have served, and the guest's refusal already names the
1393
+ * variable that raises it.
1394
+ */
1395
+ async readFileRange(path, offset, length, signal) {
1396
+ const res = await this.request({ op: 'read-file', body: { path, encoding: 'base64', offset, length } }, signal);
1397
+ if (!res.ok || typeof res.content !== 'string') {
1398
+ throw new Error(res.error ?? 'read-file: no content');
1399
+ }
1400
+ return Buffer.from(res.content, 'base64');
1401
+ }
1402
+ /**
1403
+ * Read a file as an ordered sequence of chunks, so neither side holds
1404
+ * the whole of it.
1405
+ *
1406
+ * The guest sends `meta`, then `data` frames, then `end`, then the
1407
+ * zero-length terminator — the same terminated-stream shape `execute`
1408
+ * uses. Two bounds keep this side's heap flat while the guest's stays
1409
+ * flat on its own: the socket is PAUSED once
1410
+ * {@link READ_FILE_STREAM_HIGH_WATER_BYTES} of decoded chunks are
1411
+ * waiting for a slow consumer, and the guest itself waits for each
1412
+ * `data` frame to drain before it reads the next one.
1413
+ *
1414
+ * Leaving the loop early — `break`, an exception, an aborted
1415
+ * `options.signal` — destroys the socket in the generator's `finally`,
1416
+ * which is what makes the guest close its fd: it sees the connection go
1417
+ * and releases the descriptor rather than leaking one per abandoned
1418
+ * read.
1419
+ *
1420
+ * Refuses a guest that does not advertise
1421
+ * {@link READ_FILE_STREAM_FEATURE} before dialing, with
1422
+ * {@link AgentReadFileStreamUnsupportedError}.
1423
+ */
1424
+ readFileStream(path, options) {
1425
+ return this.readFileFrames(path, options);
1426
+ }
1427
+ /**
1428
+ * The stream itself. Private, and one argument wider than
1429
+ * {@link readFileStream}: `onMeta` fires once, with the guest's `meta`
1430
+ * frame, before the first chunk is yielded, which is how
1431
+ * {@link readFile} sizes its destination buffer without a second round
1432
+ * trip and without a public parameter nobody outside this class should
1433
+ * pass.
1434
+ */
1435
+ async *readFileFrames(path, options, onMeta) {
1436
+ const signal = options?.signal;
1437
+ signal?.throwIfAborted();
1438
+ if (!(await this.guestSupportsReadFileStream(signal))) {
1439
+ throw new AgentReadFileStreamUnsupportedError(`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.`);
1440
+ }
1441
+ const body = {
1442
+ path,
1443
+ ...(options?.offset !== undefined ? { offset: options.offset } : {}),
1444
+ ...(options?.length !== undefined ? { length: options.length } : {}),
1445
+ };
1446
+ const payload = JSON.stringify(this.withCredential({ op: 'read-file-stream', body }));
1447
+ this.assertPreauthBudget(payload);
1448
+ const socket = await this.dial(signal);
1449
+ const queue = [];
1450
+ let queuedBytes = 0;
1451
+ let paused = false;
1452
+ let ended = false;
1453
+ let failure;
1454
+ let meta;
1455
+ let received = 0;
1456
+ let declared;
1457
+ let wake;
1458
+ const notify = () => {
1459
+ const resume = wake;
1460
+ wake = undefined;
1461
+ resume?.();
1462
+ };
1463
+ const fail = (error) => {
1464
+ if (failure || ended)
1465
+ return;
1466
+ failure = error;
1467
+ notify();
1468
+ };
1469
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () => fail(new Error(`vsock transport: read-file-stream idle timeout after ${this.readIdleTimeoutMs}ms`)));
1470
+ const reader = new FrameReader();
1471
+ let terminated = false;
1472
+ const onAbort = () => fail(signalError(signal));
1473
+ socket.on('data', (chunk) => {
1474
+ // Once this read has failed — an abort, an idle timeout, a frame
1475
+ // the guest should not have sent — nothing more will be yielded,
1476
+ // so decoding what is still in flight only grows a queue no
1477
+ // consumer will ever pull from.
1478
+ if (failure)
1479
+ return;
1480
+ idle.bump();
1481
+ let frames;
1482
+ try {
1483
+ frames = reader.push(chunk);
1484
+ }
1485
+ catch (err) {
1486
+ fail(err instanceof Error ? err : new Error(String(err)));
1487
+ return;
1488
+ }
1489
+ for (const frameBody of frames) {
1490
+ if (terminated) {
1491
+ fail(new Error('vsock transport: read-file-stream emitted data after its terminator'));
1492
+ return;
1493
+ }
1494
+ if (frameBody.length === 0) {
1495
+ terminated = true;
1496
+ continue;
1497
+ }
1498
+ let event;
1499
+ try {
1500
+ event = JSON.parse(frameBody);
1501
+ }
1502
+ catch (err) {
1503
+ fail(err instanceof Error ? err : new Error(String(err)));
1504
+ return;
1505
+ }
1506
+ if (event.type === 'meta') {
1507
+ if (meta !== undefined) {
1508
+ fail(new Error('vsock transport: read-file-stream sent a second meta frame'));
1509
+ return;
1510
+ }
1511
+ meta = { sizeBytes: event.sizeBytes, offset: event.offset, length: event.length };
1512
+ declared = event.length;
1513
+ try {
1514
+ onMeta?.(meta);
1515
+ }
1516
+ catch (err) {
1517
+ fail(err instanceof Error ? err : new Error(String(err)));
1518
+ return;
1519
+ }
1520
+ continue;
1521
+ }
1522
+ if (event.type === 'data') {
1523
+ if (meta === undefined) {
1524
+ fail(new Error('vsock transport: read-file-stream sent data before its meta frame'));
1525
+ return;
1526
+ }
1527
+ const bytes = Buffer.from(event.data, 'base64');
1528
+ received += bytes.byteLength;
1529
+ queue.push(bytes);
1530
+ queuedBytes += bytes.byteLength;
1531
+ if (!paused && queuedBytes >= READ_FILE_STREAM_HIGH_WATER_BYTES) {
1532
+ paused = true;
1533
+ socket.pause();
1534
+ // The idle timer guards a guest that went silent, and
1535
+ // while WE are the reason it is silent it would be
1536
+ // measuring the consumer instead. A host draining a
1537
+ // gigabyte onto slow storage must not have its stream
1538
+ // torn down for reading carefully.
1539
+ idle.clear();
1540
+ }
1541
+ notify();
1542
+ continue;
1543
+ }
1544
+ if (event.type === 'end') {
1545
+ // The guest counts what it sent; this side counts what it
1546
+ // decoded. A mismatch is a lost or duplicated frame, and a
1547
+ // truncated file handed back as a whole one is exactly the
1548
+ // silent corruption a streamed read must not introduce.
1549
+ if (event.bytesSent !== received) {
1550
+ fail(new Error(`vsock transport: read-file-stream declared ${event.bytesSent} bytes and delivered ${received}`));
1551
+ return;
1552
+ }
1553
+ if (declared !== undefined && received !== declared) {
1554
+ fail(new Error(`vsock transport: read-file-stream announced ${declared} bytes and delivered ${received}`));
1555
+ return;
1556
+ }
1557
+ ended = true;
1558
+ notify();
1559
+ continue;
1560
+ }
1561
+ fail(new Error(event.error));
1562
+ return;
1563
+ }
1564
+ });
1565
+ socket.once('error', (err) => fail(err));
1566
+ socket.once('close', () => {
1567
+ if (ended || failure) {
1568
+ notify();
1569
+ return;
1570
+ }
1571
+ fail(new Error('vsock transport: read-file-stream socket closed before its end frame'));
1572
+ });
1573
+ if (signal?.aborted)
1574
+ fail(signalError(signal));
1575
+ else
1576
+ signal?.addEventListener('abort', onAbort, { once: true });
1577
+ try {
1578
+ socket.write(frame(payload));
1579
+ idle.bump();
1580
+ for (;;) {
1581
+ // Asked BEFORE the queue, not after it: an aborted read that
1582
+ // goes on handing its consumer up to a high-water mark of
1583
+ // already-decoded bytes before surfacing the rejection is not
1584
+ // the prompt refusal `signal` promises. `ended` is the other
1585
+ // way round — a finished stream owes the consumer every byte
1586
+ // that arrived, so the queue drains first.
1587
+ if (failure)
1588
+ throw failure;
1589
+ const next = queue.shift();
1590
+ if (next !== undefined) {
1591
+ queuedBytes -= next.byteLength;
1592
+ if (paused && queuedBytes < READ_FILE_STREAM_HIGH_WATER_BYTES) {
1593
+ paused = false;
1594
+ socket.resume();
1595
+ // Asking for bytes again restarts the clock that
1596
+ // measures whether they come.
1597
+ idle.bump();
1598
+ }
1599
+ yield next;
1600
+ continue;
1601
+ }
1602
+ if (ended)
1603
+ return;
1604
+ await new Promise((resolve) => {
1605
+ wake = resolve;
1606
+ });
1607
+ }
1608
+ }
1609
+ finally {
1610
+ idle.clear();
1611
+ signal?.removeEventListener('abort', onAbort);
1612
+ // Destroyed, never `end()`ed: the guest releases the file
1613
+ // descriptor when the connection goes, and a half-close would
1614
+ // leave it holding one for a read nobody is listening to.
1615
+ socket.destroy();
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Ask the guest whether it implements ranged and streamed reads.
1620
+ * Cached for the transport's lifetime, exactly as
1621
+ * {@link guestSupportsWriteFileParts} is, and for the same reason: a
1622
+ * pod does not swap its agent binary while it is running.
1623
+ */
1624
+ async guestSupportsReadFileStream(signal) {
1625
+ return (await this.guestFeatures(signal)).includes(READ_FILE_STREAM_FEATURE);
1626
+ }
617
1627
  /**
618
1628
  * Open a real PTY owned by the in-VM agent.
619
1629
  *
@@ -624,7 +1634,18 @@ export class VsockAgentTransport {
624
1634
  * the session and its authenticated WebSocket attachment.
625
1635
  */
626
1636
  async openTerminal(options) {
627
- const socket = await this.dial();
1637
+ return (await this.openSessionTerminal(options)).session;
1638
+ }
1639
+ /**
1640
+ * The same open, handing back the session's own handles as well as the
1641
+ * `TerminalSession` — the offset to come back at, and the detach that
1642
+ * ends the attachment without signalling the program.
1643
+ *
1644
+ * Exactly one code path serves both: a persistent terminal is not a
1645
+ * second kind of terminal, it is the same stream with a different answer
1646
+ * to "what does a closed connection mean".
1647
+ */
1648
+ async openSessionTerminal(options) {
628
1649
  const request = {
629
1650
  ...(options.command !== undefined ? { command: options.command } : {}),
630
1651
  ...(options.args !== undefined ? { args: options.args } : {}),
@@ -632,7 +1653,48 @@ export class VsockAgentTransport {
632
1653
  ...(options.env !== undefined ? { env: { ...options.env } } : {}),
633
1654
  cols: options.size.cols,
634
1655
  rows: options.size.rows,
1656
+ // Additive and optional: an agent that predates it ignores the
1657
+ // field and echoes nothing back, and nothing below arms.
1658
+ ...(this.heartbeatMs !== undefined ? { heartbeatMs: this.heartbeatMs } : {}),
1659
+ // Equally additive, and only ever sent by a caller that checked
1660
+ // the guest advertises `sessions` — see `protocol.ts`.
1661
+ ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
1662
+ ...(options.persistent !== undefined ? { persistent: options.persistent } : {}),
635
1663
  };
1664
+ return await this.openTerminalStream({ op: 'terminal', body: request }, { detachable: options.persistent === true });
1665
+ }
1666
+ /**
1667
+ * The framed, bidirectional stream behind every terminal this transport
1668
+ * opens — the one the `terminal` op starts, and the one `attach-session`
1669
+ * joins to a terminal that is already running.
1670
+ *
1671
+ * Parameterised rather than copied, because the two differ in exactly two
1672
+ * places and everything else — the dial, the framing, the ready
1673
+ * handshake, the read-idle timer that is cleared once a shell may
1674
+ * legitimately go quiet, the heartbeat, the output buffering before the
1675
+ * first listener, the kill grace — has to behave identically or a
1676
+ * reattached terminal is a second terminal implementation with its own
1677
+ * bugs. The two differences:
1678
+ *
1679
+ * - **`detachable`.** For a connection-bound terminal a lost stream IS
1680
+ * the end of the program, and `exited` resolves with `exitCode: -1`
1681
+ * exactly as it always has. For a session attachment it is not: the
1682
+ * program is still running in the pod, so `exited` REJECTS with
1683
+ * {@link AgentSessionDetachedError} rather than reporting an exit that
1684
+ * did not happen. The rejection is pre-handled here so a caller that
1685
+ * only reads output cannot take the host process down with an
1686
+ * unhandled rejection.
1687
+ * - **the offsets.** A session stream's frames carry their place in the
1688
+ * guest's retained log, and {@link AgentTerminalStream.nextOffset} is
1689
+ * what a reattach resumes from. It is never computed from the decoded
1690
+ * text: a chunk that ends mid-character decodes wider than the bytes
1691
+ * it replaced.
1692
+ */
1693
+ async openTerminalStream(req, init) {
1694
+ const askedHeartbeatMs = this.heartbeatMs;
1695
+ const openPayload = JSON.stringify(this.withCredential(req));
1696
+ this.assertPreauthBudget(openPayload);
1697
+ const socket = await this.dial();
636
1698
  return await new Promise((resolve, reject) => {
637
1699
  const KILL_GRACE_MS = 5_000;
638
1700
  const reader = new FrameReader();
@@ -642,23 +1704,40 @@ export class VsockAgentTransport {
642
1704
  let ready = false;
643
1705
  let settled = false;
644
1706
  let killTimer;
1707
+ let readyEvent = { type: 'ready' };
1708
+ let nextOffset;
1709
+ /** Armed only if the guest echoed the interval — see `protocol.ts`. */
1710
+ let liveness;
645
1711
  let resolveExit;
646
- const exited = new Promise((done) => {
1712
+ let rejectExit;
1713
+ const exited = new Promise((done, fail) => {
647
1714
  resolveExit = done;
1715
+ rejectExit = fail;
648
1716
  });
1717
+ // See the header: a detachable stream's `exited` can reject, and
1718
+ // the caller may legitimately never look at it.
1719
+ if (init.detachable)
1720
+ void exited.catch(() => undefined);
649
1721
  const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
650
1722
  finish(new Error(`vsock transport: terminal read idle timeout after ${this.readIdleTimeoutMs}ms`));
651
1723
  });
652
- const finish = (error, exit = { exitCode: -1 }) => {
1724
+ const finish = (error, exit) => {
653
1725
  if (settled)
654
1726
  return;
655
1727
  settled = true;
656
1728
  idle.clear();
1729
+ liveness?.stop();
657
1730
  if (killTimer)
658
1731
  clearTimeout(killTimer);
659
1732
  socket.destroy();
660
1733
  listeners.clear();
661
- resolveExit(exit);
1734
+ if (exit !== undefined)
1735
+ resolveExit(exit);
1736
+ else if (init.detachable) {
1737
+ rejectExit(new AgentSessionDetachedError(nextOffset, `vsock transport: this attachment ended without the session's program exiting${error ? `: ${error.message}` : ''}. The program is still the guest's to run; attach again to go on reading it.`, { cause: error ?? undefined }));
1738
+ }
1739
+ else
1740
+ resolveExit({ exitCode: -1 });
662
1741
  if (!ready)
663
1742
  reject(error ?? new Error('terminal exited before readiness'));
664
1743
  };
@@ -703,6 +1782,10 @@ export class VsockAgentTransport {
703
1782
  socket.on('data', (chunk) => {
704
1783
  if (!ready)
705
1784
  idle.bump();
1785
+ // Bytes are proof of life, not only a heartbeat and not only a
1786
+ // whole frame: one large frame can take longer to arrive than the
1787
+ // window, and the peer was plainly there while it was arriving.
1788
+ liveness?.bump();
706
1789
  let payloads;
707
1790
  try {
708
1791
  payloads = reader.push(chunk);
@@ -712,6 +1795,11 @@ export class VsockAgentTransport {
712
1795
  return;
713
1796
  }
714
1797
  for (const payload of payloads) {
1798
+ // The guest ends a session stream with the same zero-length
1799
+ // terminator every other streamed op uses. Nothing follows it,
1800
+ // and the close below is what settles this stream.
1801
+ if (payload.length === 0)
1802
+ continue;
715
1803
  let event;
716
1804
  try {
717
1805
  event = JSON.parse(payload);
@@ -723,15 +1811,36 @@ export class VsockAgentTransport {
723
1811
  if (event.type === 'ready') {
724
1812
  if (!ready) {
725
1813
  ready = true;
1814
+ readyEvent = event;
1815
+ this.observeGuestReply(event);
1816
+ if (typeof event.nextOffset === 'number')
1817
+ nextOffset = event.nextOffset;
726
1818
  // Once ready, an interactive shell may legitimately sit silent
727
1819
  // for hours. Runtime/session TTL owns idle cleanup; a transport
728
1820
  // read timer would incorrectly kill a healthy quiet terminal.
1821
+ // The heartbeat below is what tells that shell from a peer
1822
+ // that vanished — armed only if the guest echoed an interval.
729
1823
  idle.clear();
730
- resolve(session);
1824
+ const beat = negotiatedHeartbeatMs(askedHeartbeatMs, event.heartbeatMs);
1825
+ if (beat !== undefined) {
1826
+ liveness = new StreamLiveness(beat, () => send({ type: 'heartbeat' }), () => finish(new Error(`vsock transport: terminal peer sent nothing for ${beat * STREAM_HEARTBEAT_MISS_LIMIT}ms and is treated as gone`)));
1827
+ }
1828
+ resolve({
1829
+ session,
1830
+ get ready() {
1831
+ return readyEvent;
1832
+ },
1833
+ nextOffset: () => nextOffset,
1834
+ detach: () => finish(new Error('vsock transport: attachment released by the host')),
1835
+ });
731
1836
  }
732
1837
  continue;
733
1838
  }
1839
+ if (event.type === 'heartbeat')
1840
+ continue;
734
1841
  if (event.type === 'data') {
1842
+ if (typeof event.nextOffset === 'number')
1843
+ nextOffset = event.nextOffset;
735
1844
  if (listeners.size === 0) {
736
1845
  buffered.push(event.data);
737
1846
  bufferedBytes += Buffer.byteLength(event.data);
@@ -746,12 +1855,21 @@ export class VsockAgentTransport {
746
1855
  continue;
747
1856
  }
748
1857
  if (event.type === 'exit') {
1858
+ if (typeof event.nextOffset === 'number')
1859
+ nextOffset = event.nextOffset;
749
1860
  finish(null, {
750
1861
  exitCode: event.exitCode,
751
1862
  ...(event.signal !== undefined ? { signal: event.signal } : {}),
752
1863
  });
753
1864
  return;
754
1865
  }
1866
+ if (event.type === 'detached') {
1867
+ // The program did not exit: another attachment took the
1868
+ // session, or this one stopped draining. Either way this
1869
+ // stream ends and nothing in the guest was signalled.
1870
+ finish(new Error(`the guest ended this attachment (${event.reason})`));
1871
+ return;
1872
+ }
755
1873
  finish(new Error(event.error));
756
1874
  return;
757
1875
  }
@@ -759,12 +1877,27 @@ export class VsockAgentTransport {
759
1877
  socket.once('error', (err) => finish(err));
760
1878
  socket.once('close', () => finish(new Error('vsock transport: terminal socket closed before exit')));
761
1879
  idle.bump();
762
- socket.write(frame(JSON.stringify({
763
- op: 'terminal',
764
- body: request,
765
- })));
1880
+ socket.write(frame(openPayload));
766
1881
  });
767
1882
  }
1883
+ /**
1884
+ * Join a terminal session that is already running in the guest, replaying
1885
+ * what it printed from `fromOffset` before following it live.
1886
+ *
1887
+ * The guest allows ONE attachment per session and ends the previous one
1888
+ * by name, so two host processes cannot interleave keystrokes into one
1889
+ * shell. Nothing here signals the program: releasing this stream is a
1890
+ * detach, and ending the session is `kill-session`.
1891
+ */
1892
+ async attachSessionTerminal(request) {
1893
+ return await this.openTerminalStream({
1894
+ op: 'attach-session',
1895
+ body: {
1896
+ ...request,
1897
+ ...(this.heartbeatMs !== undefined ? { heartbeatMs: this.heartbeatMs } : {}),
1898
+ },
1899
+ }, { detachable: true });
1900
+ }
768
1901
  /** Open one TCP stream to a service listening on guest loopback. */
769
1902
  async openTcpConnection(options) {
770
1903
  if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65_535) {
@@ -774,8 +1907,16 @@ export class VsockAgentTransport {
774
1907
  if (host !== '127.0.0.1' && host !== '::1') {
775
1908
  throw new Error('firecracker TCP connections are restricted to guest loopback');
776
1909
  }
1910
+ const askedHeartbeatMs = this.heartbeatMs;
1911
+ const request = {
1912
+ host,
1913
+ port: options.port,
1914
+ // Additive and optional, exactly as on `terminal` — see `openTerminal`.
1915
+ ...(askedHeartbeatMs !== undefined ? { heartbeatMs: askedHeartbeatMs } : {}),
1916
+ };
1917
+ const openPayload = JSON.stringify(this.withCredential({ op: 'tcp-connect', body: request }));
1918
+ this.assertPreauthBudget(openPayload);
777
1919
  const socket = await this.dial();
778
- const request = { host, port: options.port };
779
1920
  return await new Promise((resolve, reject) => {
780
1921
  const reader = new FrameReader();
781
1922
  const listeners = new Set();
@@ -783,6 +1924,8 @@ export class VsockAgentTransport {
783
1924
  let bufferedBytes = 0;
784
1925
  let ready = false;
785
1926
  let settled = false;
1927
+ /** Armed only if the guest echoed the interval — see `protocol.ts`. */
1928
+ let liveness;
786
1929
  let resolveClosed;
787
1930
  const closed = new Promise((done) => {
788
1931
  resolveClosed = done;
@@ -795,6 +1938,7 @@ export class VsockAgentTransport {
795
1938
  return;
796
1939
  settled = true;
797
1940
  idle.clear();
1941
+ liveness?.stop();
798
1942
  socket.destroy();
799
1943
  listeners.clear();
800
1944
  resolveClosed();
@@ -818,10 +1962,14 @@ export class VsockAgentTransport {
818
1962
  },
819
1963
  pause() {
820
1964
  socket.pause();
1965
+ // Paused by this caller, so nothing arriving is this caller's
1966
+ // doing and not the peer's — see `StreamLiveness.suspend`.
1967
+ liveness?.suspend();
821
1968
  },
822
1969
  resume() {
823
1970
  if (!settled)
824
1971
  socket.resume();
1972
+ liveness?.resume();
825
1973
  },
826
1974
  onData(listener) {
827
1975
  listeners.add(listener);
@@ -846,6 +1994,8 @@ export class VsockAgentTransport {
846
1994
  socket.on('data', (chunk) => {
847
1995
  if (!ready)
848
1996
  idle.bump();
1997
+ // Bytes, not frames — see the reader in `openTerminal` above.
1998
+ liveness?.bump();
849
1999
  let payloads;
850
2000
  try {
851
2001
  payloads = reader.push(chunk);
@@ -866,11 +2016,20 @@ export class VsockAgentTransport {
866
2016
  if (event.type === 'ready') {
867
2017
  if (!ready) {
868
2018
  ready = true;
2019
+ this.observeGuestReply(event);
869
2020
  idle.clear();
2021
+ const beat = negotiatedHeartbeatMs(askedHeartbeatMs, event.heartbeatMs);
2022
+ if (beat !== undefined) {
2023
+ liveness = new StreamLiveness(beat, () => {
2024
+ send({ type: 'heartbeat' });
2025
+ }, () => finish(null));
2026
+ }
870
2027
  resolve(connection);
871
2028
  }
872
2029
  continue;
873
2030
  }
2031
+ if (event.type === 'heartbeat')
2032
+ continue;
874
2033
  if (event.type === 'data') {
875
2034
  const bytes = Buffer.from(event.data, 'base64');
876
2035
  if (listeners.size === 0) {
@@ -896,10 +2055,7 @@ export class VsockAgentTransport {
896
2055
  socket.once('error', (error) => finish(error));
897
2056
  socket.once('close', () => finish(ready ? null : new Error('vsock TCP socket closed before readiness')));
898
2057
  idle.bump();
899
- socket.write(frame(JSON.stringify({
900
- op: 'tcp-connect',
901
- body: request,
902
- })));
2058
+ socket.write(frame(openPayload));
903
2059
  });
904
2060
  }
905
2061
  }
@@ -925,6 +2081,97 @@ class LineReader {
925
2081
  return r;
926
2082
  }
927
2083
  }
2084
+ /**
2085
+ * The host half of the negotiated stream heartbeat (`protocol.ts`'s
2086
+ * `StreamHeartbeat`): send one every interval, and give up on a peer that
2087
+ * has sent nothing for {@link STREAM_HEARTBEAT_MISS_LIMIT} of them.
2088
+ *
2089
+ * Constructed only once the guest ECHOED an interval, so a transport that
2090
+ * asked for no heartbeat, or one talking to an agent that predates the
2091
+ * field, never builds one and writes no frame an older peer could not read.
2092
+ *
2093
+ * The watchdog polls at a quarter of the interval rather than at the
2094
+ * interval, so a dead stream is noticed within the three intervals plus at
2095
+ * most one poll tick rather than within four. Both timers are unref'd: a
2096
+ * host process with nothing else to do should exit, not be held open by a
2097
+ * terminal it forgot about.
2098
+ */
2099
+ class StreamLiveness {
2100
+ intervalMs;
2101
+ send;
2102
+ onDead;
2103
+ sendTimer;
2104
+ watchTimer;
2105
+ lastSeen = Date.now();
2106
+ watching = true;
2107
+ stopped = false;
2108
+ constructor(intervalMs, send, onDead) {
2109
+ this.intervalMs = intervalMs;
2110
+ this.send = send;
2111
+ this.onDead = onDead;
2112
+ this.sendTimer = setInterval(() => {
2113
+ if (!this.stopped)
2114
+ this.send();
2115
+ }, intervalMs);
2116
+ this.sendTimer.unref?.();
2117
+ this.watchTimer = setInterval(() => this.check(), Math.max(10, Math.floor(intervalMs / 4)));
2118
+ this.watchTimer.unref?.();
2119
+ }
2120
+ /** Any BYTES from the peer count, not just a heartbeat and not a whole frame. */
2121
+ bump() {
2122
+ this.lastSeen = Date.now();
2123
+ }
2124
+ /**
2125
+ * This side has paused reading for backpressure, so silence is its own
2126
+ * doing and the peer's frames are waiting in the kernel. Not counted.
2127
+ */
2128
+ suspend() {
2129
+ this.watching = false;
2130
+ }
2131
+ resume() {
2132
+ if (this.stopped)
2133
+ return;
2134
+ this.watching = true;
2135
+ this.lastSeen = Date.now();
2136
+ }
2137
+ stop() {
2138
+ this.stopped = true;
2139
+ if (this.sendTimer)
2140
+ clearInterval(this.sendTimer);
2141
+ if (this.watchTimer)
2142
+ clearInterval(this.watchTimer);
2143
+ this.sendTimer = undefined;
2144
+ this.watchTimer = undefined;
2145
+ }
2146
+ check() {
2147
+ if (this.stopped || !this.watching)
2148
+ return;
2149
+ if (Date.now() - this.lastSeen < this.intervalMs * STREAM_HEARTBEAT_MISS_LIMIT)
2150
+ return;
2151
+ this.stop();
2152
+ this.onDead();
2153
+ }
2154
+ }
2155
+ /**
2156
+ * The interval the guest echoed back, or undefined when this side asked for
2157
+ * no heartbeat or the guest did not answer with one. An agent that predates
2158
+ * the field echoes nothing, which is exactly what keeps an older image's
2159
+ * streams behaving as they always did.
2160
+ *
2161
+ * The echo is clamped into `[MIN_STREAM_HEARTBEAT_MS, asked x
2162
+ * STREAM_HEARTBEAT_MAX_ECHO_FACTOR]`, because it is a number from the pod and
2163
+ * this side times its own watchdog with it. A guest that clamps to the same
2164
+ * floor — which is what this repository's agent does — always echoes a value
2165
+ * already inside the band, so nothing about the honest case changes.
2166
+ */
2167
+ function negotiatedHeartbeatMs(asked, echoed) {
2168
+ if (asked === undefined)
2169
+ return undefined;
2170
+ if (typeof echoed !== 'number' || !Number.isFinite(echoed) || echoed <= 0)
2171
+ return undefined;
2172
+ const ceiling = Math.max(asked, MIN_STREAM_HEARTBEAT_MS) * STREAM_HEARTBEAT_MAX_ECHO_FACTOR;
2173
+ return Math.min(ceiling, Math.max(MIN_STREAM_HEARTBEAT_MS, Math.floor(echoed)));
2174
+ }
928
2175
  /** Resets a timer on every byte; fires `onIdle` after `ms` of silence. */
929
2176
  class IdleTimer {
930
2177
  ms;
@@ -966,6 +2213,29 @@ function delay(ms, signal) {
966
2213
  signal?.addEventListener('abort', abort, { once: true });
967
2214
  });
968
2215
  }
2216
+ /** Length of `n` raw bytes base64-encoded, padding included. Exact. */
2217
+ function base64Length(n) {
2218
+ return 4 * Math.ceil(n / 3);
2219
+ }
2220
+ /**
2221
+ * The temp file a part sequence for `target` writes into: a SIBLING of the
2222
+ * target, so the finishing `rename` is a within-directory rename on one
2223
+ * filesystem (atomic) rather than a cross-device copy, and so the path
2224
+ * passes the guest's workspace jail exactly as the target does.
2225
+ *
2226
+ * Split on `/` rather than through `node:path` because the path is the
2227
+ * GUEST's, which is always POSIX — a host running the orchestrator on
2228
+ * Windows must not rewrite it with backslashes. The target's own basename
2229
+ * rides along, truncated, so an operator who finds one of these knows what
2230
+ * it was becoming; the uuid is what makes two concurrent writers to the
2231
+ * same target use two different temp files.
2232
+ */
2233
+ function writeFilePartTempPath(target) {
2234
+ const slash = target.lastIndexOf('/');
2235
+ const dir = slash < 0 ? '' : target.slice(0, slash + 1);
2236
+ const base = (slash < 0 ? target : target.slice(slash + 1)).slice(0, 96);
2237
+ return `${dir}.namzu-write-${randomUUID()}-${base}.part`;
2238
+ }
969
2239
  function signalError(signal) {
970
2240
  if (signal?.reason instanceof Error)
971
2241
  return signal.reason;
@@ -979,6 +2249,9 @@ function describeHandle(handle) {
979
2249
  return `vsock:${handle.udsPath}#${handle.port}`;
980
2250
  case 'mtls':
981
2251
  return `mtls:${handle.host}:${handle.port}/${handle.sandboxId}`;
2252
+ case 'tcp':
2253
+ // Never the token: this string lands in thrown error messages.
2254
+ return `tcp:${handle.host}:${handle.port}`;
982
2255
  }
983
2256
  }
984
2257
  // Internal framing helpers exported for the transport unit tests so the