@namzu/sandbox 14.0.0 → 16.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 (100) hide show
  1. package/CHANGELOG.md +924 -0
  2. package/README.md +369 -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 +169 -6
  7. package/dist/backends/docker/index.d.ts.map +1 -1
  8. package/dist/backends/docker/index.js +499 -85
  9. package/dist/backends/docker/index.js.map +1 -1
  10. package/dist/backends/firecracker/index.d.ts.map +1 -1
  11. package/dist/backends/firecracker/index.js +12 -2
  12. package/dist/backends/firecracker/index.js.map +1 -1
  13. package/dist/backends/firecracker/protocol.d.ts +459 -8
  14. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  15. package/dist/backends/firecracker/protocol.js +136 -0
  16. package/dist/backends/firecracker/protocol.js.map +1 -1
  17. package/dist/backends/firecracker/transport.d.ts +539 -6
  18. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  19. package/dist/backends/firecracker/transport.js +1171 -24
  20. package/dist/backends/firecracker/transport.js.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.d.ts +1181 -13
  22. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  23. package/dist/backends/kubernetes/egress-policy.js +2350 -31
  24. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  25. package/dist/backends/kubernetes/identity.d.ts +193 -0
  26. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  27. package/dist/backends/kubernetes/identity.js +147 -0
  28. package/dist/backends/kubernetes/identity.js.map +1 -0
  29. package/dist/backends/kubernetes/index.d.ts +678 -33
  30. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  31. package/dist/backends/kubernetes/index.js +1180 -95
  32. package/dist/backends/kubernetes/index.js.map +1 -1
  33. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  34. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  35. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  36. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  38. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  39. package/dist/backends/kubernetes/k8s-client.js +359 -52
  40. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  41. package/dist/backends/kubernetes/lease.d.ts +40 -14
  42. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  43. package/dist/backends/kubernetes/lease.js +68 -18
  44. package/dist/backends/kubernetes/lease.js.map +1 -1
  45. package/dist/backends/kubernetes/objects.d.ts +423 -3
  46. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  47. package/dist/backends/kubernetes/objects.js +364 -2
  48. package/dist/backends/kubernetes/objects.js.map +1 -1
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js +375 -0
  52. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  53. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  54. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  55. package/dist/backends/kubernetes/rbac.js +177 -0
  56. package/dist/backends/kubernetes/rbac.js.map +1 -0
  57. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  58. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  59. package/dist/backends/kubernetes/sandbox.js +149 -15
  60. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  61. package/dist/backends/kubernetes/transport.d.ts +935 -9
  62. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  63. package/dist/backends/kubernetes/transport.js +1958 -62
  64. package/dist/backends/kubernetes/transport.js.map +1 -1
  65. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  66. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  67. package/dist/backends/kubernetes/workspace.js +2825 -186
  68. package/dist/backends/kubernetes/workspace.js.map +1 -1
  69. package/dist/backends/remote-execution-controller.d.ts +14 -0
  70. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  71. package/dist/backends/remote-execution-controller.js.map +1 -1
  72. package/dist/index.d.ts +294 -18
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +280 -10
  75. package/dist/index.js.map +1 -1
  76. package/dist/testing/sandbox-conformance.d.ts +39 -5
  77. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  78. package/dist/testing/sandbox-conformance.js +436 -5
  79. package/dist/testing/sandbox-conformance.js.map +1 -1
  80. package/package.json +3 -3
  81. package/src/backends/aci-standby-pool/index.ts +16 -1
  82. package/src/backends/docker/index.ts +617 -100
  83. package/src/backends/firecracker/index.ts +14 -2
  84. package/src/backends/firecracker/protocol.ts +514 -6
  85. package/src/backends/firecracker/transport.ts +1492 -40
  86. package/src/backends/kubernetes/egress-policy.ts +3334 -55
  87. package/src/backends/kubernetes/identity.ts +261 -0
  88. package/src/backends/kubernetes/index.ts +1785 -127
  89. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  90. package/src/backends/kubernetes/k8s-client.ts +444 -54
  91. package/src/backends/kubernetes/lease.ts +75 -19
  92. package/src/backends/kubernetes/objects.ts +626 -6
  93. package/src/backends/kubernetes/per-sandbox-policy.ts +497 -0
  94. package/src/backends/kubernetes/rbac.ts +192 -0
  95. package/src/backends/kubernetes/sandbox.ts +218 -20
  96. package/src/backends/kubernetes/transport.ts +2733 -124
  97. package/src/backends/kubernetes/workspace.ts +4476 -222
  98. package/src/backends/remote-execution-controller.ts +14 -0
  99. package/src/index.ts +668 -19
  100. package/src/testing/sandbox-conformance.ts +540 -5
