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