@namzu/sandbox 13.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/README.md +151 -0
  3. package/dist/backends/firecracker/protocol.d.ts +22 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js.map +1 -1
  6. package/dist/backends/firecracker/transport.d.ts +104 -9
  7. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  8. package/dist/backends/firecracker/transport.js +139 -13
  9. package/dist/backends/firecracker/transport.js.map +1 -1
  10. package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
  11. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  12. package/dist/backends/kubernetes/egress-policy.js +314 -0
  13. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  14. package/dist/backends/kubernetes/index.d.ts +374 -0
  15. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  16. package/dist/backends/kubernetes/index.js +671 -0
  17. package/dist/backends/kubernetes/index.js.map +1 -0
  18. package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
  19. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  20. package/dist/backends/kubernetes/k8s-client.js +246 -0
  21. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  22. package/dist/backends/kubernetes/lease.d.ts +119 -0
  23. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  24. package/dist/backends/kubernetes/lease.js +151 -0
  25. package/dist/backends/kubernetes/lease.js.map +1 -0
  26. package/dist/backends/kubernetes/objects.d.ts +282 -0
  27. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  28. package/dist/backends/kubernetes/objects.js +156 -0
  29. package/dist/backends/kubernetes/objects.js.map +1 -0
  30. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  31. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  32. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  33. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  34. package/dist/backends/kubernetes/sandbox.d.ts +123 -0
  35. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  36. package/dist/backends/kubernetes/sandbox.js +299 -0
  37. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  38. package/dist/backends/kubernetes/transport.d.ts +122 -0
  39. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  40. package/dist/backends/kubernetes/transport.js +197 -0
  41. package/dist/backends/kubernetes/transport.js.map +1 -0
  42. package/dist/backends/kubernetes/workspace.d.ts +381 -0
  43. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  44. package/dist/backends/kubernetes/workspace.js +1064 -0
  45. package/dist/backends/kubernetes/workspace.js.map +1 -0
  46. package/dist/index.d.ts +132 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +102 -34
  49. package/dist/index.js.map +1 -1
  50. package/dist/testing/sandbox-conformance.d.ts +193 -0
  51. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  52. package/dist/testing/sandbox-conformance.js +465 -0
  53. package/dist/testing/sandbox-conformance.js.map +1 -0
  54. package/package.json +5 -4
  55. package/src/backends/firecracker/protocol.ts +27 -0
  56. package/src/backends/firecracker/transport.ts +199 -28
  57. package/src/backends/kubernetes/egress-policy.ts +437 -0
  58. package/src/backends/kubernetes/index.ts +1012 -0
  59. package/src/backends/kubernetes/k8s-client.ts +352 -0
  60. package/src/backends/kubernetes/lease.ts +198 -0
  61. package/src/backends/kubernetes/objects.ts +363 -0
  62. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  63. package/src/backends/kubernetes/sandbox.ts +395 -0
  64. package/src/backends/kubernetes/transport.ts +286 -0
  65. package/src/backends/kubernetes/workspace.ts +1386 -0
  66. package/src/index.ts +257 -35
  67. package/src/testing/sandbox-conformance.ts +667 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The {@link Sandbox} a kubernetes acquire hands back: the SDK contract,