@@ -55,6 +55,7 @@ import type {
55
55
  SandboxExecResult,
56
56
  SandboxFileEntry,
57
57
  SandboxId,
58
+ SandboxReadFileOptions,
58
59
  SandboxStatus,
59
60
  SandboxTcpConnectOptions,
60
61
  SandboxTcpConnection,
@@ -559,9 +560,20 @@ async function spawnFirecrackerSandbox(
559
560
  await transport.writeFile(path, buf)
560
561
  },
561
562
 
562
- async readFile(path: string): Promise<Buffer> {
563
+ async readFile(path: string, readOptions?: SandboxReadFileOptions): Promise<Buffer> {
563
564
  assertActive()
564
- return await transport.readFile(path)
565
+ return await transport.readFile(path, readOptions)
566
+ },
567
+
568
+ /**
569
+ * Chunks, in order, with nothing whole at either end. The guest must
570
+ * advertise the capability; a golden image built before it refuses
571
+ * with `AgentReadFileStreamUnsupportedError` rather than reading the
572
+ * file whole and pretending to have streamed it.
573
+ */
574
+ readFileStream(path: string, readOptions?: SandboxReadFileOptions): AsyncIterable<Buffer> {
575
+ assertActive()
576
+ return transport.readFileStream(path, readOptions)
565
577
  },
566
578
 
567
579
  async openTerminal(options: OpenTerminalOptions): Promise<TerminalSession> {
@@ -65,6 +65,17 @@ export interface ExecRequest {
65
65
  readonly stdin?: string
66
66
  readonly timeoutMs?: number
67
67
  readonly maxOutputBytes?: number
68
+ /**
69
+ * Ask the guest to keep this command's output in its retained log, so a
70
+ * host that loses the data connection can reattach by execution id and
71
+ * read on from the offset it reached.
72
+ *
73
+ * Requires `executionId` — a log nobody can name is a log nobody can
74
+ * attach to — and requires the guest to advertise `execution-attach` in
75
+ * its `healthz` features. Omitted on every ordinary exec, which is why
76
+ * the default wire request is byte-for-byte what it has always been.
77
+ */
78
+ readonly retainOutput?: boolean
68
79
  }
69
80
 
70
81
  /**
@@ -72,12 +83,28 @@ export interface ExecRequest {
72
83
  * exact union the HTTP worker writes via `writeEvent`:
73
84
  * { type: 'stdout_delta', data }
74
85
  * { type: 'stderr_delta', data }
86
+ *
87
+ * A delta of an execution the guest was asked to RETAIN also carries
88
+ * `offset` and `nextOffset`: the bytes the chunk occupies in that
89
+ * execution's retained log. They are additive, ignored by every consumer
90
+ * that does not reattach, and they are the only correct source for a
91
+ * reattach cursor — see {@link parseExecEvent}.
75
92
  * { type: 'result', exitCode, timedOut, durationMs, stdoutTruncated?, stderrTruncated? }
76
93
  * { type: 'error', error }
77
94
  */
78
95
  export type ExecEvent =
79
- | { readonly type: 'stdout_delta'; readonly data: string }
80
- | { readonly type: 'stderr_delta'; readonly data: string }
96
+ | {
97
+ readonly type: 'stdout_delta'
98
+ readonly data: string
99
+ readonly offset?: number
100
+ readonly nextOffset?: number
101
+ }
102
+ | {
103
+ readonly type: 'stderr_delta'
104
+ readonly data: string
105
+ readonly offset?: number
106
+ readonly nextOffset?: number
107
+ }
81
108
  | {
82
109
  readonly type: 'result'
83
110
  readonly exitCode: number
@@ -93,26 +120,144 @@ export type ExecEvent =
93
120
  // File-IO — base64 request + response shapes (verbatim from server.js)
94
121
  // ---------------------------------------------------------------------------
95
122
 
123
+ /**
124
+ * One slice of a `write-file` body, for a body too large to cross the wire
125
+ * in a single frame.
126
+ *
127
+ * ADDITIVE, and deliberately so: the guest protocol version is unchanged,
128
+ * an agent that predates this field ignores it, and a host only ever sends
129
+ * it to an agent that advertised {@link WRITE_FILE_PARTS_FEATURE} in its
130
+ * `healthz` reply. The sequence writes a temporary sibling of the target
131
+ * (`WriteFileRequest.path` names the TEMP file while `part` is present, so
132
+ * an agent that dropped the field would overwrite the temp file rather
133
+ * than the target) and finishes with an atomic rename onto `renameTo`.
134
+ */
135
+ export interface WriteFilePart {
136
+ /**
137
+ * Byte offset in the temp file this slice starts at. `0` creates or
138
+ * truncates it; any other value must equal the temp file's CURRENT
139
+ * size, so a lost, duplicated or reordered part is refused rather
140
+ * than silently producing a corrupt file.
141
+ */
142
+ readonly offset?: number
143
+ /** Last slice: rename the temp file onto {@link renameTo} once written. */
144
+ readonly final?: boolean
145
+ /** The real target, required when {@link final} is true. */
146
+ readonly renameTo?: string
147
+ /**
148
+ * Remove the temp file named by `path` and write nothing — the
149
+ * best-effort cleanup a host runs when a part sequence is abandoned.
150
+ * `content` is ignored.
151
+ */
152
+ readonly discard?: boolean
153
+ }
154
+
96
155
  /** `/write-file` request body. `content` is base64. */
97
156
  export interface WriteFileRequest {
98
157
  readonly path: string
99
158
  readonly content: string
100
159
  readonly encoding: 'base64'
160
+ /** Present only for a chunked write; see {@link WriteFilePart}. */
161
+ readonly part?: WriteFilePart
101
162
  }
102
163
 
103
164
  /** `/write-file` success response. */
104
165
  export interface WriteFileResponse {
105
166
  readonly ok: boolean
106
167
  readonly bytesWritten?: number
168
+ /** Total size of the temp file after this part, for a chunked write. */
169
+ readonly sizeBytes?: number
107
170
  readonly error?: string
108
171
  }
109
172
 
110
- /** `/read-file` request body. */
173
+ /**
174
+ * The `healthz` feature string an agent advertises when it implements
175
+ * {@link WriteFilePart}. A host that does not see it in `features` keeps
176
+ * to the single-frame write and its named too-large error.
177
+ */
178
+ export const WRITE_FILE_PARTS_FEATURE = 'write-file-parts'
179
+
180
+ /**
181
+ * The `healthz` feature string an agent advertises when it accepts a
182
+ * caller-chosen `executionId`, an `execute` carrying `retainOutput`, and
183
+ * the `attach-execution` op that replays a retained execution's output by
184
+ * byte offset.
185
+ *
186
+ * A host asking for a detachable command against a guest that does not
187
+ * advertise it is refused BEFORE the command is admitted, rather than
188
+ * starting one whose output nothing keeps.
189
+ */
190
+ export const EXECUTION_ATTACH_FEATURE = 'execution-attach'
191
+
192
+ /**
193
+ * `/read-file` request body.
194
+ *
195
+ * `offset`/`length` are ADDITIVE and gated exactly as {@link WriteFilePart}
196
+ * is: a host sends them only to an agent that advertised
197
+ * {@link READ_FILE_STREAM_FEATURE} in its `healthz` reply, because an agent
198
+ * that predates them ignores both and answers with the WHOLE file — which
199
+ * the caller would read as its slice. Omitting both is the whole-file read
200
+ * this op has always served, byte for byte.
201
+ */
111
202
  export interface ReadFileRequest {
112
203
  readonly path: string
113
204
  readonly encoding: 'base64'
205
+ /** First byte of the slice. Defaults to 0 when only `length` is given. */
206
+ readonly offset?: number
207
+ /**
208
+ * How many bytes to answer with. Defaults to the rest of the file, and
209
+ * is REFUSED rather than shortened above the guest's per-frame range
210
+ * ceiling (`NAMZU_AGENT_READ_FILE_RANGE_BYTES`, 1 MiB by default) — a
211
+ * whole file goes through {@link ReadFileStreamRequest} instead.
212
+ */
213
+ readonly length?: number
214
+ }
215
+
216
+ /**
217
+ * `read-file-stream` request body — one file as an ordered sequence of
218
+ * frames rather than one reply.
219
+ *
220
+ * `offset`/`length` are accepted and deliberately NOT capped: this op is
221
+ * where {@link ReadFileRequest}'s range ceiling sends a caller who wants
222
+ * more than one frame's worth.
223
+ */
224
+ export interface ReadFileStreamRequest {
225
+ readonly path: string
226
+ readonly offset?: number
227
+ readonly length?: number
114
228
  }
115
229
 
230
+ /**
231
+ * Guest → host frames for one `read-file-stream`, in order: exactly one
232
+ * `meta`, zero or more `data`, then one `end` — or a single `error`
233
+ * instead of any of them — followed by the zero-length terminator.
234
+ *
235
+ * `data` is base64 for the same reason every other payload on this wire
236
+ * is: the frame body is UTF-8 JSON, which cannot carry arbitrary bytes.
237
+ */
238
+ export type ReadFileStreamEvent =
239
+ | {
240
+ readonly type: 'meta'
241
+ /** The WHOLE file's size, never the slice's — how a caller knows where it ends. */
242
+ readonly sizeBytes: number
243
+ readonly offset: number
244
+ /** What this stream intends to send, after clamping to EOF. */
245
+ readonly length: number
246
+ }
247
+ | { readonly type: 'data'; readonly data: string }
248
+ | { readonly type: 'end'; readonly bytesSent: number }
249
+ | { readonly type: 'error'; readonly error: string }
250
+
251
+ /**
252
+ * The `healthz` feature string an agent advertises when `read-file`
253
+ * honours {@link ReadFileRequest.offset}/`length` AND the
254
+ * `read-file-stream` op exists. ONE string for both halves because they
255
+ * ship together in `agent/agent.cjs` and no guest can have one without the
256
+ * other; a host that does not see it sends neither shape and keeps to the
257
+ * single whole-file reply.
258
+ */
259
+ export const READ_FILE_STREAM_FEATURE = 'read-file-stream'
260
+
116
261
  // ---------------------------------------------------------------------------
117
262
  // Terminal — a real guest-owned PTY over the same framed stream
118
263
  // ---------------------------------------------------------------------------
@@ -125,6 +270,22 @@ export interface TerminalOpenRequest {
125
270
  readonly env?: Record<string, string>
126
271
  readonly cols: number
127
272
  readonly rows: number
273
+ /** See {@link StreamHeartbeat}. Absent → no heartbeat on this stream. */
274
+ readonly heartbeatMs?: number
275
+ /**
276
+ * Name this terminal so it can be found again. Both fields are ADDITIVE
277
+ * and both are required together: a guest that predates them ignores
278
+ * them and serves the connection-bound terminal it always served, which
279
+ * is why a host only ever sends them to one advertising
280
+ * {@link SESSIONS_FEATURE}.
281
+ *
282
+ * With them, the PTY belongs to the guest's session registry rather than
283
+ * to this connection: output is read into a retained log whether or not
284
+ * anybody is attached, and closing the connection detaches instead of
285
+ * killing.
286
+ */
287
+ readonly sessionId?: string
288
+ readonly persistent?: boolean
128
289
  }
129
290
 
130
291
  /** Host → guest messages after the terminal stream reports ready. */
@@ -132,17 +293,265 @@ export type TerminalInputEvent =
132
293
  | { readonly type: 'input'; readonly data: string }
133
294
  | { readonly type: 'resize'; readonly cols: number; readonly rows: number }
134
295
  | { readonly type: 'kill'; readonly signal?: string }
296
+ | StreamHeartbeat
135
297
 
136
298
  /** Guest → host events carried for the lifetime of the terminal stream. */
137
299
  export type TerminalOutputEvent =
138
- | { readonly type: 'ready' }
139
- | { readonly type: 'data'; readonly data: string }
300
+ | TerminalReadyEvent
301
+ | {
302
+ readonly type: 'data'
303
+ readonly data: string
304
+ /** Only on a session stream: where this chunk sits in the retained log. */
305
+ readonly stream?: SessionStreamName
306
+ readonly offset?: number
307
+ readonly nextOffset?: number
308
+ }
140
309
  | {
141
310
  readonly type: 'exit'
142
311
  readonly exitCode: number
143
312
  readonly signal?: number
313
+ readonly nextOffset?: number
144
314
  }
315
+ | SessionDetachedEvent
145
316
  | { readonly type: 'error'; readonly error: string }
317
+ | StreamHeartbeat
318
+
319
+ /**
320
+ * The opening frame of a terminal stream, and the one place the session ops
321
+ * add to it.
322
+ *
323
+ * Every session field is optional because a connection-bound terminal sends
324
+ * none of them, and a guest that predates the registry sends none either.
325
+ */
326
+ export interface TerminalReadyEvent {
327
+ readonly type: 'ready'
328
+ readonly heartbeatMs?: number
329
+ /**
330
+ * The guest agent PROCESS this stream reached — see
331
+ * {@link GUEST_BOOT_ID_FEATURE}. Absent from a guest that predates it, so
332
+ * nothing may require it.
333
+ */
334
+ readonly guestBootId?: string
335
+ readonly sessionId?: string
336
+ readonly kind?: SessionKind
337
+ readonly state?: SessionState
338
+ /** Where the replay this stream is about to send begins. */
339
+ readonly fromOffset?: number
340
+ /** Bytes evicted between what the reader asked for and what survived. */
341
+ readonly droppedBytes?: number
342
+ /** One past the newest byte the guest had when the stream opened. */
343
+ readonly nextOffset?: number
344
+ readonly exitCode?: number
345
+ readonly signal?: number
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Sessions — a program that outlives the connection that started it
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * The `healthz` feature string an agent advertises when it keeps a session
354
+ * registry: `terminal` with `{ sessionId, persistent: true }`, plus the
355
+ * `attach-session`, `start-detached`, `list-sessions` and `kill-session`
356
+ * ops.
357
+ *
358
+ * A host asking for any of them against a guest that does not advertise it
359
+ * is refused by name and never falls back to a connection-bound terminal: a
360
+ * caller that asked for a session is about to rely on coming back to it, and
361
+ * handing it one that dies with the socket would keep nothing and tell
362
+ * nobody.
363
+ */
364
+ export const SESSIONS_FEATURE = 'sessions'
365
+
366
+ /**
367
+ * `healthz.features` entry: every authenticated reply and every stream
368
+ * `ready` frame carries a `guestBootId` naming the agent PROCESS that
369
+ * answered.
370
+ *
371
+ * The field is optional on every shape that carries it and a host must treat
372
+ * an absent one as "this guest cannot tell me" rather than as a change — an
373
+ * image built before this existed answers exactly as it always did. The
374
+ * feature string is here for the one host that wants to REQUIRE the evidence
375
+ * (a Kubernetes workspace deciding whether a command's guest is still the
376
+ * one it started on) and needs to know before it relies on it.
377
+ */
378
+ export const GUEST_BOOT_ID_FEATURE = 'guest-boot-id'
379
+
380
+ /**
381
+ * The identity fields an authenticated reply may carry, and the whole of
382
+ * what an observer of one is allowed to read.
383
+ *
384
+ * Deliberately not the reply itself: an observer exists to follow the GUEST,
385
+ * and widening it to the payload would make every reply's content reachable
386
+ * from a hook whose job is a single opaque id.
387
+ */
388
+ export interface GuestReplyIdentity {
389
+ readonly guestBootId?: string
390
+ }
391
+
392
+ /** A PTY, or a program started with no terminal at all. */
393
+ export type SessionKind = 'terminal' | 'detached'
394
+
395
+ export type SessionState = 'running' | 'exited'
396
+
397
+ /** Which of the two streams a retained chunk came from. */
398
+ export type SessionStreamName = 'stdout' | 'stderr'
399
+
400
+ /** Why an attachment ended without the program exiting. */
401
+ export type SessionDetachReason = 'superseded' | 'slow_reader'
402
+
403
+ /**
404
+ * Sent to the attachment a session is taking away from it. It is never an
405
+ * exit: the program is still running, and the reason says who took it.
406
+ */
407
+ export interface SessionDetachedEvent {
408
+ readonly type: 'detached'
409
+ readonly reason: SessionDetachReason
410
+ }
411
+
412
+ /** Read one session's retained output, and optionally follow it live. */
413
+ export interface AttachSessionRequest {
414
+ readonly sessionId: string
415
+ /** Byte offset to resume from. Default 0 — the whole retained log. */
416
+ readonly fromOffset?: number
417
+ /**
418
+ * `false` replays what is retained and ends. Default `true`: stay
419
+ * attached, and — for a terminal session — accept input and resize.
420
+ */
421
+ readonly follow?: boolean
422
+ /** Resize the PTY on attach, for a terminal whose new reader has its own window. */
423
+ readonly cols?: number
424
+ readonly rows?: number
425
+ /** See {@link StreamHeartbeat}. Absent → no heartbeat on this stream. */
426
+ readonly heartbeatMs?: number
427
+ }
428
+
429
+ /** Start a program with no terminal, which nothing but a kill ends. */
430
+ export interface StartDetachedRequest {
431
+ readonly sessionId: string
432
+ readonly command: string
433
+ readonly args?: readonly string[]
434
+ readonly cwd?: string
435
+ readonly env?: Record<string, string>
436
+ }
437
+
438
+ /** End one session and everything still in it. */
439
+ export interface KillSessionRequest {
440
+ readonly sessionId: string
441
+ /** `SIGTERM`, `SIGKILL`, `SIGINT` or `SIGHUP`. Default `SIGKILL`. */
442
+ readonly signal?: string
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Quiesce — stop everything the guest is running, and keep serving
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * The `healthz` feature string an agent advertises when it implements the
451
+ * `quiesce` op: stop every process this guest is running — including ones
452
+ * no session and no execution owns — and go on serving `execute`,
453
+ * `read-file` and `write-file` afterwards.
454
+ *
455
+ * Gated exactly like {@link SESSIONS_FEATURE}: a host sends the op only to
456
+ * a guest that advertised it, because an agent that predates it answers
457
+ * `unknown_op: quiesce` and a host that read that as "nothing was running"
458
+ * would take its capture over a disk somebody is still writing to.
459
+ */
460
+ export const QUIESCE_FEATURE = 'quiesce'
461
+
462
+ /** Stop everything. `graceMs` bounds ONE round's SIGTERM window. */
463
+ export interface QuiesceRequest {
464
+ /**
465
+ * How long a round waits after `SIGTERM` before it escalates to
466
+ * `SIGKILL`. The guest refuses a value at or above its own
467
+ * cancel-confirmation timeout (5000ms by default) and defaults to
468
+ * 1000ms. It is NOT the pod's `terminationGracePeriodSeconds`.
469
+ */
470
+ readonly graceMs?: number
471
+ }
472
+
473
+ /**
474
+ * How wide the guest's scan was allowed to be — reported, so a narrowed
475
+ * scan is never silently weaker than the one the host asked for.
476
+ *
477
+ * - `pid-namespace` — every process in the guest's PID namespace but its
478
+ * init and the agent. What a shipped pod does.
479
+ * - `owned-sessions` — only the kernel sessions the guest's own registries
480
+ * own, which an agent that is not the init of its own PID namespace
481
+ * narrows itself to. It can miss a program that moved into a session of
482
+ * its own.
483
+ */
484
+ export type QuiesceScope = 'pid-namespace' | 'owned-sessions'
485
+
486
+ /** One process a quiesce stopped, and the signal that stopped it. */
487
+ export interface QuiescedProcess {
488
+ readonly pid: number
489
+ /**
490
+ * The kernel's short name for the executable (`/proc/<pid>/stat`), not
491
+ * the command line: this travels to the host, and a command line
492
+ * carries the workload's own arguments.
493
+ */
494
+ readonly command: string
495
+ readonly signal: 'SIGTERM' | 'SIGKILL'
496
+ }
497
+
498
+ /** What a successful `quiesce` answers with. */
499
+ export interface QuiesceReply {
500
+ readonly ok: true
501
+ readonly stopped: readonly QuiescedProcess[]
502
+ readonly scope: QuiesceScope
503
+ /** The window each round actually used, after the guest's own clamp. */
504
+ readonly graceMs: number
505
+ /** Scan-and-signal passes performed. `0` means nothing was running. */
506
+ readonly rounds: number
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // Flush — put the workspace's dirty pages on the device, on purpose
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /**
514
+ * The `healthz` feature string an agent advertises when it implements the
515
+ * `flush` op: run `syncfs(2)` over the workspace mount and answer only once
516
+ * it has returned.
517
+ *
518
+ * Gated exactly like {@link QUIESCE_FEATURE}, and for a sharper reason than
519
+ * any other feature in this file: an agent that predates the op answers
520
+ * `unknown_op: flush`, and a host that read that as "the disk is flushed"
521
+ * would take the pod away over pages that never reached the device. So the
522
+ * op is sent only to a guest that named it, and a guest that did not is
523
+ * reported as one that cannot flush rather than one that had nothing to.
524
+ *
525
+ * An agent that HAS the op advertises it only when it can actually perform
526
+ * one: the flush runs a program, and an image that stripped it has the code
527
+ * and nothing to run. Such a guest also answers `flush_unsupported` to an op
528
+ * sent anyway, which a host reads exactly as the missing string — because
529
+ * the alternative, an unconfirmed flush, is the shape that means "the disk
530
+ * may be missing writes" and would refuse that image's every suspend
531
+ * forever.
532
+ */
533
+ export const FLUSH_FEATURE = 'flush'
534
+
535
+ /** Flush the workspace filesystem. */
536
+ export interface FlushRequest {
537
+ /**
538
+ * How long the guest may spend on the `syncfs` before it reports the
539
+ * flush unconfirmed. The guest defaults to 10000ms
540
+ * (`NAMZU_AGENT_FLUSH_TIMEOUT_MS`). It is NOT the pod's
541
+ * `terminationGracePeriodSeconds`, which has to cover this and the
542
+ * guest's own termination drain together.
543
+ */
544
+ readonly timeoutMs?: number
545
+ }
546
+
547
+ /** What a successful `flush` answers with. */
548
+ export interface FlushReply {
549
+ readonly ok: true
550
+ /** How long the `syncfs` itself took, as the guest measured it. */
551
+ readonly durationMs: number
552
+ /** The mount the guest flushed — its workspace root. */
553
+ readonly workspace: string
554
+ }
146
555
 
147
556
  // ---------------------------------------------------------------------------
148
557
  // Loopback TCP — publish a service without moving it out of the sandbox
@@ -151,26 +560,111 @@ export type TerminalOutputEvent =
151
560
  export interface TcpConnectRequest {
152
561
  readonly host: '127.0.0.1' | '::1'
153
562
  readonly port: number
563
+ /** See {@link StreamHeartbeat}. Absent → no heartbeat on this stream. */
564
+ readonly heartbeatMs?: number
154
565
  }
155
566
 
156
567
  export type TcpInputEvent =
157
568
  | { readonly type: 'data'; readonly data: string }
158
569
  | { readonly type: 'end' }
159
570
  | { readonly type: 'destroy' }
571
+ | StreamHeartbeat
160
572
 
161
573
  export type TcpOutputEvent =
162
- | { readonly type: 'ready' }
574
+ | { readonly type: 'ready'; readonly heartbeatMs?: number; readonly guestBootId?: string }
163
575
  | { readonly type: 'data'; readonly data: string }
164
576
  | { readonly type: 'end' }
165
577
  | { readonly type: 'error'; readonly error: string }
578
+ | StreamHeartbeat
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Stream liveness — telling a quiet peer from a dead one
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * The one frame either side of an open `terminal` or `tcp-connect` stream
586
+ * may send to say it is still there.
587
+ *
588
+ * Once a stream reports `ready` the transport clears its read-idle timer,
589
+ * correctly: an interactive shell may sit silent for hours and a timer would
590
+ * kill a healthy one. So nothing was left that could tell that shell from a
591
+ * host that vanished without a FIN or an RST — a lost node, a partition, a
592
+ * middlebox that drops idle state. TCP keepalive proves only that the peer's
593
+ * KERNEL answers, and a `healthz` proves only that a FRESH connection is
594
+ * served; neither says anything about the stream in hand.
595
+ *
596
+ * **Negotiated per stream, in both directions.** The open request carries
597
+ * {@link TerminalOpenRequest.heartbeatMs} / {@link TcpConnectRequest.heartbeatMs};
598
+ * a guest that implements this echoes the interval it will use back in its
599
+ * `ready` event and only then starts sending, and the host only starts once
600
+ * that echo arrived. An agent that predates the field ignores it and echoes
601
+ * nothing, so the host behaves exactly as it did; an older HOST — which ends
602
+ * a stream with an error on any frame type it does not know — is never sent
603
+ * one, because it never asked.
604
+ *
605
+ * Anything arriving from the other side counts as proof of life, not just
606
+ * this frame and not even a whole frame: both sides count BYTES, so a single
607
+ * large frame that takes longer than the window to arrive cannot be read as
608
+ * the peer having gone away. {@link STREAM_HEARTBEAT_MISS_LIMIT} consecutive
609
+ * intervals with nothing at all end the stream: the host resolves `exited`
610
+ * with `exitCode: -1` (what a closed socket already produces) or resolves
611
+ * `closed`, and the guest runs the same close cleanup it runs for a socket
612
+ * that went away. Detection lands within those intervals plus at most one
613
+ * watchdog tick, since each side polls at a quarter of the interval rather
614
+ * than at it. Silence while a side has paused reading for backpressure is
615
+ * not silence — that side chose it, and the bytes are waiting in the kernel.
616
+ */
617
+ export type StreamHeartbeat = { readonly type: 'heartbeat' }
618
+
619
+ /** Missed intervals that end a stream. Three, so one lost frame is not fatal. */
620
+ export const STREAM_HEARTBEAT_MISS_LIMIT = 3
621
+
622
+ /**
623
+ * The shortest interval either side will run a heartbeat at.
624
+ *
625
+ * The interval arrives from the host, so the guest clamps it to this before
626
+ * using it and echoes the CLAMPED value — otherwise an authenticated host
627
+ * could ask for a fraction of a millisecond and leave the agent doing
628
+ * nothing but writing heartbeats.
629
+ */
630
+ export const MIN_STREAM_HEARTBEAT_MS = 100
631
+
632
+ /**
633
+ * How far above what it asked for a host will honour the guest's echo.
634
+ *
635
+ * The echo is a number from the pod, and the host does its own watchdog
636
+ * arithmetic with it: unclamped, a guest echoing a fraction of a millisecond
637
+ * makes the host tear the stream down on its first tick, and one echoing a
638
+ * day disables the host's detection entirely. A guest that clamps the way
639
+ * this one does always echoes a value inside the band, so the honest case is
640
+ * never altered.
641
+ */
642
+ export const STREAM_HEARTBEAT_MAX_ECHO_FACTOR = 4
643
+
644
+ /**
645
+ * The `healthz` feature string an agent advertises when it understands
646
+ * {@link StreamHeartbeat}. Informational for the host — the per-stream
647
+ * `ready` echo is what actually arms anything — and the honest answer for an
648
+ * operator reading a `healthz` reply to find out what an image can do.
649
+ */
650
+ export const STREAM_HEARTBEAT_FEATURE = 'stream-heartbeat'
166
651
 
167
652
  /** `/read-file` response. `content` is base64 on success. */
168
653
  export interface ReadFileResponse {
169
654
  readonly ok: boolean
170
655
  readonly content?: string
656
+ /** The WHOLE file's size, in both the whole-file and the ranged shape. */
171
657
  readonly sizeBytes?: number
172
658
  readonly encoding?: string
173
659
  readonly error?: string
660
+ /** Present only on a ranged reply: the first byte `content` starts at. */
661
+ readonly offset?: number
662
+ /**
663
+ * Present only on a ranged reply: how many bytes `content` decodes to.
664
+ * Below the requested `length` when the range ran past EOF, which is an
665
+ * answer rather than an error.
666
+ */
667
+ readonly bytesRead?: number
174
668
  }
175
669
 
176
670
  // ---------------------------------------------------------------------------
@@ -277,6 +771,20 @@ export function parseExecLine(line: string): ExecEvent | undefined {
277
771
  `agent emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
278
772
  )
279
773
  }
774
+ return parseExecEvent(parsed)
775
+ }
776
+
777
+ /**
778
+ * The same structural validation over an event that has ALREADY been
779
+ * parsed out of its frame.
780
+ *
781
+ * Split out of {@link parseExecLine} for the caller that reads the exec
782
+ * stream frame by frame rather than as NDJSON text — the Kubernetes
783
+ * detached execution, which needs the raw object to read the retained-log
784
+ * offsets off it. One validation, two entry points: an ordinary exec and a
785
+ * detached one must never disagree about what a valid frame is.
786
+ */
787
+ export function parseExecEvent(parsed: unknown): ExecEvent {
280
788
  if (!parsed || typeof parsed !== 'object') {
281
789
  throw new RemoteProtocolError('agent emitted an event without an object body')
282
790
  }