@namzu/sandbox 16.0.0 → 17.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 (37) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +164 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts +22 -4
  4. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  5. package/dist/backends/aci-standby-pool/index.js +31 -7
  6. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  7. package/dist/backends/docker/index.d.ts +243 -24
  8. package/dist/backends/docker/index.d.ts.map +1 -1
  9. package/dist/backends/docker/index.js +717 -108
  10. package/dist/backends/docker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +156 -1
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +223 -29
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/http-worker-client.d.ts +64 -2
  16. package/dist/backends/http-worker-client.d.ts.map +1 -1
  17. package/dist/backends/http-worker-client.js +78 -7
  18. package/dist/backends/http-worker-client.js.map +1 -1
  19. package/dist/backends/kubernetes/transport.d.ts +7 -0
  20. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/transport.js.map +1 -1
  22. package/dist/egress/proxy.d.ts +47 -2
  23. package/dist/egress/proxy.d.ts.map +1 -1
  24. package/dist/egress/proxy.js +31 -7
  25. package/dist/egress/proxy.js.map +1 -1
  26. package/dist/index.d.ts +67 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +22 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +4 -4
  31. package/src/backends/aci-standby-pool/index.ts +37 -7
  32. package/src/backends/docker/index.ts +909 -126
  33. package/src/backends/firecracker/transport.ts +387 -36
  34. package/src/backends/http-worker-client.ts +89 -5
  35. package/src/backends/kubernetes/transport.ts +7 -0
  36. package/src/egress/proxy.ts +65 -8
  37. package/src/index.ts +89 -0
@@ -21,6 +21,68 @@ type WorkerEvent =
21
21
  }
22
22
  | { readonly type: 'error'; readonly error: string }
23
23
 
