@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.
- package/CHANGELOG.md +924 -0
- package/README.md +369 -14
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +13 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts +169 -6
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +499 -85
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +12 -2
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +459 -8
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +136 -0
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +539 -6
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +1171 -24
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +1181 -13
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +2350 -31
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/identity.d.ts +193 -0
- package/dist/backends/kubernetes/identity.d.ts.map +1 -0
- package/dist/backends/kubernetes/identity.js +147 -0
- package/dist/backends/kubernetes/identity.js.map +1 -0
- package/dist/backends/kubernetes/index.d.ts +678 -33
- package/dist/backends/kubernetes/index.d.ts.map +1 -1
- package/dist/backends/kubernetes/index.js +1180 -95
- package/dist/backends/kubernetes/index.js.map +1 -1
- package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
- package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/ingress-policy.js +1050 -0
- package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
- package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
- package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
- package/dist/backends/kubernetes/k8s-client.js +359 -52
- package/dist/backends/kubernetes/k8s-client.js.map +1 -1
- package/dist/backends/kubernetes/lease.d.ts +40 -14
- package/dist/backends/kubernetes/lease.d.ts.map +1 -1
- package/dist/backends/kubernetes/lease.js +68 -18
- package/dist/backends/kubernetes/lease.js.map +1 -1
- package/dist/backends/kubernetes/objects.d.ts +423 -3
- package/dist/backends/kubernetes/objects.d.ts.map +1 -1
- package/dist/backends/kubernetes/objects.js +364 -2
- package/dist/backends/kubernetes/objects.js.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.js +375 -0
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
- package/dist/backends/kubernetes/rbac.d.ts +153 -0
- package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
- package/dist/backends/kubernetes/rbac.js +177 -0
- package/dist/backends/kubernetes/rbac.js.map +1 -0
- package/dist/backends/kubernetes/sandbox.d.ts +81 -14
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
- package/dist/backends/kubernetes/sandbox.js +149 -15
- package/dist/backends/kubernetes/sandbox.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +935 -9
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js +1958 -62
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/backends/kubernetes/workspace.d.ts +1149 -18
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
- package/dist/backends/kubernetes/workspace.js +2825 -186
- package/dist/backends/kubernetes/workspace.js.map +1 -1
- package/dist/backends/remote-execution-controller.d.ts +14 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -1
- package/dist/backends/remote-execution-controller.js.map +1 -1
- package/dist/index.d.ts +294 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -10
- package/dist/index.js.map +1 -1
- package/dist/testing/sandbox-conformance.d.ts +39 -5
- package/dist/testing/sandbox-conformance.d.ts.map +1 -1
- package/dist/testing/sandbox-conformance.js +436 -5
- package/dist/testing/sandbox-conformance.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/aci-standby-pool/index.ts +16 -1
- package/src/backends/docker/index.ts +617 -100
- package/src/backends/firecracker/index.ts +14 -2
- package/src/backends/firecracker/protocol.ts +514 -6
- package/src/backends/firecracker/transport.ts +1492 -40
- package/src/backends/kubernetes/egress-policy.ts +3334 -55
- package/src/backends/kubernetes/identity.ts +261 -0
- package/src/backends/kubernetes/index.ts +1785 -127
- package/src/backends/kubernetes/ingress-policy.ts +1344 -0
- package/src/backends/kubernetes/k8s-client.ts +444 -54
- package/src/backends/kubernetes/lease.ts +75 -19
- package/src/backends/kubernetes/objects.ts +626 -6
- package/src/backends/kubernetes/per-sandbox-policy.ts +497 -0
- package/src/backends/kubernetes/rbac.ts +192 -0
- package/src/backends/kubernetes/sandbox.ts +218 -20
- package/src/backends/kubernetes/transport.ts +2733 -124
- package/src/backends/kubernetes/workspace.ts +4476 -222
- package/src/backends/remote-execution-controller.ts +14 -0
- package/src/index.ts +668 -19
- 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
|
-
| {
|
|
80
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
|
139
|
-
| {
|
|
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
|
}
|