3
+ * served over the guest agent's TCP transport, with the lease that keeps the
4
+ * cluster from deleting the pod out from under a long run.
5
+ *
6
+ * Split out of `index.ts` because that file is about the CONTROL plane —
7
+ * claim, poll, address, release — and this one is about the DATA plane, and
8
+ * the two are read for different reasons.
9
+ *
10
+ * ## What it implements, and what it deliberately does not
11
+ *
12
+ * Implemented: `exec` (through the shared {@link RemoteExecutionController},
13
+ * so an `AbortSignal` terminates the guest process rather than abandoning
14
+ * the wait), `writeFile`, `readFile`, `listFiles`, `openTerminal`,
15
+ * `openTcpConnection`, `destroy`.
16
+ *
17
+ * Absent on purpose, because the SDK's contract says a backend that cannot
18
+ * honour an optional method must omit it rather than accept and ignore:
19
+ *
20
+ * - `setNetworkPolicy` — egress here is a `NetworkPolicy` attached to the
21
+ * pool's `SandboxTemplate`. There is no per-running-pod knob to turn, and
22
+ * a policy accepted and not applied is worse than one never offered: the
23
+ * caller stops looking.
24
+ * - `spawnDetached` — the guest agent has no op that starts a process and
25
+ * returns it running. A host asking for background jobs must be told no.
26
+ * - `walkFiles` — not in this batch. A host that requires bounded search
27
+ * refuses an absent method, which is the honest answer today.
28
+ *
29
+ * ## Terminals are owned
30
+ *
31
+ * `openTerminal` is only a compliant implementation if `destroy()` kills and
32
+ * awaits every terminal it returned, so open terminals are tracked and
33
+ * reaped before the object is released — the same thing the Firecracker
34
+ * backend does, for the same contract.
35
+ *
36
+ * ## Two terminal states, one `SandboxStatus`
37
+ *
38
+ * `SandboxStatus` has exactly four members and this change does not widen
39
+ * the SDK's union, so both ways a sandbox ends report `'destroyed'`. They
40
+ * are told apart by the error a later call throws:
41
+ * {@link KubernetesSandboxDestroyedError} (this host released it) and
42
+ * {@link KubernetesSandboxGoneError} (the cluster deleted it — the lease
43
+ * renewal found the object already gone).
44
+ */
45
+
46
+ import type {
47
+ OpenTerminalOptions,
48
+ Sandbox,
49
+ SandboxDestroyOptions,
50
+ SandboxEnvironment,
51
+ SandboxExecOptions,
52
+ SandboxExecResult,
53
+ SandboxFileEntry,
54
+ SandboxId,
55
+ SandboxStatus,
56
+ SandboxTcpConnectOptions,
57
+ SandboxTcpConnection,
58
+ TerminalSession,
59
+ } from '@namzu/sdk'
60
+
61
+ import { OperationDeadline } from '../readiness.js'
62
+ import {
63
+ RemoteCancellationUnknownError,
64
+ type SandboxRetirementObservation,
65
+ } from '../remote-execution-controller.js'
66
+ import { KubernetesLeaseRenewal } from './lease.js'
67
+ import type { KubernetesAgentTransport } from './transport.js'
68
+
69
+ /**
70
+ * How long a retirement triggered by an unconfirmed cancellation may spend
71
+ * deleting the object. Separate from every other clock on the path: the
72
+ * caller's own deadline has usually already expired by the time this runs,
73
+ * and reusing it would mean skipping teardown exactly when a command of
74
+ * unknown state is still out there.
75
+ */
76
+ const RETIREMENT_TIMEOUT_MS = 15_000
77
+
78
+ /** Thrown by any operation on a sandbox this host already destroyed. */
79
+ export class KubernetesSandboxDestroyedError extends Error {
80
+ override readonly name = 'KubernetesSandboxDestroyedError'
81
+
82
+ constructor(
83
+ readonly operation: string,
84
+ readonly sandboxName: string,
85
+ ) {
86
+ super(
87
+ `kubernetes sandbox ${sandboxName} has been destroyed; ${operation}() cannot be admitted. Acquire a new sandbox — a destroyed one's pod, Service and claim are deleted and its agent address no longer resolves.`,
88
+ )
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Thrown by any operation on a sandbox the CLUSTER removed while this
94
+ * handle still held it — the lease renewal PATCH came back 404/410. Distinct
95
+ * from {@link KubernetesSandboxDestroyedError} because nothing this host did
96
+ * caused it: the object expired, an operator deleted it, or the controller
97
+ * reaped it, and the actionable advice is different.
98
+ */
99
+ export class KubernetesSandboxGoneError extends Error {
100
+ override readonly name = 'KubernetesSandboxGoneError'
101
+
102
+ constructor(
103
+ readonly operation: string,
104
+ readonly sandboxName: string,
105
+ ) {
106
+ super(
107
+ `kubernetes sandbox ${sandboxName} no longer exists on the cluster; ${operation}() cannot be admitted. Its lease renewal found the object already deleted — it expired (spec.lifecycle.shutdownTime), an operator deleted it, or the controller reaped it. Nothing this handle can do brings it back; acquire a new sandbox.`,
108
+ )
109
+ }
110
+ }
111
+
112
+ interface KubernetesSandboxBaseOptions {
113
+ /** The cluster's own name for the bound sandbox — also the sandbox id. */
114
+ readonly name: string
115
+ readonly rootDir: string
116
+ readonly transport: KubernetesAgentTransport
117
+ /** DELETE the object this backend created. Already-gone counts as done. */
118
+ readonly release: (signal?: AbortSignal) => Promise<void>
119
+ }
120
+
121
+ /**
122
+ * The lease half of the options: a way to move the expiry, and the expiry it
123
+ * is moving. Required TOGETHER, because `renew` without `ttlSeconds` is a
124
+ * renewal loop with nothing to stamp — it would re-stamp `now + 0`, an
125
+ * expiry already in the past, and hand the object straight to the
126
+ * controller's reaper while reporting every tick a success. A pair is the
127
+ * only shape that cannot be half-configured.
128
+ */
129
+ interface KubernetesSandboxLeaseOptions {
130
+ /** PATCH the object's `shutdownTime` forward. See `lease.ts`. */
131
+ readonly renew: (shutdownTime: string, signal?: AbortSignal) => Promise<void>
132
+ /** The TTL acquire stamped; each renewal re-stamps exactly this much. */
133
+ readonly ttlSeconds: number
134
+ readonly onRenewalError?: (error: unknown) => void
135
+ /** Test seam: the renewal loop's base interval. Default: half the TTL. */
136
+ readonly leaseIntervalMs?: number
137
+ }
138
+
139
+ /**
140
+ * The other arm: an object that carries no expiry, so this handle runs no
141
+ * renewal loop at all — the persistent workspace (`workspace.ts`), which is
142
+ * explicitly managed and must outlive a host that stopped renewing. A no-op
143
+ * `renew` would be the wrong way to say that: it would leave a timer ticking
144
+ * forever to do nothing. The lease fields are typed `undefined` rather than
145
+ * omitted so that passing one of them here is a type error and not an
146
+ * excess-property check a spread would slip past.
147
+ */
148
+ interface KubernetesSandboxUnleasedOptions {
149
+ readonly renew?: undefined
150
+ readonly ttlSeconds?: undefined
151
+ readonly onRenewalError?: undefined
152
+ readonly leaseIntervalMs?: undefined
153
+ }
154
+
155
+ export type KubernetesSandboxOptions = KubernetesSandboxBaseOptions &
156
+ (KubernetesSandboxLeaseOptions | KubernetesSandboxUnleasedOptions)
157
+
158
+ function detectEnvironment(): SandboxEnvironment {
159
+ // The guest runs Linux; the enum describes the host-facing shape of the
160
+ // worker, not the isolation technology under it. Firecracker's guest
161
+ // reports the same for the same reason.
162
+ return 'linux-namespace'
163
+ }
164
+
165
+ /**
166
+ * What this backend hands back: the SDK contract, with the two optional
167
+ * members it DOES implement narrowed to present, so a caller that composes
168
+ * one — `workspace.ts` wraps this handle — does not have to re-check for a
169
+ * method this file always defines.
170
+ */
171
+ export type KubernetesSandboxHandle = Sandbox &
172
+ Required<Pick<Sandbox, 'openTerminal' | 'openTcpConnection'>>
173
+
174
+ /**
175
+ * Build the handle. It does NOT run the acquire-time privilege probe — that
176
+ * is `create()`'s job in `index.ts`, so that a refusal can destroy this
177
+ * object before any caller has a reference to it, and so this function stays
178
+ * usable by the workspace path that runs its own probe.
179
+ */
180
+ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): KubernetesSandboxHandle {
181
+ // The cluster owns this name. Preserving it verbatim as the sandbox id —
182
+ // as the Firecracker backend preserves its orchestrator's — means a log
183
+ // line carrying an id is also a `kubectl get sandbox` argument.
184
+ const id = options.name as SandboxId
185
+ const transport = options.transport
186
+
187
+ type Lifecycle = 'active' | 'retiring' | 'destroyed' | 'gone'
188
+ let lifecycle: Lifecycle = 'active'
189
+ let activeExecutions = 0
190
+ let teardownPromise: Promise<void> | undefined
191
+ let teardownComplete = false
192
+ let retirementPromise: Promise<SandboxRetirementObservation> | undefined
193
+ const terminals = new Set<TerminalSession>()
194
+
195
+ // No `renew` ⇒ no expiry to move ⇒ no loop. `stop()` on the undefined
196
+ // case is the caller's problem to not have, which is why every use below
197
+ // goes through `lease?.stop()`.
198
+ const renew = options.renew
199
+ const ttlSeconds = options.ttlSeconds
200
+ // The type above already pairs the two. This is the runtime half of the
201
+ // same rule, for a caller that reached here through a cast or from
202
+ // JavaScript: a lease stamping `now + 0` expires the moment it is written,
203
+ // and every tick would report success while the controller deleted the
204
+ // object underneath it.
205
+ if (renew !== undefined && (typeof ttlSeconds !== 'number' || ttlSeconds <= 0)) {
206
+ throw new Error(
207
+ `kubernetes: sandbox ${options.name} was given a lease renewal with ttlSeconds ${String(ttlSeconds)}. A renewal re-stamps shutdownTime as now + ttlSeconds, so a zero or absent TTL stamps an expiry that has already passed and the object is reaped while the loop reports every tick a success. Pass renew and a positive ttlSeconds together, or neither — an object with no expiry (a persistent workspace) runs no renewal loop.`,
208
+ )
209
+ }
210
+ // `ttlSeconds === undefined` is unreachable once `renew` is defined — the
211
+ // throw above saw to that — and is written out anyway because it is what
212
+ // narrows the field to a number for the constructor below.
213
+ const lease =
214
+ renew === undefined || ttlSeconds === undefined
215
+ ? undefined
216
+ : new KubernetesLeaseRenewal({
217
+ ttlSeconds,
218
+ renew,
219
+ onGone: () => {
220
+ // The object is gone; the pod behind the address went with it.
221
+ // Refuse every later call by name rather than let it dial into a
222
+ // connect timeout with nothing to explain it.
223
+ if (lifecycle === 'active') lifecycle = 'gone'
224
+ },
225
+ ...(options.onRenewalError !== undefined
226
+ ? { onRenewalError: options.onRenewalError }
227
+ : {}),
228
+ ...(options.leaseIntervalMs !== undefined ? { intervalMs: options.leaseIntervalMs } : {}),
229
+ })
230
+ lease?.start()
231
+
232
+ const assertAdmissible = (operation: string): void => {
233
+ if (lifecycle === 'active') return
234
+ if (lifecycle === 'gone') throw new KubernetesSandboxGoneError(operation, options.name)
235
+ throw new KubernetesSandboxDestroyedError(operation, options.name)
236
+ }
237
+
238
+ const teardown = (signal?: AbortSignal): Promise<void> => {
239
+ if (lifecycle === 'active') lifecycle = 'retiring'
240
+ lease?.stop()
241
+ if (teardownComplete) return Promise.resolve()
242
+ if (teardownPromise) return teardownPromise
243
+ const shared = options.release(signal).then(
244
+ () => {
245
+ teardownComplete = true
246
+ lifecycle = 'destroyed'
247
+ },
248
+ (error: unknown) => {
249
+ // A failed teardown must stay retryable; keeping the rejected
250
+ // promise would answer every later destroy() with the same
251
+ // stale failure.
252
+ if (teardownPromise === shared) teardownPromise = undefined
253
+ throw error
254
+ },
255
+ )
256
+ teardownPromise = shared
257
+ return shared
258
+ }
259
+
260
+ /**
261
+ * A command whose cancellation the guest could not confirm may still be
262
+ * running in that pod, so the pod stops being reusable. Retire it and
263
+ * report whether the retirement landed, on the error the caller is about
264
+ * to receive.
265
+ */
266
+ const retire = (): Promise<SandboxRetirementObservation> => {
267
+ if (lifecycle === 'active') lifecycle = 'retiring'
268
+ retirementPromise ??= new OperationDeadline(
269
+ RETIREMENT_TIMEOUT_MS,
270
+ `kubernetes sandbox ${options.name} retirement`,
271
+ )
272
+ .run(async (signal) => await teardown(signal))
273
+ .then(() => ({ accepted: true as const }))
274
+ .catch((error: unknown) => ({
275
+ accepted: false as const,
276
+ error: error instanceof Error ? error : new Error(String(error)),
277
+ }))
278
+ return retirementPromise
279
+ }
280
+
281
+ const runExecution = async <T>(operation: string, run: () => Promise<T>): Promise<T> => {
282
+ assertAdmissible(operation)
283
+ activeExecutions += 1
284
+ try {
285
+ return await run()
286
+ } catch (error) {
287
+ if (error instanceof RemoteCancellationUnknownError) {
288
+ error.retirement = await retire()
289
+ }
290
+ throw error
291
+ } finally {
292
+ activeExecutions = Math.max(0, activeExecutions - 1)
293
+ }
294
+ }
295
+
296
+ return {
297
+ id,
298
+ get status(): SandboxStatus {
299
+ // Four members, and no new one: a cluster-side disappearance and a
300
+ // host-side destroy both read as 'destroyed' here and are told
301
+ // apart by the error a later call throws.
302
+ if (lifecycle !== 'active') return 'destroyed'
303
+ return activeExecutions > 0 ? 'busy' : 'ready'
304
+ },
305
+ rootDir: options.rootDir,
306
+ environment: detectEnvironment(),
307
+
308
+ async exec(
309
+ command: string,
310
+ argv?: string[],
311
+ opts?: SandboxExecOptions,
312
+ ): Promise<SandboxExecResult> {
313
+ return await runExecution('exec', async () => await transport.exec(command, argv, opts))
314
+ },
315
+
316
+ /**
317
+ * Every `tcp` request dials a fresh connection, so its envelope is
318
+ * also that connection's first, not-yet-authenticated frame and is
319
+ * bounded by the guest's pre-auth frame ceiling (8 MiB by default).
320
+ * The transport checks that BEFORE dialing and throws
321
+ * `AgentPreauthFrameTooLargeError` naming the limit; it is passed
322
+ * through unwrapped so a caller can catch that class and chunk,
323
+ * rather than having to pattern-match a message.
324
+ */
325
+ async writeFile(path: string, content: string | Buffer): Promise<void> {
326
+ assertAdmissible('writeFile')
327
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
328
+ await transport.writeFile(path, buf)
329
+ },
330
+
331
+ async readFile(path: string): Promise<Buffer> {
332
+ assertAdmissible('readFile')
333
+ return await transport.readFile(path)
334
+ },
335
+
336
+ async openTerminal(terminalOptions: OpenTerminalOptions): Promise<TerminalSession> {
337
+ assertAdmissible('openTerminal')
338
+ const terminal = await transport.openTerminal(terminalOptions)
339
+ terminals.add(terminal)
340
+ void terminal.exited.finally(() => terminals.delete(terminal))
341
+ return terminal
342
+ },
343
+
344
+ async openTcpConnection(
345
+ connectOptions: SandboxTcpConnectOptions,
346
+ ): Promise<SandboxTcpConnection> {
347
+ assertAdmissible('openTcpConnection')
348
+ return await transport.openTcpConnection(connectOptions)
349
+ },
350
+
351
+ async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
352
+ return await runExecution('listFiles', async () => {
353
+ // Same wire as docker/aci/firecracker: `find -printf '%p\t%s\n'`,
354
+ // parsed line by line, with a non-zero exit (a root that does
355
+ // not exist yet) mapped to "empty" as the SDK contract asks.
356
+ const result = await transport.exec('find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'])
357
+ if (result.exitCode !== 0) return []
358
+ const entries: SandboxFileEntry[] = []
359
+ for (const rawLine of result.stdout.split('\n')) {
360
+ if (!rawLine) continue
361
+ const tab = rawLine.indexOf('\t')
362
+ if (tab < 0) continue
363
+ const filePath = rawLine.slice(0, tab)
364
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10)
365
+ if (!filePath || !Number.isFinite(size)) continue
366
+ entries.push({ path: filePath, size })
367
+ }
368
+ return entries
369
+ })
370
+ },
371
+
372
+ async destroy(destroyOptions?: SandboxDestroyOptions): Promise<void> {
373
+ if (retirementPromise) {
374
+ const observation = await retirementPromise
375
+ if (observation.accepted) return
376
+ retirementPromise = undefined
377
+ }
378
+ // A terminal owns an interactive process tree in this pod. Stop and
379
+ // await every one before releasing the object, so the SDK's
380
+ // ownership contract is real rather than best-effort bookkeeping.
381
+ lifecycle = lifecycle === 'active' ? 'retiring' : lifecycle
382
+ lease?.stop()
383
+ const activeTerminals = [...terminals]
384
+ for (const terminal of activeTerminals) terminal.kill('SIGKILL')
385
+ await Promise.allSettled(activeTerminals.map((terminal) => terminal.exited))
386
+ terminals.clear()
387
+ // Deleting the claim cascades to the sandbox it adopted through the
388
+ // ownerReferences the controller re-parents on bind, so one DELETE
389
+ // retires the pod, the Service and the object. An object that is
390
+ // already gone counts as released — that is the state DELETE was
391
+ // asking for.
392
+ await teardown(destroyOptions?.signal)
393
+ },
394
+ }
395
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Host-side transport for the kubernetes backend's guest agent: the same
3
+ * `agent/agent.cjs` the Firecracker tier bakes into its golden image,
4
+ * reached over the pod network instead of a host-local vsock/unix socket.
5
+ *
6
+ * Every byte on the wire goes through {@link VsockAgentTransport}
7
+ * constructed with a `{ kind: 'tcp' }` handle (`../firecracker/transport.js`):
8
+ * the SAME per-call dial (fresh connection, no cached socket, no cached
9
+ * IP — a Service-FQDN host re-resolves on every call, so a resumed pod's
10
+ * new address costs nothing extra), the SAME 8-hex-length framing, and
11
+ * the SAME token-in-envelope credential. Nothing about the wire is
12
+ * reimplemented here.
13
+ *
14
+ * This module supplies its OWN {@link RemoteExecutionAdapter} to the
15
+ * shared {@link RemoteExecutionController} — a third adapter against the
16
+ * same controller, alongside `http-worker-client.ts` and
17
+ * `firecracker/transport.ts`, each of which does this independently — so
18
+ * it can report where an `exec()` call's wall time actually goes: the
19
+ * dial(s), the reserve round trip, the execute round trip, and the small
20
+ * amount of local bookkeeping after the peer's own work is done
21
+ * ("drain"). That attribution is the whole point of `onTiming`: the
22
+ * Firecracker relay path has a known, unexplained fixed cost, and the
23
+ * sub-second warm-acquire target this backend is judged against must be
24
+ * measured, not guessed at.
25
+ */
26
+
27
+ import type {
28
+ OpenTerminalOptions,
29
+ SandboxExecOptions,
30
+ SandboxExecResult,
31
+ SandboxTcpConnectOptions,
32
+ SandboxTcpConnection,
33
+ TerminalSession,
34
+ } from '@namzu/sdk'
35
+
36
+ import type { ExecRequest } from '../firecracker/protocol.js'
37
+ import {
38
+ type AgentRequest,
39
+ type SandboxAgentHandle,
40
+ VsockAgentTransport,
41
+ type VsockTransportOptions,
42
+ } from '../firecracker/transport.js'
43
+ import {
44
+ type RemoteExecutionAdapter,
45
+ RemoteExecutionController,
46
+ } from '../remote-execution-controller.js'
47
+
48
+ /** The one {@link SandboxAgentHandle} arm this backend ever constructs. */
49
+ export type KubernetesAgentHandle = Extract<SandboxAgentHandle, { kind: 'tcp' }>
50
+
51
+ /**
52
+ * Thrown when the guest agent refuses a request because the handle's
53
+ * token does not match what the pod is bound to. Named distinctly from
54
+ * {@link RemoteProtocolError} so a caller can tell "this credential is
55
+ * wrong" (never going to succeed by retrying) from "the wire shape was
56
+ * unexpected".
57
+ */
58
+ export class KubernetesAgentUnauthorizedError extends Error {
59
+ constructor(
60
+ message = 'kubernetes tcp transport: the guest agent rejected this connection’s token (unauthorized)',
61
+ ) {
62
+ super(message)
63
+ this.name = 'KubernetesAgentUnauthorizedError'
64
+ }
65
+ }
66
+
67
+ /** True for the wire shape `agent.cjs` sends when a token is rejected. */
68
+ function isUnauthorized(response: unknown): boolean {
69
+ if (!response || typeof response !== 'object') return false
70
+ const value = response as { ok?: unknown; error?: unknown }
71
+ return value.ok === false && value.error === 'unauthorized'
72
+ }
73
+
74
+ /**
75
+ * One framed control request, with the guest's `unauthorized` refusal
76
+ * turned into {@link KubernetesAgentUnauthorizedError}.
77
+ *
78
+ * BOTH control requests go through here — `reserve-execution` and
79
+ * `cancel-execution` — because the two are read by the same caller for
80
+ * opposite reasons and neither may mistake a refusal for a blip. The
81
+ * cancel path is the sharper of the two: {@link RemoteExecutionController}
82
+ * RETRIES a failed cancellation for its whole confirm window and then
83
+ * reports the cancellation UNCONFIRMED, which retires the sandbox. A
84
+ * rejected token read as a transport failure would therefore spend that
85
+ * window re-sending a request that can never succeed, and end by
86
+ * describing a wrong credential as an ambiguous outcome.
87
+ */
88
+ async function requestChecked(
89
+ wire: VsockAgentTransport,
90
+ request: AgentRequest,
91
+ signal?: AbortSignal,
92
+ ): Promise<unknown> {
93
+ const response = await wire.request(request, signal)
94
+ if (isUnauthorized(response)) throw new KubernetesAgentUnauthorizedError()
95
+ return response
96
+ }
97
+
98
+ /**
99
+ * One `exec()` call's wall-time breakdown. Durations are NOT a partition
100
+ * of a single total — `reserveMs` and `executeMs` each include their OWN
101
+ * dial, which is also folded into `dialMs` — this is a diagnostic
102
+ * breakdown for attribution, not an accounting identity. Never carries a
103
+ * token, a command, its arguments, or any output.
104
+ */
105
+ export interface KubernetesTransportTiming {
106
+ /** Total time spent establishing TCP connections for this call. */
107
+ readonly dialMs: number
108
+ /** Time spent on the `reserve-execution` round trip (dial included). */
109
+ readonly reserveMs: number
110
+ /** Time spent on the `execute` round trip (dial included). */
111
+ readonly executeMs: number
112
+ /**
113
+ * Time between the execute round trip settling and `exec()` itself
114
+ * resolving — the shared controller's own post-execute bookkeeping
115
+ * (clearing timers, tearing down the observation race). Always small
116
+ * on the happy path; distinct from `executeMs` because it is spent
117
+ * locally, after the peer has nothing left to do.
118
+ */
119
+ readonly drainMs: number
120
+ }
121
+
122
+ export interface KubernetesTransportOptions extends VsockTransportOptions {
123
+ /**
124
+ * Fires once per completed `exec()` call (success or failure) with
125
+ * the four phase durations above. The payload is exactly those four
126
+ * numbers — never the token, never a command, argv, or output.
127
+ */
128
+ readonly onTiming?: (timing: KubernetesTransportTiming) => void
129
+ }
130
+
131
+ /**
132
+ * The kubernetes backend's dialable transport: a `tcp` handle plus a
133
+ * `RemoteExecutionAdapter` built from it, so `exec()` gets the same
134
+ * reserve-before-admission behaviour (cancellation, timeout ownership,
135
+ * "do not infer complete output on an ambiguous cancel") every other
136
+ * remote backend gets, while every network operation is delegated to
137
+ * {@link VsockAgentTransport} for the actual dial/frame/token work.
138
+ */
139
+ export class KubernetesAgentTransport {
140
+ private readonly handle: KubernetesAgentHandle
141
+ private readonly transportOptions: VsockTransportOptions
142
+ private readonly onTiming?: (timing: KubernetesTransportTiming) => void
143
+ /** Simple pass-through operations share one transport instance. */
144
+ private readonly wire: VsockAgentTransport
145
+
146
+ constructor(handle: KubernetesAgentHandle, options: KubernetesTransportOptions = {}) {
147
+ const { onTiming, ...transportOptions } = options
148
+ this.handle = handle
149
+ this.transportOptions = transportOptions
150
+ this.onTiming = onTiming
151
+ this.wire = new VsockAgentTransport(handle, transportOptions)
152
+ }
153
+
154
+ /** Readiness probe — never requires a token; see `protocol.ts`. */
155
+ async healthz(signal?: AbortSignal): Promise<boolean> {
156
+ return await this.wire.healthz(signal)
157
+ }
158
+
159
+ /** Poll until `healthz` succeeds or the timeout elapses. */
160
+ async waitForReady(
161
+ timeoutMs: number,
162
+ pollIntervalMs: number,
163
+ signal?: AbortSignal,
164
+ ): Promise<void> {
165
+ return await this.wire.waitForReady(timeoutMs, pollIntervalMs, signal)
166
+ }
167
+
168
+ /**
169
+ * The raw `reserve-execution` primitive, exposed directly (rather than
170
+ * only reachable as a side effect of `exec()`) so the reservation
171
+ * round trip is independently observable and testable against the
172
+ * real guest.
173
+ */
174
+ async reserve(signal?: AbortSignal): Promise<unknown> {
175
+ return await requestChecked(this.wire, { op: 'reserve-execution' }, signal)
176
+ }
177
+
178
+ /**
179
+ * The raw `cancel-execution` primitive, exposed for the same reason
180
+ * {@link reserve} is: it is a control request with its own refusal
181
+ * semantics, and proving those against the real guest should not require
182
+ * driving a whole cancelled `exec()` to reach it.
183
+ */
184
+ async cancel(executionId: string, signal?: AbortSignal): Promise<unknown> {
185
+ return await requestChecked(
186
+ this.wire,
187
+ { op: 'cancel-execution', body: { executionId } },
188
+ signal,
189
+ )
190
+ }
191
+
192
+ async writeFile(path: string, content: Buffer): Promise<void> {
193
+ return await this.wire.writeFile(path, content)
194
+ }
195
+
196
+ async readFile(path: string): Promise<Buffer> {
197
+ return await this.wire.readFile(path)
198
+ }
199
+
200
+ async openTerminal(options: OpenTerminalOptions): Promise<TerminalSession> {
201
+ return await this.wire.openTerminal(options)
202
+ }
203
+
204
+ async openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection> {
205
+ return await this.wire.openTcpConnection(options)
206
+ }
207
+
208
+ /**
209
+ * Run one command through a fresh, call-scoped adapter + controller.
210
+ * Fresh per call — not shared instance state — because
211
+ * {@link VsockAgentTransport} itself carries no cross-call connection
212
+ * state (it dials fresh every time), so building one per `exec()` is
213
+ * free and makes concurrent `exec()` calls on the same
214
+ * `KubernetesAgentTransport` correctly independent: each gets its own
215
+ * `onDial` closure and its own timing accumulator, with no shared
216
+ * mutable field for two in-flight calls to race on.
217
+ */
218
+ async exec(
219
+ command: string,
220
+ argv?: string[],
221
+ opts?: SandboxExecOptions,
222
+ ): Promise<SandboxExecResult> {
223
+ let dialMs = 0
224
+ let reserveMs = 0
225
+ let executeMs = 0
226
+ let executeSettledAt = 0
227
+
228
+ const timedWire = new VsockAgentTransport(this.handle, {
229
+ ...this.transportOptions,
230
+ onDial: (ms) => {
231
+ dialMs += ms
232
+ },
233
+ })
234
+
235
+ const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
236
+ label: 'kubernetes pod-network agent',
237
+ reserve: async (signal) => {
238
+ const startedAt = Date.now()
239
+ try {
240
+ return await requestChecked(timedWire, { op: 'reserve-execution' }, signal)
241
+ } finally {
242
+ reserveMs += Date.now() - startedAt
243
+ }
244
+ },
245
+ // Checked exactly like `reserve` — see `requestChecked`.
246
+ cancel: async (executionId, signal) =>
247
+ await requestChecked(timedWire, { op: 'cancel-execution', body: { executionId } }, signal),
248
+ execute: async (executionId, cmd, execArgv, execOpts, signal, context) => {
249
+ const startedAt = Date.now()
250
+ try {
251
+ return await timedWire.executeStreamed(
252
+ {
253
+ ...(executionId ? { executionId } : {}),
254
+ command: cmd,
255
+ args: execArgv ?? [],
256
+ ...(execOpts?.cwd !== undefined ? { cwd: execOpts.cwd } : {}),
257
+ ...(execOpts?.env !== undefined ? { env: execOpts.env } : {}),
258
+ ...(execOpts?.timeout !== undefined ? { timeoutMs: execOpts.timeout } : {}),
259
+ ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
260
+ ...(context?.maxOutputBytes !== undefined
261
+ ? { maxOutputBytes: context.maxOutputBytes }
262
+ : {}),
263
+ },
264
+ execOpts,
265
+ signal,
266
+ )
267
+ } finally {
268
+ executeMs += Date.now() - startedAt
269
+ executeSettledAt = Date.now()
270
+ }
271
+ },
272
+ }
273
+
274
+ const controller = new RemoteExecutionController(adapter)
275
+ try {
276
+ return await controller.exec(command, argv, opts)
277
+ } finally {
278
+ this.onTiming?.({
279
+ dialMs,
280
+ reserveMs,
281
+ executeMs,
282
+ drainMs: executeSettledAt > 0 ? Date.now() - executeSettledAt : 0,
283
+ })
284
+ }
285
+ }
286
+ }