24
+ /**
25
+ * What every call to a worker's control API carries, if the worker has a
26
+ * credential at all.
27
+ *
28
+ * The worker (`packages/sandbox/worker/server.js`) requires
29
+ * `Authorization: Bearer <token>` on every route but `/healthz`, where the
30
+ * token is the per-instance `NAMZU_SANDBOX_TOKEN` it was started with. An
31
+ * absent token here sends no header, which is exactly right for a worker
32
+ * that has none: a loopback-bound dev worker, or a warm-pool worker whose
33
+ * profile authenticates nothing.
34
+ *
35
+ * A worker the host did NOT create is the case this cannot solve by
36
+ * itself. The token is minted by whoever starts the container and travels
37
+ * in its environment, so a warm pool has to be provisioned with the same
38
+ * token before a host can claim it — there is no channel back. Constructing
39
+ * this client with no token against such a worker fails at the first call
40
+ * with a `401`, not silently.
41
+ */
42
+ export function workerAuthorization(token: string | undefined): Record<string, string> {
43
+ return token ? { authorization: `Bearer ${token}` } : {}
44
+ }
45
+
46
+ /**
47
+ * The `401` a worker answers with, and what to do about it.
48
+ *
49
+ * Hung on every path that can be a caller's FIRST request to a worker —
50
+ * `reserve`, `execute`, and the container backend's direct `read-file` /
51
+ * `write-file` — because the bare status is the one failure whose cause is
52
+ * never visible from the sandbox the caller thinks it is talking to: the
53
+ * container is up, the port is open, and every command fails.
54
+ */
55
+ export const WORKER_UNAUTHORIZED_HINT =
56
+ 'The worker requires the per-instance token it was started with, as `Authorization: Bearer <token>`. If this host created the worker, the token it minted and the token the client sends have diverged. If it did not — a warm pool, a shared profile, a container someone else started — that worker must be provisioned with the token by whoever builds it: the worker has no channel back to hand one over.'
57
+
58
+ /**
59
+ * The same failure, for the one backend that can never fix it.
60
+ *
61
+ * The standby pool has no way to present a token. The claim API admits
62
+ * exactly one property override, and it is not `env`: it is a config map,
63
+ * and a config map reaches the container as a FILE MOUNT under
64
+ * `/mnt/configmap/<containername>/<key>`, not as an environment variable.
65
+ * This worker reads its credential from `process.env` once at startup, so
66
+ * a value delivered that way is not read at all — and Microsoft's own
67
+ * guidance is that config map values are not validated by the runtime and
68
+ * that a value affecting application security belongs in an environment
69
+ * variable instead. The channel exists; this credential is declined for
70
+ * it, at both ends. See `docs/sdk/container-sandbox-worker.md` for the
71
+ * answer, the reason, and the change that would close the gap.
72
+ *
73
+ * A token on the shared profile would make the worker boot and then refuse
74
+ * every call the backend makes — an unrecoverable loop that looks like a
75
+ * broken worker. So what works there is the address first and the
76
+ * worker-side escape second, and the hint says which of the two situations
77
+ * the caller is in.
78
+ *
79
+ * `HttpWorkerClient` uses {@link WORKER_UNAUTHORIZED_HINT} rather than this
80
+ * one: the client is shared by two backends, and a 401 it sees could be
81
+ * either situation.
82
+ */
83
+ export const STANDBY_POOL_UNAUTHORIZED_HINT =
84
+ 'The worker requires a per-instance token, and this backend cannot present one: the claim API admits a config map and nothing else, and a config map arrives as a file mount under /mnt/configmap, not as an environment variable this worker reads. Putting a token on the shared container group profile does NOT fix this — the worker boots, the backend sends no header, and every call 401s. What works today is the address and then the worker-side escape: claim the group with `subnetId` so it sits on a private network, and set `NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED=1` on that group profile, which is the only configuration in which a pooled worker starts and this backend can talk to it. Any other workload should run on a backend that can carry a credential. See `docs/sdk/container-sandbox-worker.md`.'
85
+
24
86
  function parseWorkerEvent(line: string): WorkerEvent {
25
87
  let parsed: unknown
26
88
  try {
@@ -57,6 +119,7 @@ function parseWorkerEvent(line: string): WorkerEvent {
57
119
 
58
120
  async function readExecution(
59
121
  baseUrl: string,
122
+ token: string | undefined,
60
123
  executionId: string | undefined,
61
124
  command: string,
62
125
  argv: string[] | undefined,
@@ -67,7 +130,7 @@ async function readExecution(
67
130
  try {
68
131
  response = await fetch(`${baseUrl}/execute`, {
69
132
  method: 'POST',
70
- headers: { 'content-type': 'application/json' },
133
+ headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
71
134
  signal: transportSignal,
72
135
  body: JSON.stringify({
73
136
  ...(executionId ? { executionId } : {}),
@@ -94,6 +157,12 @@ async function readExecution(
94
157
  'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
95
158
  )
96
159
  }
160
+ if (response.status === 401) {
161
+ throw withHint(
162
+ new Error(`execute failed: HTTP 401 ${await response.text()}`),
163
+ WORKER_UNAUTHORIZED_HINT,
164
+ )
165
+ }
97
166
  if (!response.ok || !response.body) {
98
167
  throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
99
168
  }
@@ -163,16 +232,24 @@ async function readExecution(
163
232
  /**
164
233
  * A per-sandbox HTTP worker client. Every command must reserve an identity
165
234
  * through the exact worker protocol before it can be admitted.
235
+ *
236
+ * `token` is the per-instance credential the worker was started with, and
237
+ * it rides on every request this client makes. It is optional because a
238
+ * worker that was never given one — a loopback dev worker — requires none;
239
+ * say what happens when it is missing from the wrong side rather than
240
+ * sending nothing quietly: the worker answers `401` and the failure names
241
+ * the reason.
166
242
  */
167
243
  export class HttpWorkerClient {
168
244
  private readonly controller: RemoteExecutionController
169
245
 
170
- constructor(baseUrl: string) {
246
+ constructor(baseUrl: string, token?: string) {
171
247
  const adapter: RemoteExecutionAdapter = {
172
248
  label: 'HTTP worker',
173
249
  reserve: async (signal) => {
174
250
  const response = await fetch(`${baseUrl}/executions/reserve`, {
175
251
  method: 'POST',
252
+ headers: workerAuthorization(token),
176
253
  signal,
177
254
  })
178
255
  if (response.status === 404) {
@@ -180,6 +257,12 @@ export class HttpWorkerClient {
180
257
  'The sandbox worker does not implement the required execution protocol. Rebuild the worker image or standby-pool profile from the same Namzu release before admitting commands.',
181
258
  )
182
259
  }
260
+ if (response.status === 401) {
261
+ throw withHint(
262
+ new Error(`execution reservation failed: HTTP 401 ${await response.text()}`),
263
+ WORKER_UNAUTHORIZED_HINT,
264
+ )
265
+ }
183
266
  if (!response.ok) {
184
267
  throw new Error(
185
268
  `execution reservation failed: HTTP ${response.status} ${await response.text()}`,
@@ -190,7 +273,7 @@ export class HttpWorkerClient {
190
273
  cancel: async (executionId, signal) => {
191
274
  const response = await fetch(`${baseUrl}/cancel`, {
192
275
  method: 'POST',
193
- headers: { 'content-type': 'application/json' },
276
+ headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
194
277
  body: JSON.stringify({ executionId }),
195
278
  signal,
196
279
  })
@@ -200,7 +283,7 @@ export class HttpWorkerClient {
200
283
  return await response.json()
201
284
  },
202
285
  execute: async (executionId, command, argv, opts, signal) =>
203
- await readExecution(baseUrl, executionId, command, argv, opts, signal),
286
+ await readExecution(baseUrl, token, executionId, command, argv, opts, signal),
204
287
  }
205
288
  this.controller = new RemoteExecutionController(adapter)
206
289
  }
@@ -220,6 +303,7 @@ export async function execViaHttpWorker(
220
303
  command: string,
221
304
  argv: string[] | undefined,
222
305
  opts: SandboxExecOptions | undefined,
306
+ token?: string,
223
307
  ): Promise<SandboxExecResult> {
224
- return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
308
+ return await new HttpWorkerClient(baseUrl, token).exec(command, argv, opts)
225
309
  }
@@ -530,6 +530,13 @@ export interface KubernetesTransportOptions
530
530
  * Fires once per completed `exec()` call (success or failure) with
531
531
  * the four phase durations above. The payload is exactly those four
532
532
  * numbers — never the token, never a command, argv, or output.
533
+ *
534
+ * THIS is the timing hook for this tier. The base options also carry
535
+ * `VsockTransportOptions.onExecTiming`, which is inherited here and is
536
+ * INERT: it belongs to the firecracker tier, whose `exec()` reports into
537
+ * it, while this transport drives the shared wire through
538
+ * `executeStreamed` and never calls that `exec()`. Setting it on a
539
+ * Kubernetes transport starts nothing.
533
540
  */
534
541
  readonly onTiming?: (timing: KubernetesTransportTiming) => void
535
542
  /**
@@ -71,13 +71,46 @@ export interface EgressProxyOptions {
71
71
  * reach — which is the hole this screen exists to close.
72
72
  */
73
73
  readonly allowInwardFor?: readonly string[]
74
+ /**
75
+ * Address this proxy listens on. Default `127.0.0.1`.
76
+ *
77
+ * The default is loopback because a proxy holding real credentials and
78
+ * bound to every interface is reachable by anything on the network, which
79
+ * is the opposite of what it exists for. There is exactly one deployment
80
+ * where that reasoning flips, and it is the whole reason this is
81
+ * configurable: the docker tier runs the proxy as a container of its own
82
+ * (`egress-proxy/server.mjs`), the sandbox is attached to an `--internal`
83
+ * network with no route out, and this container is the only way its traffic
84
+ * reaches the internet. There, `0.0.0.0` inside the proxy container is not
85
+ * "every interface on the host" — the container IS the boundary — and what
86
+ * else the sandbox can reach on that network is whatever other containers a
87
+ * host attached to it, which `docs/sdk/sandbox-egress.md` states.
88
+ */
89
+ readonly bindHost?: string
90
+ /**
91
+ * Names that denote THIS proxy, for the loop guard in `listen`.
92
+ *
93
+ * `isSelf` refuses a request whose target is the proxy itself, because
94
+ * forwarding it makes the proxy call itself until the process runs out of
95
+ * sockets — a failure that arrives as a hang rather than as an error. On
96
+ * loopback the target is one of `127.0.0.1`, `localhost`, `::1` and the
97
+ * check is closed by construction. Bound to another address it is not:
98
+ * anything that names this proxy by a name it answers to — its container
99
+ * name or network alias, say — would walk straight into that loop. So
100
+ * every such name is listed here by whoever knows it.
101
+ */
102
+ readonly selfNames?: readonly string[]
74
103
  /** Injected in tests. Defaults to the platform resolver. */
75
104
  readonly resolveAddresses?: ScreeningLookupOptions['resolve']
76
105
  }
77
106
 
78
107
  export interface RunningEgressProxy {
79
108
  readonly port: number
80
- /** `http://127.0.0.1:<port>` — what a sandbox sets `HTTP_PROXY` to. */
109
+ /**
110
+ * `http://<the address this proxy bound>:<port>` — what a sandbox sets
111
+ * `HTTP_PROXY` to. Loopback by default; the address is whatever
112
+ * {@link EgressProxyOptions.bindHost} said.
113
+ */
81
114
  readonly url: string
82
115
  /** Swap the allowlist on a live proxy. See `setNetworkPolicy`. */
83
116
  setAllowedHosts(resolve: () => Promise<readonly string[]>): void
@@ -86,6 +119,12 @@ export interface RunningEgressProxy {
86
119
 
87
120
  const DENIED_STATUS = 403
88
121
 
122
+ /** The default bind address. See {@link EgressProxyOptions.bindHost}. */
123
+ const LOOPBACK_BIND_HOST = '127.0.0.1'
124
+
125
+ /** Names that mean "this proxy" on loopback, whatever it bound to. */
126
+ const LOOPBACK_SELF_NAMES: readonly string[] = ['127.0.0.1', 'localhost', '::1']
127
+
89
128
  export class EgressProxy {
90
129
  private resolveAllowed: () => Promise<readonly string[]>
91
130
  private readonly credentials: readonly BrokeredCredential[]
@@ -101,6 +140,8 @@ export class EgressProxy {
101
140
  */
102
141
  private readonly lookup: ReturnType<typeof createScreeningLookup>
103
142
  private readonly inwardAllowed: readonly string[]
143
+ private readonly bindHost: string
144
+ private readonly selfNames: readonly string[]
104
145
 
105
146
  constructor(options: EgressProxyOptions) {
106
147
  this.resolveAllowed = options.allowedHosts
@@ -108,6 +149,8 @@ export class EgressProxy {
108
149
  this.upgradeToHttps = options.upgradeToHttps ?? true
109
150
  this.onDenied = options.onDenied
110
151
  this.inwardAllowed = options.allowInwardFor ?? []
152
+ this.bindHost = options.bindHost ?? LOOPBACK_BIND_HOST
153
+ this.selfNames = options.selfNames ?? []
111
154
  this.lookup = createScreeningLookup(
112
155
  {
113
156
  ...(options.allowInwardFor ? { allowInwardFor: options.allowInwardFor } : {}),
@@ -131,10 +174,12 @@ export class EgressProxy {
131
174
  })
132
175
  await new Promise<void>((resolve, reject) => {
133
176
  server.once('error', reject)
134
- // Loopback only. A proxy that holds real credentials and binds
135
- // every interface is reachable by anything on the network, which
136
- // is the opposite of what it exists for.
137
- server.listen(port, '127.0.0.1', () => {
177
+ // Loopback by default. A proxy that holds real credentials and binds
178
+ // every interface is reachable by anything on the network, which is
179
+ // the opposite of what it exists for — see
180
+ // {@link EgressProxyOptions.bindHost} for the one deployment where
181
+ // the container itself is the boundary and this is not that.
182
+ server.listen(port, this.bindHost, () => {
138
183
  server.off('error', reject)
139
184
  resolve()
140
185
  })
@@ -153,7 +198,7 @@ export class EgressProxy {
153
198
 
154
199
  return {
155
200
  port: boundPort,
156
- url: `http://127.0.0.1:${boundPort}`,
201
+ url: `http://${this.bindHost}:${boundPort}`,
157
202
  setAllowedHosts: (resolve) => {
158
203
  this.resolveAllowed = resolve
159
204
  },
@@ -363,10 +408,22 @@ export class EgressProxy {
363
408
  })
364
409
  }
365
410
 
366
- /** Whether a target names this proxy. See the loop guard in `listen`. */
411
+ /**
412
+ * Whether a target names this proxy. See the loop guard in `listen`.
413
+ *
414
+ * The loopback spellings are always this proxy — a client inside the same
415
+ * network namespace reaches it at one of them whatever it bound to, and
416
+ * the bound address when that address is a literal is one more. Anything
417
+ * else has to be named by the caller, through
418
+ * {@link EgressProxyOptions.selfNames}: this process cannot know which
419
+ * names resolve to it on the network it is attached to, and a guard that
420
+ * guessed would either miss the loop or refuse a legitimate target.
421
+ */
367
422
  private isSelf(host: string, port?: number): boolean {
368
423
  if (port === undefined || port !== this.selfPort) return false
369
- return host === '127.0.0.1' || host === 'localhost' || host === '::1'
424
+ if (host === this.bindHost) return true
425
+ if (LOOPBACK_SELF_NAMES.includes(host)) return true
426
+ return this.selfNames.includes(host)
370
427
  }
371
428
 
372
429
  private credentialFor(host: string): BrokeredCredential | undefined {
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  import { buildAciStandbyPoolBackend } from './backends/aci-standby-pool/index.js'
63
63
  import { buildDockerBackend, resolveLayout } from './backends/docker/index.js'
64
64
  import { buildFirecrackerBackend } from './backends/firecracker/index.js'
65
+ import type { FirecrackerTransportTiming } from './backends/firecracker/transport.js'
65
66
  import type { KubernetesEgressConfig } from './backends/kubernetes/egress-policy.js'
66
67
  import {
67
68
  type KubernetesAgentAddressMode,
@@ -126,6 +127,7 @@ export {
126
127
  AgentWriteFileTooLargeError,
127
128
  DEFAULT_MAX_WRITE_FILE_BYTES,
128
129
  FIRECRACKER_AGENT_PROTOCOL_VERSION,
130
+ type FirecrackerTransportTiming,
129
131
  GUEST_FRAME_LIMIT_BYTES,
130
132
  type SandboxAgentHandle,
131
133
  TCP_PREAUTH_FRAME_LIMIT_BYTES,
@@ -659,8 +661,51 @@ export interface ContainerBackendConfig {
659
661
  *
660
662
  * Egress from the sandbox is governed separately by `EgressPolicy`,
661
663
  * which is checked against this network rather than trusted.
664
+ *
665
+ * An egress policy of `static` or `resolver` — a host allowlist —
666
+ * additionally REQUIRES this network to be `--internal`, and requires
667
+ * `hostReachability: 'container-network'`. The allowlist is enforced by
668
+ * the egress proxy running as a sibling container on this network: the
669
+ * sandbox is attached to this network alone, and an internal network has
670
+ * no route off it, so the only way the sandbox's traffic reaches the
671
+ * internet is through that container. It is a container on a subnet like
672
+ * any other there, so what else a host attaches to this network — a second
673
+ * sandbox, and that sandbox's proxy — is reachable from this one too; on a
674
+ * network with a route out the allowlist is a proxy environment variable a
675
+ * workload may decline to read, and `create()` refuses rather than
676
+ * reporting a boundary that is not there.
662
677
  */
663
678
  readonly network?: 'none' | 'bridge' | string
679
+ /**
680
+ * Image the egress proxy runs as, when the policy needs one.
681
+ *
682
+ * Required for a `static` or `resolver` policy, refused without it. The
683
+ * image is `packages/sandbox/egress-proxy/Dockerfile`:
684
+ *
685
+ * pnpm --filter @namzu/sandbox build
686
+ * docker build -f packages/sandbox/egress-proxy/Dockerfile \
687
+ * -t <tag> packages/sandbox
688
+ *
689
+ * It is a second image rather than the sandbox's own because the
690
+ * sandbox image is a string this backend cannot read: there is no way to
691
+ * know whether it contains the proxy module, and the bind-mount
692
+ * alternative fails on exactly the remote-daemon deployment
693
+ * `hostReachability: 'container-network'` exists for. `deny-all` and
694
+ * `allow-all` need no image.
695
+ */
696
+ readonly egressProxyImage?: string
697
+ /**
698
+ * Network the egress proxy joins for its route to the internet. Default
699
+ * `'bridge'`, docker's own default bridge.
700
+ *
701
+ * The proxy is dual-homed: it sits on the internal network the sandbox
702
+ * is on, and on this one, which is how it reaches the world. Name a
703
+ * dedicated network when the daemon is shared, because anything else
704
+ * attached to this one can reach the proxy — and this proxy enforces its
705
+ * allowlist for whoever asks and stamps brokered credentials on what it
706
+ * forwards.
707
+ */
708
+ readonly egressProxyUpstreamNetwork?: string
664
709
  /**
665
710
  * Maximum time spent waiting for the container worker's `/healthz`
666
711
  * readiness probe. Must be a positive integer within Node's timer range.
@@ -796,6 +841,28 @@ export type MicroVMBackendConfig = {
796
841
  readonly readyTimeoutMs?: number
797
842
  /** Delay between guest-agent health probes. Default 250ms. */
798
843
  readonly readyPollIntervalMs?: number
844
+ /**
845
+ * Fires once per completed `exec()` on this provider's sandboxes with
846
+ * that call's wall-time breakdown — the reserve round trip, the execute
847
+ * round trip, the dials inside them, the first reply frame, the
848
+ * terminator frame and the peer's own close. See
849
+ * {@link FirecrackerTransportTiming} for what each number is and is not,
850
+ * and {@link VsockTransportOptions.onExecTiming} for why the field is
851
+ * named this rather than the kubernetes tier's `onTiming`.
852
+ *
853
+ * Opt-in and additive, with no default: a host that sets nothing sends,
854
+ * receives and waits for exactly what it did before. The intended use is
855
+ * attribution — a relay or a guest that adds a fixed cost to every call
856
+ * moves a named phase, and one that adds none leaves the numbers at the
857
+ * cost of a command's own runtime.
858
+ *
859
+ * It is an OBSERVER, not a control: it cannot change a command's result,
860
+ * it is called after the call has settled, and a listener that throws is
861
+ * that listener's problem. The payload is durations only — never the
862
+ * agent token, a command, its arguments, or any output — so a host may
863
+ * log it without leaking what the sandbox ran.
864
+ */
865
+ readonly onExecTiming?: (timing: FirecrackerTransportTiming) => void
799
866
  /**
800
867
  * NETWORK-mode mTLS client material (ses_051 P4 client-proxy
801
868
  * bridge). When present, the orchestrator returns an `mtls` agent
@@ -1317,6 +1384,12 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
1317
1384
  : {}),
1318
1385
  ...(backend.network !== undefined ? { network: backend.network } : {}),
1319
1386
  ...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
1387
+ ...(backend.egressProxyImage !== undefined
1388
+ ? { egressProxyImage: backend.egressProxyImage }
1389
+ : {}),
1390
+ ...(backend.egressProxyUpstreamNetwork !== undefined
1391
+ ? { egressProxyUpstreamNetwork: backend.egressProxyUpstreamNetwork }
1392
+ : {}),
1320
1393
  ...(backend.labels !== undefined ? { labels: backend.labels } : {}),
1321
1394
  ...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
1322
1395
  ...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
@@ -1342,6 +1415,12 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
1342
1415
  : {}),
1343
1416
  ...(backend.network !== undefined ? { network: backend.network } : {}),
1344
1417
  ...(backend.allowInwardFor !== undefined ? { allowInwardFor: backend.allowInwardFor } : {}),
1418
+ ...(backend.egressProxyImage !== undefined
1419
+ ? { egressProxyImage: backend.egressProxyImage }
1420
+ : {}),
1421
+ ...(backend.egressProxyUpstreamNetwork !== undefined
1422
+ ? { egressProxyUpstreamNetwork: backend.egressProxyUpstreamNetwork }
1423
+ : {}),
1345
1424
  ...(backend.labels !== undefined ? { labels: backend.labels } : {}),
1346
1425
  ...(backend.cpuLimit !== undefined ? { cpuLimit: backend.cpuLimit } : {}),
1347
1426
  ...(backend.readOnlyRootfs !== undefined ? { readOnlyRootfs: backend.readOnlyRootfs } : {}),
@@ -1372,6 +1451,16 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
1372
1451
  ...(backend.readyPollIntervalMs !== undefined
1373
1452
  ? { readyPollIntervalMs: backend.readyPollIntervalMs }
1374
1453
  : {}),
1454
+ // Forwarded into the transport's own options rather than onto the
1455
+ // backend's config: the backend reads nothing here, and the phase
1456
+ // numbers are the transport's to report — see
1457
+ // `VsockTransportOptions.onExecTiming`, which is where a host that
1458
+ // builds a `VsockAgentTransport` directly sets it too. Absent ⇒
1459
+ // this spread adds no key at all and the transport is built with
1460
+ // the options it was always built with.
1461
+ ...(backend.onExecTiming !== undefined
1462
+ ? { transport: { onExecTiming: backend.onExecTiming } }
1463
+ : {}),
1375
1464
  ...(backend.mtls !== undefined ? { mtls: backend.mtls } : {}),
1376
1465
  ...(backend.controlPlaneMtls !== undefined
1377
1466
  ? { controlPlaneMtls: backend.controlPlaneMtls }