@namzu/sandbox 1.1.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 (69) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE.md +110 -0
  3. package/README.md +148 -0
  4. package/dist/backends/aci-standby-pool/index.d.ts +104 -0
  5. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
  6. package/dist/backends/aci-standby-pool/index.js +425 -0
  7. package/dist/backends/aci-standby-pool/index.js.map +1 -0
  8. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
  9. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
  10. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
  11. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
  12. package/dist/backends/docker/index.d.ts +118 -0
  13. package/dist/backends/docker/index.d.ts.map +1 -0
  14. package/dist/backends/docker/index.js +645 -0
  15. package/dist/backends/docker/index.js.map +1 -0
  16. package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
  17. package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
  18. package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
  19. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
  20. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
  21. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
  22. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
  23. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
  24. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
  25. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
  26. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
  27. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
  28. package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
  29. package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
  30. package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
  31. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
  32. package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
  33. package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
  34. package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
  35. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
  36. package/dist/backends/firecracker/index.d.ts +124 -0
  37. package/dist/backends/firecracker/index.d.ts.map +1 -0
  38. package/dist/backends/firecracker/index.js +334 -0
  39. package/dist/backends/firecracker/index.js.map +1 -0
  40. package/dist/backends/firecracker/protocol.d.ts +132 -0
  41. package/dist/backends/firecracker/protocol.d.ts.map +1 -0
  42. package/dist/backends/firecracker/protocol.js +112 -0
  43. package/dist/backends/firecracker/protocol.js.map +1 -0
  44. package/dist/backends/firecracker/transport.d.ts +251 -0
  45. package/dist/backends/firecracker/transport.d.ts.map +1 -0
  46. package/dist/backends/firecracker/transport.js +524 -0
  47. package/dist/backends/firecracker/transport.js.map +1 -0
  48. package/dist/index.d.ts +611 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +376 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/index.test.d.ts +28 -0
  53. package/dist/index.test.d.ts.map +1 -0
  54. package/dist/index.test.js +670 -0
  55. package/dist/index.test.js.map +1 -0
  56. package/package.json +54 -0
  57. package/src/backends/aci-standby-pool/index.ts +602 -0
  58. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
  59. package/src/backends/docker/index.ts +826 -0
  60. package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
  61. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
  62. package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
  63. package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
  64. package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
  65. package/src/backends/firecracker/index.ts +528 -0
  66. package/src/backends/firecracker/protocol.ts +191 -0
  67. package/src/backends/firecracker/transport.ts +667 -0
  68. package/src/index.test.ts +731 -0
  69. package/src/index.ts +930 -0
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Azure Container Instances Standby Pool backend.
3
+ *
4
+ * Sibling of `docker/` — same {@link SandboxBackend} surface, same
5
+ * worker-HTTP contract, different shipping mechanism. Where docker
6
+ * `docker run`s a container on a local daemon, this backend PUTs an
7
+ * `Microsoft.ContainerInstance/containerGroups` resource that
8
+ * references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
9
+ * resource — Azure hands back a warm ACI in ~1.5 s instead of a
10
+ * cold 10-30 s spawn. Refill is automatic per the pool's
11
+ * `refillPolicy`.
12
+ *
13
+ * Workspace shipping:
14
+ * - Docker uses bind-mounts (`hostDir` source variant).
15
+ * - ACI has no host filesystem; this backend ONLY accepts
16
+ * `azureFileShare` source variants and translates them to ACI's
17
+ * `properties.volumes[] + container.properties.volumeMounts[]`
18
+ * shape. The Vandal-side (or any host) provisions per-task
19
+ * shares upstream and hands them in via the layout.
20
+ *
21
+ * Authentication:
22
+ * - Caller supplies a `getArmToken()` async function. Sandbox
23
+ * keeps zero auth dependencies (`@azure/identity` etc.) — the
24
+ * consumer's runtime owns Managed-Identity / AzureCLI / federated
25
+ * credential picking. Token is fetched on every ARM call so a
26
+ * short-lived token survives a long-running sandbox.
27
+ *
28
+ * Trust model:
29
+ * - ACI runs the container in a Microsoft-owned isolation host;
30
+ * inside, the worker is a non-root user (image's `USER namzu`).
31
+ * - The container group can be subnet-injected (no public IP) when
32
+ * `subnetId` is supplied. Without it the IP is public — fine for
33
+ * benchmarking, NOT acceptable for production. Caller decides.
34
+ * - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
35
+ * a pool-side knob, not a backend knob — the backend never
36
+ * chooses; it just PUTs against whichever pool the caller named.
37
+ */
38
+
39
+ import type {
40
+ ContainerSandboxMountSource,
41
+ ResolvedContainerSandboxLayout,
42
+ Sandbox,
43
+ SandboxEnvironment,
44
+ SandboxExecOptions,
45
+ SandboxExecResult,
46
+ SandboxFileEntry,
47
+ SandboxId,
48
+ SandboxStatus,
49
+ } from '@namzu/sdk'
50
+
51
+ import type { SandboxBackend, SandboxBackendOptions } from '../../index.js'
52
+
53
+ /**
54
+ * Authentication callback. Caller returns a fresh Azure Resource
55
+ * Manager bearer token (audience `https://management.azure.com/`).
56
+ * Backend invokes this on every ARM call so a long-running sandbox
57
+ * survives token rotation.
58
+ */
59
+ export type ArmTokenProvider = () => Promise<string>
60
+
61
+ export interface ACIStandbyPoolBackendInternalConfig {
62
+ readonly subscriptionId: string
63
+ readonly resourceGroup: string
64
+ readonly location: string
65
+ /**
66
+ * Fully-qualified resource ID of the Standby Pool to claim from.
67
+ * Example:
68
+ * /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.StandbyPool/standbyContainerGroupPools/<pool>
69
+ */
70
+ readonly standbyPoolResourceId: string
71
+ /**
72
+ * Fully-qualified resource ID of the Container Group Profile the
73
+ * pool was created against.
74
+ */
75
+ readonly containerGroupProfileResourceId: string
76
+ /**
77
+ * Container Group Profile revision the pool's warm instances were
78
+ * built from. Defaults to 1.
79
+ */
80
+ readonly containerGroupProfileRevision?: number
81
+ /**
82
+ * Pre-resolved layout. The backend requires every mount source to
83
+ * be `azureFileShare`; any other variant throws.
84
+ */
85
+ readonly layout: ResolvedContainerSandboxLayout
86
+ /**
87
+ * Authentication callback (see {@link ArmTokenProvider}).
88
+ */
89
+ readonly getArmToken: ArmTokenProvider
90
+ /**
91
+ * Optional subnet to inject the container group into (no public IP).
92
+ * Strongly recommended for production. When omitted, ACI assigns a
93
+ * public IP — fine for benchmarks, attack-surface for prod.
94
+ */
95
+ readonly subnetId?: string
96
+ readonly readyPollIntervalMs?: number
97
+ readonly readyTimeoutMs?: number
98
+ /**
99
+ * Worker HTTP port (matches the image's listening port). Default 2024.
100
+ */
101
+ readonly workerPort?: number
102
+ readonly armApiVersion?: string
103
+ /**
104
+ * Prefix for the ACI container group name and the inner worker
105
+ * container. Defaults to a Namzu-branded label; consumers (e.g.
106
+ * Vandal) override via env / config to brand their own
107
+ * deployments. The runtime appends a sandbox id suffix; the
108
+ * combined name is sanitised to ARM's allowed character set.
109
+ */
110
+ readonly containerNamePrefix?: string
111
+ }
112
+
113
+ const DEFAULT_READY_POLL_MS = 500
114
+ const DEFAULT_READY_TIMEOUT_MS = 60_000
115
+ const DEFAULT_WORKER_PORT = 2024
116
+ const DEFAULT_ARM_API_VERSION = '2024-05-01-preview'
117
+ const ARM_BASE = 'https://management.azure.com'
118
+ const DEFAULT_CONTAINER_NAME_PREFIX = 'namzu-task'
119
+
120
+ /**
121
+ * Build a {@link SandboxBackend} backed by Azure Container Instances
122
+ * Standby Pool. Construction is synchronous; the ARM PUT happens on
123
+ * the first `create()`.
124
+ */
125
+ export function buildAciStandbyPoolBackend(
126
+ config: ACIStandbyPoolBackendInternalConfig,
127
+ ): SandboxBackend {
128
+ return {
129
+ tier: 'container',
130
+ name: 'aci-standby-pool',
131
+ async create(options: SandboxBackendOptions): Promise<Sandbox> {
132
+ return await spawnAciSandbox(config, options)
133
+ },
134
+ }
135
+ }
136
+
137
+ interface AzureFileShareSource {
138
+ readonly storageAccountName: string
139
+ readonly shareName: string
140
+ readonly storageAccountKey: string
141
+ }
142
+
143
+ /**
144
+ * Interpret one mount source. ACI accepts two source variants:
145
+ * - `azureFileShare` → emit an ACI `volume.azureFile` + matching `volumeMount`.
146
+ * - `inImage` → emit NOTHING; the container's own filesystem carries the path.
147
+ *
148
+ * Standby-Pool-warm flows MUST use `inImage` because Standby Pool's
149
+ * claim-time API rejects every `volumes[]` override (the volume set
150
+ * is profile-baked across all warm instances). Cold-spawn ACI flows
151
+ * can use either.
152
+ *
153
+ * The `hostDir` variant is for docker backends and is rejected here.
154
+ */
155
+ function interpretSource(
156
+ source: ContainerSandboxMountSource,
157
+ label: string,
158
+ ): { kind: 'azureFile'; source: AzureFileShareSource } | { kind: 'inImage' } {
159
+ if (source.type === 'azureFileShare') {
160
+ return {
161
+ kind: 'azureFile',
162
+ source: {
163
+ storageAccountName: source.storageAccountName,
164
+ shareName: source.shareName,
165
+ storageAccountKey: source.storageAccountKey,
166
+ },
167
+ }
168
+ }
169
+ if (source.type === 'inImage') {
170
+ return { kind: 'inImage' }
171
+ }
172
+ throw new Error(
173
+ `aci-standby-pool backend cannot consume mount source type ${JSON.stringify(source.type)} for ${label}; expected 'azureFileShare' or 'inImage'. The hostDir variant belongs to the docker backend.`,
174
+ )
175
+ }
176
+
177
+ interface BuiltVolumes {
178
+ readonly volumes: Array<{
179
+ name: string
180
+ azureFile: {
181
+ shareName: string
182
+ storageAccountName: string
183
+ storageAccountKey: string
184
+ readOnly: boolean
185
+ }
186
+ }>
187
+ readonly volumeMounts: Array<{
188
+ name: string
189
+ mountPath: string
190
+ readOnly: boolean
191
+ }>
192
+ }
193
+
194
+ function buildAzureFileVolumesFromLayout(layout: ResolvedContainerSandboxLayout): BuiltVolumes {
195
+ const volumes: BuiltVolumes['volumes'] = []
196
+ const volumeMounts: BuiltVolumes['volumeMounts'] = []
197
+ let counter = 0
198
+
199
+ function add(
200
+ mount: { readonly source: ContainerSandboxMountSource; readonly containerPath: string },
201
+ label: string,
202
+ readOnly: boolean,
203
+ ): void {
204
+ const interpreted = interpretSource(mount.source, label)
205
+ // `inImage` is a no-op — the image's own filesystem provides
206
+ // the path. The Standby-Pool-warm flow lives on this branch.
207
+ if (interpreted.kind === 'inImage') return
208
+ const source = interpreted.source
209
+ const name = `vol-${label}-${counter++}`
210
+ volumes.push({
211
+ name,
212
+ azureFile: {
213
+ shareName: source.shareName,
214
+ storageAccountName: source.storageAccountName,
215
+ storageAccountKey: source.storageAccountKey,
216
+ readOnly,
217
+ },
218
+ })
219
+ volumeMounts.push({
220
+ name,
221
+ mountPath: mount.containerPath,
222
+ readOnly,
223
+ })
224
+ }
225
+
226
+ add(layout.outputs, 'outputs', false)
227
+ if (layout.uploads) add(layout.uploads, 'uploads', true)
228
+ if (layout.scratch) add(layout.scratch, 'scratch', false)
229
+ if (layout.toolResults) add(layout.toolResults, 'toolResults', true)
230
+ if (layout.transcripts) add(layout.transcripts, 'transcripts', true)
231
+ if (layout.skills) {
232
+ for (const skill of layout.skills) {
233
+ add({ source: skill.source, containerPath: skill.containerPath }, `skill-${skill.id}`, true)
234
+ }
235
+ }
236
+
237
+ return { volumes, volumeMounts }
238
+ }
239
+
240
+ function detectEnvironment(): SandboxEnvironment {
241
+ // ACI containers run Linux. The SandboxEnvironment enum is host-
242
+ // platform shape, not container internals — we pick the variant
243
+ // the consumer's code paths expect for a Linux namespace-isolated
244
+ // worker.
245
+ return 'linux-namespace'
246
+ }
247
+
248
+ let _sandboxIdCounter = 0
249
+ function generateSandboxId(): SandboxId {
250
+ const ts = Date.now().toString(36)
251
+ const rand = Math.random().toString(36).slice(2, 8)
252
+ _sandboxIdCounter += 1
253
+ return `sbx_${ts}_${rand}_${_sandboxIdCounter}` as SandboxId
254
+ }
255
+
256
+ async function armCall<T>(
257
+ url: string,
258
+ method: 'GET' | 'PUT' | 'DELETE',
259
+ getToken: ArmTokenProvider,
260
+ body?: unknown,
261
+ ): Promise<T | undefined> {
262
+ const token = await getToken()
263
+ const init: RequestInit = {
264
+ method,
265
+ headers: {
266
+ Authorization: `Bearer ${token}`,
267
+ 'content-type': 'application/json',
268
+ },
269
+ }
270
+ if (body !== undefined) {
271
+ init.body = JSON.stringify(body)
272
+ }
273
+ const res = await fetch(url, init)
274
+ if (!res.ok) {
275
+ const text = await res.text()
276
+ throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`)
277
+ }
278
+ if (res.status === 204 || res.status === 202) return undefined
279
+ const ct = res.headers.get('content-type') ?? ''
280
+ if (ct.includes('application/json')) {
281
+ return (await res.json()) as T
282
+ }
283
+ return undefined
284
+ }
285
+
286
+ interface ArmContainerGroup {
287
+ id?: string
288
+ name?: string
289
+ properties?: {
290
+ provisioningState?: string
291
+ ipAddress?: {
292
+ ip?: string
293
+ fqdn?: string
294
+ }
295
+ }
296
+ }
297
+
298
+ async function spawnAciSandbox(
299
+ config: ACIStandbyPoolBackendInternalConfig,
300
+ _options: SandboxBackendOptions,
301
+ ): Promise<Sandbox> {
302
+ const id = generateSandboxId()
303
+ const prefix = config.containerNamePrefix ?? DEFAULT_CONTAINER_NAME_PREFIX
304
+ const cgName = `${prefix}-${id
305
+ .replace(/[^a-z0-9-]/gi, '')
306
+ .toLowerCase()
307
+ .slice(0, 50)}`
308
+ const apiVersion = config.armApiVersion ?? DEFAULT_ARM_API_VERSION
309
+ const workerPort = config.workerPort ?? DEFAULT_WORKER_PORT
310
+ const armUrl = `${ARM_BASE}/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/${cgName}?api-version=${apiVersion}`
311
+
312
+ const { volumes, volumeMounts } = buildAzureFileVolumesFromLayout(config.layout)
313
+
314
+ // Standby Pool's claim API rejects every property override that
315
+ // is NOT a `configMap`. The empty / no-mount cases (every source
316
+ // is `inImage`) MUST therefore omit `containers`, `volumes`, and
317
+ // `volumeMounts` entirely from the PUT body — even an empty
318
+ // array trips the BadRequest "ContainerGroup properties other
319
+ // than config map are not allowed" check. The fields land only
320
+ // when something real needs to ride through (e.g. cold-spawn ACI
321
+ // with per-task azureFileShare mounts, future flow).
322
+ const properties: Record<string, unknown> = {
323
+ containerGroupProfile: {
324
+ id: config.containerGroupProfileResourceId,
325
+ revision: config.containerGroupProfileRevision ?? 1,
326
+ },
327
+ standbyPoolProfile: {
328
+ id: config.standbyPoolResourceId,
329
+ },
330
+ ...(config.subnetId ? { subnetIds: [{ id: config.subnetId }] } : {}),
331
+ }
332
+ if (volumes.length > 0) {
333
+ properties.volumes = volumes
334
+ properties.containers = [
335
+ {
336
+ name: `${prefix}-worker`,
337
+ properties: { volumeMounts },
338
+ },
339
+ ]
340
+ }
341
+
342
+ const body: Record<string, unknown> = {
343
+ location: config.location,
344
+ properties,
345
+ }
346
+
347
+ let claimed: ArmContainerGroup | undefined
348
+ try {
349
+ claimed = await armCall<ArmContainerGroup>(armUrl, 'PUT', config.getArmToken, body)
350
+ } catch (err) {
351
+ throw new Error(
352
+ `aci-standby-pool: failed to claim from pool — ${err instanceof Error ? err.message : String(err)}`,
353
+ )
354
+ }
355
+
356
+ const initialIp = claimed?.properties?.ipAddress?.ip
357
+ let ip = initialIp
358
+ try {
359
+ if (!ip) {
360
+ ip = await pollForRunningIp(
361
+ armUrl,
362
+ config.getArmToken,
363
+ config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
364
+ config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
365
+ )
366
+ }
367
+
368
+ const baseUrl = `http://${ip}:${workerPort}`
369
+ await waitForWorkerReady(
370
+ baseUrl,
371
+ config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
372
+ config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
373
+ )
374
+
375
+ let status: SandboxStatus = 'ready'
376
+ const rootDir = config.layout.outputs.containerPath
377
+
378
+ return {
379
+ id,
380
+ get status(): SandboxStatus {
381
+ return status
382
+ },
383
+ rootDir,
384
+ environment: detectEnvironment(),
385
+
386
+ async exec(
387
+ command: string,
388
+ argv?: string[],
389
+ opts?: SandboxExecOptions,
390
+ ): Promise<SandboxExecResult> {
391
+ status = 'busy'
392
+ try {
393
+ return await execViaWorker(baseUrl, command, argv, opts)
394
+ } finally {
395
+ status = 'ready'
396
+ }
397
+ },
398
+
399
+ async writeFile(path: string, content: string | Buffer): Promise<void> {
400
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
401
+ const res = await fetch(`${baseUrl}/write-file`, {
402
+ method: 'POST',
403
+ headers: { 'content-type': 'application/json' },
404
+ body: JSON.stringify({
405
+ path,
406
+ content: buf.toString('base64'),
407
+ encoding: 'base64',
408
+ }),
409
+ })
410
+ if (!res.ok) {
411
+ throw new Error(`write-file failed: HTTP ${res.status} ${await res.text()}`)
412
+ }
413
+ },
414
+
415
+ async readFile(path: string): Promise<Buffer> {
416
+ const res = await fetch(`${baseUrl}/read-file`, {
417
+ method: 'POST',
418
+ headers: { 'content-type': 'application/json' },
419
+ body: JSON.stringify({ path, encoding: 'base64' }),
420
+ })
421
+ if (!res.ok) {
422
+ throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
423
+ }
424
+ const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
425
+ if (!json.ok || typeof json.content !== 'string') {
426
+ throw new Error(json.error ?? 'read-file: no content')
427
+ }
428
+ return Buffer.from(json.content, 'base64')
429
+ },
430
+
431
+ async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
432
+ return await listFilesViaWorker(baseUrl, rootPath)
433
+ },
434
+
435
+ async destroy(): Promise<void> {
436
+ status = 'destroyed'
437
+ // ARM DELETE — let failures propagate. The Vandal-side
438
+ // lifecycle wraps this in its own try/catch with logging,
439
+ // so a silently swallowed error here means orphan ACI
440
+ // container groups pile up under the resource group with
441
+ // no observability handle. The Standby Pool's refill keeps
442
+ // the WARM side topped up; that has nothing to do with
443
+ // cleaning up a CLAIMED instance, which is exclusively the
444
+ // claimer's responsibility.
445
+ await armCall(armUrl, 'DELETE', config.getArmToken)
446
+ },
447
+ }
448
+ } catch (err) {
449
+ try {
450
+ await armCall(armUrl, 'DELETE', config.getArmToken)
451
+ } catch {
452
+ // Preserve the readiness failure as the primary error; the
453
+ // caller cannot use a Sandbox handle yet, so best-effort ARM
454
+ // cleanup is the only reliable orphan-prevention hook here.
455
+ }
456
+ throw err
457
+ }
458
+ }
459
+
460
+ async function pollForRunningIp(
461
+ armUrl: string,
462
+ getToken: ArmTokenProvider,
463
+ pollIntervalMs: number,
464
+ timeoutMs: number,
465
+ ): Promise<string> {
466
+ const deadline = Date.now() + timeoutMs
467
+ while (Date.now() < deadline) {
468
+ const cg = await armCall<ArmContainerGroup>(armUrl, 'GET', getToken)
469
+ const state = cg?.properties?.provisioningState
470
+ const ip = cg?.properties?.ipAddress?.ip
471
+ if (state === 'Succeeded' && ip) return ip
472
+ if (state === 'Failed') {
473
+ throw new Error('aci-standby-pool: container group provisioning failed')
474
+ }
475
+ await new Promise((r) => setTimeout(r, pollIntervalMs))
476
+ }
477
+ throw new Error(`aci-standby-pool: timed out waiting for container group IP (${timeoutMs}ms)`)
478
+ }
479
+
480
+ async function waitForWorkerReady(
481
+ baseUrl: string,
482
+ timeoutMs: number,
483
+ pollIntervalMs: number,
484
+ ): Promise<void> {
485
+ const deadline = Date.now() + timeoutMs
486
+ while (Date.now() < deadline) {
487
+ try {
488
+ const res = await fetch(`${baseUrl}/healthz`, {
489
+ signal: AbortSignal.timeout(2000),
490
+ })
491
+ if (res.ok) return
492
+ } catch {
493
+ // Network not ready yet, try again.
494
+ }
495
+ await new Promise((r) => setTimeout(r, pollIntervalMs))
496
+ }
497
+ throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`)
498
+ }
499
+
500
+ async function execViaWorker(
501
+ baseUrl: string,
502
+ command: string,
503
+ argv: string[] | undefined,
504
+ opts: SandboxExecOptions | undefined,
505
+ ): Promise<SandboxExecResult> {
506
+ const start = Date.now()
507
+ const res = await fetch(`${baseUrl}/execute`, {
508
+ method: 'POST',
509
+ headers: { 'content-type': 'application/json' },
510
+ body: JSON.stringify({
511
+ command,
512
+ args: argv ?? [],
513
+ cwd: opts?.cwd,
514
+ env: opts?.env,
515
+ timeoutMs: opts?.timeout,
516
+ }),
517
+ })
518
+ if (!res.ok || !res.body) {
519
+ throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
520
+ }
521
+
522
+ let stdout = ''
523
+ let stderr = ''
524
+ let exitCode = -1
525
+ let timedOut = false
526
+
527
+ const decoder = new TextDecoder()
528
+ const reader = res.body.getReader()
529
+ let buffered = ''
530
+ for (;;) {
531
+ const { value, done } = await reader.read()
532
+ if (done) break
533
+ buffered += decoder.decode(value, { stream: true })
534
+ let newlineIdx = buffered.indexOf('\n')
535
+ while (newlineIdx !== -1) {
536
+ const line = buffered.slice(0, newlineIdx).trim()
537
+ buffered = buffered.slice(newlineIdx + 1)
538
+ if (line) {
539
+ try {
540
+ const event = JSON.parse(line) as
541
+ | { type: 'stdout_delta'; data: string }
542
+ | { type: 'stderr_delta'; data: string }
543
+ | { type: 'result'; exitCode: number; timedOut: boolean }
544
+ | { type: 'error'; error: string }
545
+ if (event.type === 'stdout_delta') stdout += event.data
546
+ else if (event.type === 'stderr_delta') stderr += event.data
547
+ else if (event.type === 'result') {
548
+ exitCode = event.exitCode
549
+ timedOut = event.timedOut
550
+ } else if (event.type === 'error') {
551
+ throw new Error(event.error)
552
+ }
553
+ } catch (err) {
554
+ if (!(err instanceof SyntaxError)) throw err
555
+ }
556
+ }
557
+ newlineIdx = buffered.indexOf('\n')
558
+ }
559
+ }
560
+
561
+ return {
562
+ stdout,
563
+ stderr,
564
+ exitCode,
565
+ timedOut,
566
+ durationMs: Date.now() - start,
567
+ } as SandboxExecResult
568
+ }
569
+
570
+ /**
571
+ * Recursively list regular files under `rootPath` by shelling out to
572
+ * the worker's `find` (GNU find on the Debian-based reference image).
573
+ * `-printf` emits one `<path>\t<size>` line per file; any other
574
+ * non-zero exit (notably `find: '<root>': No such file or directory`)
575
+ * is mapped to "empty listing" because the agent legitimately may not
576
+ * have produced anything in `rootPath` yet.
577
+ */
578
+ async function listFilesViaWorker(
579
+ baseUrl: string,
580
+ rootPath: string,
581
+ ): Promise<readonly SandboxFileEntry[]> {
582
+ const result = await execViaWorker(
583
+ baseUrl,
584
+ 'find',
585
+ [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
586
+ undefined,
587
+ )
588
+ if (result.exitCode !== 0) {
589
+ return []
590
+ }
591
+ const entries: SandboxFileEntry[] = []
592
+ for (const rawLine of result.stdout.split('\n')) {
593
+ if (!rawLine) continue
594
+ const tab = rawLine.indexOf('\t')
595
+ if (tab < 0) continue
596
+ const path = rawLine.slice(0, tab)
597
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10)
598
+ if (!path || !Number.isFinite(size)) continue
599
+ entries.push({ path, size })
600
+ }
601
+ return entries
602
+ }