@multiplayer-app/sandbox 7.0.1 → 7.0.2

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/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ var import_config5 = require("dotenv/config");
28
28
  var import_commander = require("commander");
29
29
 
30
30
  // package.json
31
- var version = "7.0.1";
31
+ var version = "7.0.2";
32
32
 
33
33
  // src/config.ts
34
34
  var import_os2 = __toESM(require("os"));
@@ -51,6 +51,14 @@ var WorkerEvent = {
51
51
  // worker → sandbox, fire-and-forget like HEARTBEAT — sent once when a 'job' VM's
52
52
  // entrypoint exits on its own (the worker has already torn the resource down by then).
53
53
  VM_RUN_EXITED: "vm:run-exited",
54
+ // worker → sandbox after an asynchronous VM_CREATE finishes. Socket.IO acknowledgements
55
+ // cannot be relied on across the detached Redis-adapter emitter used by the API.
56
+ VM_CREATE_RESULT: "vm:create-result",
57
+ // worker → sandbox, fire-and-forget — sent when periodic reconciliation finds the
58
+ // underlying resource gone without this worker ever issuing a destroy (e.g. AWS's own
59
+ // idle-suspend/max-duration policies terminating a Lambda MicroVM). Only engines whose
60
+ // VMs can disappear outside an explicit command from this process emit it.
61
+ VM_EXTERNALLY_TERMINATED: "vm:externally-terminated",
54
62
  // sandbox → worker, generated by the control plane and reused by this worker process
55
63
  // on Socket.IO reconnects. This keeps duplicate configured WORKER_ID values isolated.
56
64
  ASSIGNED_ID: "worker:assigned-id",
@@ -78,6 +86,8 @@ var WorkerEvent = {
78
86
  // sandbox → worker (browser input forwarded downstream)
79
87
  VNC_MOUSE: "vnc:mouse",
80
88
  VNC_KEYBOARD: "vnc:keyboard",
89
+ TERMINAL_ATTACH: "terminal:attach",
90
+ TERMINAL_DETACH: "terminal:detach",
81
91
  TERMINAL_INPUT: "terminal:input"
82
92
  };
83
93
  var VM_ACTION_EVENTS = {
@@ -122,7 +132,20 @@ function s3Key(prefix, arch, stack, file) {
122
132
  async function makeS3Client(endpoint) {
123
133
  const { S3Client } = await import("@aws-sdk/client-s3");
124
134
  return new S3Client({
125
- ...endpoint ? { endpoint, forcePathStyle: true } : {},
135
+ ...endpoint ? {
136
+ endpoint,
137
+ forcePathStyle: true,
138
+ // A custom endpoint means a local/self-hosted store (MinIO, etc.), not real AWS.
139
+ // Use its own static credentials explicitly rather than the ambient default
140
+ // provider chain — otherwise, whenever AWS_PROFILE is also set (e.g. for the
141
+ // lambda-microvm engine's real AWS calls), the SDK unconditionally skips static env
142
+ // credentials process-wide and sends the real AWS SSO session to this local store
143
+ // instead, which rejects it since it only recognizes its own configured user.
144
+ credentials: {
145
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "minioadmin",
146
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "minioadmin"
147
+ }
148
+ } : {},
126
149
  ...process.env.AWS_REGION ? {} : { region: "us-east-1" },
127
150
  // aws-chunked checksum streaming breaks older S3-compatible stores (MinIO)
128
151
  requestChecksumCalculation: "WHEN_REQUIRED"
@@ -143,7 +166,10 @@ async function downloadFromHttp(opts, stack, file, dest) {
143
166
  if (!response.ok || !response.body) {
144
167
  throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
145
168
  }
146
- await (0, import_promises.pipeline)(import_stream.Readable.fromWeb(response.body), import_fs.default.createWriteStream(dest));
169
+ await (0, import_promises.pipeline)(
170
+ import_stream.Readable.fromWeb(response.body),
171
+ import_fs.default.createWriteStream(dest)
172
+ );
147
173
  }
148
174
  async function pullImages(opts, destDir) {
149
175
  const isS3 = opts.location.startsWith("s3://");
@@ -178,11 +204,13 @@ async function pushImages(opts, srcDir) {
178
204
  throw new Error(`${src} not found \u2014 run scripts/setup-vm-images.sh first`);
179
205
  }
180
206
  const key = s3Key(prefix, opts.arch, stack, file);
181
- await client.send(new PutObjectCommand({
182
- Bucket: bucket,
183
- Key: key,
184
- Body: import_fs.default.readFileSync(src)
185
- }));
207
+ await client.send(
208
+ new PutObjectCommand({
209
+ Bucket: bucket,
210
+ Key: key,
211
+ Body: import_fs.default.readFileSync(src)
212
+ })
213
+ );
186
214
  uploaded.push(`s3://${bucket}/${key}`);
187
215
  }
188
216
  }
@@ -221,12 +249,14 @@ function resolveSandboxEndpoint(rawUrl) {
221
249
  }
222
250
  function loadWorkerConfigFromEnv(env = process.env) {
223
251
  const workerId = env.WORKER_ID?.trim() || import_os2.default.hostname();
252
+ const workerIdFile = env.WORKER_ID_FILE?.trim() || import_path2.default.join(import_os2.default.tmpdir(), `multiplayer-sandbox-worker-${workerId}.id`);
224
253
  const token = env.WORKER_TOKEN?.trim() || void 0;
225
254
  const sandboxEndpoint = resolveSandboxEndpoint(
226
255
  env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0/gapi"
227
256
  );
228
257
  return {
229
258
  workerId,
259
+ workerIdFile,
230
260
  token,
231
261
  sandboxUrl: sandboxEndpoint.sandboxUrl,
232
262
  workerSocketPath: sandboxEndpoint.workerSocketPath,
@@ -261,7 +291,10 @@ function loadWorkerConfigFromEnv(env = process.env) {
261
291
  // 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
262
292
  maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
263
293
  suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
264
- maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800)
294
+ maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800),
295
+ // A real cold MicroVM boot can comfortably exceed the old 25s hardcoded default —
296
+ // must stay well under the api's own VM_CREATE ack timeout (see vm.service.ts).
297
+ createTimeoutMs: Number(env.MICROVM_CREATE_TIMEOUT_MS ?? 6e4)
265
298
  },
266
299
  fargate: {
267
300
  region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
@@ -2466,8 +2499,8 @@ var RemoteExecutor = class {
2466
2499
  body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
2467
2500
  };
2468
2501
  }
2469
- request(method, path7, body) {
2470
- const url = new URL(this.base + path7);
2502
+ request(method, path8, body) {
2503
+ const url = new URL(this.base + path8);
2471
2504
  const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
2472
2505
  const payload = body !== void 0 ? JSON.stringify(body) : void 0;
2473
2506
  return new Promise((resolve, reject) => {
@@ -2488,7 +2521,7 @@ var RemoteExecutor = class {
2488
2521
  res.on("end", () => {
2489
2522
  const text = Buffer.concat(chunks).toString();
2490
2523
  if (res.statusCode && res.statusCode >= 400) {
2491
- reject(new Error(`vm-agent ${method} ${path7} \u2192 ${res.statusCode}: ${text}`));
2524
+ reject(new Error(`vm-agent ${method} ${path8} \u2192 ${res.statusCode}: ${text}`));
2492
2525
  return;
2493
2526
  }
2494
2527
  if (res.statusCode === 204 || !text) {
@@ -2515,6 +2548,23 @@ var import_https2 = __toESM(require("https"));
2515
2548
  var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
2516
2549
  var AUTH_TOKEN_TTL_MINUTES = 60;
2517
2550
  var AUTH_TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
2551
+ var SHELL_PORT = 8022;
2552
+ function managedConnectorArn(imageIdentifier, name) {
2553
+ const [arn, partition, service, region] = imageIdentifier.split(":");
2554
+ if (arn !== "arn" || !partition || service !== "lambda" || !region) {
2555
+ throw new Error(`cannot derive Lambda MicroVM connector ARN from image "${imageIdentifier}"`);
2556
+ }
2557
+ return `arn:${partition}:lambda:${region}:aws:network-connector:aws-network-connector:${name}`;
2558
+ }
2559
+ function runtimeEnv(spec) {
2560
+ const env = {
2561
+ ...spec.env,
2562
+ // A release URL is unique and short-lived, so it belongs in per-VM run data rather than
2563
+ // the image-level environment shared by every MicroVM launched from that image.
2564
+ ...spec.appUrl ? { APP_URL: spec.appUrl } : {}
2565
+ };
2566
+ return Object.keys(env).length ? env : void 0;
2567
+ }
2518
2568
  var LambdaMicrovmExecutor = class {
2519
2569
  constructor(config) {
2520
2570
  this.config = config;
@@ -2524,10 +2574,15 @@ var LambdaMicrovmExecutor = class {
2524
2574
  async create(spec) {
2525
2575
  const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
2526
2576
  const maxIdle = this.config.maxIdleSeconds ?? 900;
2577
+ const shellConnectorArn = managedConnectorArn(imageIdentifier, "SHELL_INGRESS");
2578
+ const ingressNetworkConnectors = [
2579
+ .../* @__PURE__ */ new Set([...this.config.ingressConnectorArns ?? [], shellConnectorArn])
2580
+ ];
2527
2581
  const run = await this.client.send(
2528
2582
  new import_client_lambda_microvms.RunMicrovmCommand({
2529
2583
  imageIdentifier,
2530
- ingressNetworkConnectors: this.config.ingressConnectorArns,
2584
+ // Required by CreateMicrovmShellAuthToken and the /shell WebSocket used by vm-console.
2585
+ ingressNetworkConnectors,
2531
2586
  egressNetworkConnectors: this.config.egressConnectorArns,
2532
2587
  executionRoleArn: this.config.executionRoleArn,
2533
2588
  maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
@@ -2544,9 +2599,11 @@ var LambdaMicrovmExecutor = class {
2544
2599
  vmId: spec.vmId,
2545
2600
  sessionId: spec.sessionId,
2546
2601
  stack: spec.stack,
2547
- appUrl: spec.appUrl,
2548
- env: spec.env
2549
- })
2602
+ env: runtimeEnv(spec)
2603
+ }),
2604
+ // VM_CREATE may be delivered more than once by the Redis-backed control plane.
2605
+ // AWS returns the original request result instead of provisioning another MicroVM.
2606
+ clientToken: spec.vmId
2550
2607
  })
2551
2608
  );
2552
2609
  if (!run.microvmId || !run.endpoint) {
@@ -2609,6 +2666,31 @@ var LambdaMicrovmExecutor = class {
2609
2666
  list() {
2610
2667
  return [...this.vms.values()].map((v) => v.handle);
2611
2668
  }
2669
+ getSerialSource(vmId) {
2670
+ const vm = this.vms.get(vmId);
2671
+ if (!vm) return void 0;
2672
+ return {
2673
+ type: "websocket",
2674
+ url: `wss://${vm.endpoint}/shell`,
2675
+ protocols: async () => {
2676
+ const response = await this.client.send(
2677
+ new import_client_lambda_microvms.CreateMicrovmShellAuthTokenCommand({
2678
+ microvmIdentifier: vm.microvmId,
2679
+ expirationInMinutes: AUTH_TOKEN_TTL_MINUTES
2680
+ })
2681
+ );
2682
+ const token = Object.entries(response.authToken ?? {}).find(
2683
+ ([key]) => key.toLowerCase() === "x-aws-proxy-auth"
2684
+ )?.[1];
2685
+ if (!token) throw new Error("create-microvm-shell-auth-token returned no auth token");
2686
+ return [
2687
+ "lambda-microvms",
2688
+ `lambda-microvms.authentication.${token}`,
2689
+ `lambda-microvms.port.${SHELL_PORT}`
2690
+ ];
2691
+ }
2692
+ };
2693
+ }
2612
2694
  async proxyHttp(vmId, request) {
2613
2695
  const vm = this.mustGet(vmId);
2614
2696
  const auth = await this.getAuthHeaders(vm);
@@ -2676,6 +2758,47 @@ var LambdaMicrovmExecutor = class {
2676
2758
  );
2677
2759
  return handle;
2678
2760
  }
2761
+ /**
2762
+ * AWS can suspend or terminate a MicroVM on its own (idle-suspend policy, the suspended-
2763
+ * duration cap, the hard maximumDurationInSeconds lifetime) without this process ever
2764
+ * issuing a command — nothing else here polls for that. Called periodically by the
2765
+ * worker: re-describes every tracked VM, quietly reconciles running/suspended drift, and
2766
+ * reports (then forgets) any that AWS has actually terminated so the control plane's
2767
+ * record doesn't stay stuck saying "running" forever.
2768
+ */
2769
+ async reconcile() {
2770
+ const externallyTerminated = [];
2771
+ for (const [vmId, vm] of [...this.vms.entries()]) {
2772
+ try {
2773
+ const described = await this.client.send(
2774
+ new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: vm.microvmId })
2775
+ );
2776
+ if (described.state === "TERMINATED" || described.state === "TERMINATING") {
2777
+ this.vms.delete(vmId);
2778
+ externallyTerminated.push(vmId);
2779
+ src_default.warn(
2780
+ { vmId, microvmId: vm.microvmId, state: described.state },
2781
+ "[LambdaMicrovm] vm terminated outside our control"
2782
+ );
2783
+ } else if (typeof described.state === "string") {
2784
+ const running = described.state === "RUNNING";
2785
+ if (running !== vm.handle.running) vm.handle = { ...vm.handle, running };
2786
+ }
2787
+ } catch (error) {
2788
+ if (error instanceof Error && error.name === "ResourceNotFoundException") {
2789
+ this.vms.delete(vmId);
2790
+ externallyTerminated.push(vmId);
2791
+ src_default.warn(
2792
+ { vmId, microvmId: vm.microvmId },
2793
+ "[LambdaMicrovm] vm no longer exists in AWS"
2794
+ );
2795
+ } else {
2796
+ src_default.error({ vmId, err: error }, "[LambdaMicrovm] reconcile check failed");
2797
+ }
2798
+ }
2799
+ }
2800
+ return externallyTerminated;
2801
+ }
2679
2802
  mustGet(vmId) {
2680
2803
  const vm = this.vms.get(vmId);
2681
2804
  if (!vm) throw new Error(`vm ${vmId} not found`);
@@ -3248,6 +3371,8 @@ function detectEngines(config) {
3248
3371
  }
3249
3372
 
3250
3373
  // src/executor/index.ts
3374
+ var VmResourceGoneError = class extends Error {
3375
+ };
3251
3376
  function findNotAttachedVms(executor, records) {
3252
3377
  return records.filter((record) => !executor.get(record.vmId));
3253
3378
  }
@@ -3277,11 +3402,25 @@ var MultiEngineExecutor = class {
3277
3402
  constructor(executors, defaultEngine) {
3278
3403
  this.executors = executors;
3279
3404
  this.defaultEngine = defaultEngine;
3405
+ this.creations = /* @__PURE__ */ new Map();
3280
3406
  }
3281
3407
  get engines() {
3282
3408
  return [...this.executors.keys()];
3283
3409
  }
3284
3410
  async create(spec) {
3411
+ const existing = this.get(spec.vmId);
3412
+ if (existing) return existing;
3413
+ const pending = this.creations.get(spec.vmId);
3414
+ if (pending) return pending;
3415
+ const creation = this.createOnce(spec);
3416
+ this.creations.set(spec.vmId, creation);
3417
+ try {
3418
+ return await creation;
3419
+ } finally {
3420
+ this.creations.delete(spec.vmId);
3421
+ }
3422
+ }
3423
+ async createOnce(spec) {
3285
3424
  const engine = spec.engine ?? this.defaultEngine;
3286
3425
  const executor = this.executors.get(engine);
3287
3426
  if (!executor) {
@@ -3292,22 +3431,27 @@ var MultiEngineExecutor = class {
3292
3431
  const handle = await executor.create(spec);
3293
3432
  return { ...handle, engine };
3294
3433
  }
3295
- pause(vmId) {
3434
+ // async here isn't a no-op: route() throws synchronously when the vmId isn't tracked in any
3435
+ // executor's in-memory map (e.g. worker restarted and hasn't reattached this VM yet). Without
3436
+ // `async`, that throw escapes as a real exception instead of a rejected promise — which,
3437
+ // called from socket-client.ts's wrap(), skips its .catch() entirely and leaves the command
3438
+ // un-acked until the caller's timeout, looking like the action "does nothing".
3439
+ async pause(vmId) {
3296
3440
  return this.route(vmId).pause(vmId);
3297
3441
  }
3298
- resume(vmId) {
3442
+ async resume(vmId) {
3299
3443
  return this.route(vmId).resume(vmId);
3300
3444
  }
3301
- stop(vmId) {
3445
+ async stop(vmId) {
3302
3446
  return this.route(vmId).stop(vmId);
3303
3447
  }
3304
- restore(vmId) {
3448
+ async restore(vmId) {
3305
3449
  return this.route(vmId).restore(vmId);
3306
3450
  }
3307
- destroy(vmId) {
3451
+ async destroy(vmId) {
3308
3452
  return this.route(vmId).destroy(vmId);
3309
3453
  }
3310
- proxyHttp(vmId, request) {
3454
+ async proxyHttp(vmId, request) {
3311
3455
  return this.route(vmId).proxyHttp(vmId, request);
3312
3456
  }
3313
3457
  get(vmId) {
@@ -3326,15 +3470,14 @@ var MultiEngineExecutor = class {
3326
3470
  }
3327
3471
  return void 0;
3328
3472
  }
3329
- exec(vmId, request) {
3473
+ async exec(vmId, request) {
3330
3474
  const executor = this.route(vmId);
3331
- if (!executor.exec) return Promise.reject(new Error("executor does not support guest exec"));
3475
+ if (!executor.exec) throw new Error("executor does not support guest exec");
3332
3476
  return executor.exec(vmId, request);
3333
3477
  }
3334
- writeFiles(vmId, files) {
3478
+ async writeFiles(vmId, files) {
3335
3479
  const executor = this.route(vmId);
3336
- if (!executor.writeFiles)
3337
- return Promise.reject(new Error("executor does not support guest file writes"));
3480
+ if (!executor.writeFiles) throw new Error("executor does not support guest file writes");
3338
3481
  return executor.writeFiles(vmId, files);
3339
3482
  }
3340
3483
  onExit(vmId, handler) {
@@ -3342,6 +3485,15 @@ var MultiEngineExecutor = class {
3342
3485
  if (!executor.onExit) throw new Error("executor does not support job run mode");
3343
3486
  executor.onExit(vmId, handler);
3344
3487
  }
3488
+ /** Fans out to every sub-executor that supports it (currently just lambda-microvm). */
3489
+ async reconcile() {
3490
+ const externallyTerminated = [];
3491
+ for (const executor of this.executors.values()) {
3492
+ if (!executor.reconcile) continue;
3493
+ externallyTerminated.push(...await executor.reconcile());
3494
+ }
3495
+ return externallyTerminated;
3496
+ }
3345
3497
  /**
3346
3498
  * Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
3347
3499
  * restart, most likely). Runs in parallel — probing many VMs serially would needlessly
@@ -3350,9 +3502,16 @@ var MultiEngineExecutor = class {
3350
3502
  * never thrown — a VM that can't be reattached just stays unavailable until something
3351
3503
  * touches it again (create/proxy/lifecycle), which already errors gracefully today.
3352
3504
  */
3505
+ /**
3506
+ * Returns vmIds found genuinely gone (executor.reattach resolved undefined — the
3507
+ * resource itself no longer exists, e.g. AWS already terminated it while this worker
3508
+ * was down) as distinct from any other reattach failure (engine unavailable, no reattach
3509
+ * support, a transient describe error, ...), which leave the VM merely unavailable
3510
+ * rather than confirmed gone.
3511
+ */
3353
3512
  async reattachAll(records) {
3354
3513
  const pending = findNotAttachedVms(this, records);
3355
- if (!pending.length) return;
3514
+ if (!pending.length) return [];
3356
3515
  const results = await Promise.allSettled(
3357
3516
  pending.map(async (record) => {
3358
3517
  const executor = record.engine ? this.executors.get(record.engine) : void 0;
@@ -3361,18 +3520,25 @@ var MultiEngineExecutor = class {
3361
3520
  if (!executor.reattach)
3362
3521
  throw new Error(`engine "${record.engine}" does not support reattach`);
3363
3522
  const handle = await executor.reattach(record);
3364
- if (!handle) throw new Error("resource no longer exists");
3523
+ if (!handle) throw new VmResourceGoneError();
3365
3524
  })
3366
3525
  );
3526
+ const externallyTerminated = [];
3367
3527
  results.forEach((result, i) => {
3368
3528
  const vmId = pending[i].vmId;
3369
- if (result.status === "fulfilled") src_default.info({ vmId }, "vm reattached");
3370
- else
3529
+ if (result.status === "fulfilled") {
3530
+ src_default.info({ vmId }, "vm reattached");
3531
+ } else if (result.reason instanceof VmResourceGoneError) {
3532
+ externallyTerminated.push(vmId);
3533
+ src_default.warn({ vmId }, "vm reattach found resource already gone");
3534
+ } else {
3371
3535
  src_default.warn(
3372
3536
  { vmId, err: result.reason },
3373
3537
  "vm reattach failed \u2014 staying unavailable until next touch"
3374
3538
  );
3539
+ }
3375
3540
  });
3541
+ return externallyTerminated;
3376
3542
  }
3377
3543
  route(vmId) {
3378
3544
  for (const executor of this.executors.values()) {
@@ -3394,8 +3560,36 @@ function createExecutor(config) {
3394
3560
  // src/socket-client.ts
3395
3561
  var import_os9 = __toESM(require("os"));
3396
3562
  var import_socket = require("socket.io-client");
3563
+
3564
+ // src/worker-identity.ts
3565
+ var import_fs3 = __toESM(require("fs"));
3566
+ var import_path7 = __toESM(require("path"));
3567
+ function loadPersistedWorkerId(filePath) {
3568
+ try {
3569
+ const id = import_fs3.default.readFileSync(filePath, "utf8").trim();
3570
+ return id || void 0;
3571
+ } catch {
3572
+ return void 0;
3573
+ }
3574
+ }
3575
+ function savePersistedWorkerId(filePath, workerId) {
3576
+ try {
3577
+ import_fs3.default.mkdirSync(import_path7.default.dirname(filePath), { recursive: true });
3578
+ import_fs3.default.writeFileSync(filePath, workerId, "utf8");
3579
+ } catch (error) {
3580
+ src_default.warn(
3581
+ { err: error, filePath },
3582
+ "failed to persist assigned worker id \u2014 reattach after a restart may not find its VMs"
3583
+ );
3584
+ }
3585
+ }
3586
+
3587
+ // src/socket-client.ts
3397
3588
  var HEARTBEAT_INTERVAL_MS = 1e4;
3589
+ var RECONCILE_INTERVAL_MS = 6e4;
3398
3590
  var REATTACH_FALLBACK_MS = 5e3;
3591
+ var RECONNECTION_DELAY_MS = 1e3;
3592
+ var RECONNECTION_DELAY_MAX_MS = 1e4;
3399
3593
  function primaryIp() {
3400
3594
  for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
3401
3595
  for (const iface of ifaces ?? []) {
@@ -3424,7 +3618,11 @@ function wrap(handler) {
3424
3618
  return (payload, callback) => {
3425
3619
  handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
3426
3620
  src_default.error({ err: error }, "command failed");
3427
- callback({ ok: false, error: error.message });
3621
+ try {
3622
+ callback({ ok: false, error: error.message });
3623
+ } catch (ackError) {
3624
+ src_default.error({ err: ackError }, "failed to send command failure ack");
3625
+ }
3428
3626
  });
3429
3627
  };
3430
3628
  }
@@ -3432,20 +3630,32 @@ function registerCommandHandlers(socket, executor) {
3432
3630
  socket.on(
3433
3631
  WorkerEvent.VM_CREATE,
3434
3632
  wrap(async (spec) => {
3435
- const handle = await executor.create(spec);
3436
- if (spec.runMode === "job") {
3437
- try {
3633
+ try {
3634
+ const handle = await executor.create(spec);
3635
+ if (spec.runMode === "job") {
3438
3636
  executor.onExit(
3439
3637
  spec.vmId,
3440
- (result) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result })
3638
+ (result2) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result2 })
3441
3639
  );
3442
- } catch (error) {
3443
- await executor.destroy(spec.vmId).catch(() => {
3444
- });
3445
- throw error;
3446
3640
  }
3641
+ const result = {
3642
+ vmId: spec.vmId,
3643
+ ok: true,
3644
+ metadata: handle.metadata
3645
+ };
3646
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3647
+ return handle;
3648
+ } catch (error) {
3649
+ if (spec.runMode === "job") await executor.destroy(spec.vmId).catch(() => {
3650
+ });
3651
+ const result = {
3652
+ vmId: spec.vmId,
3653
+ ok: false,
3654
+ error: error instanceof Error ? error.message : String(error)
3655
+ };
3656
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3657
+ throw error;
3447
3658
  }
3448
- return handle;
3449
3659
  })
3450
3660
  );
3451
3661
  socket.on(
@@ -3497,7 +3707,7 @@ function registerCommandHandlers(socket, executor) {
3497
3707
  );
3498
3708
  }
3499
3709
  function connectToSandbox(config, executor) {
3500
- let assignedWorkerId;
3710
+ let assignedWorkerId = loadPersistedWorkerId(config.workerIdFile);
3501
3711
  const socket = (0, import_socket.io)(config.sandboxUrl, {
3502
3712
  path: config.workerSocketPath,
3503
3713
  auth: (callback) => callback({
@@ -3506,12 +3716,17 @@ function connectToSandbox(config, executor) {
3506
3716
  ...config.token ? { token: config.token } : {}
3507
3717
  }),
3508
3718
  transports: ["websocket"],
3509
- reconnection: true
3719
+ reconnection: true,
3720
+ reconnectionAttempts: Infinity,
3721
+ reconnectionDelay: RECONNECTION_DELAY_MS,
3722
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX_MS
3510
3723
  });
3511
3724
  let heartbeatTimer;
3725
+ let reconcileTimer;
3512
3726
  let reattachFallbackTimer;
3513
3727
  socket.on(WorkerEvent.ASSIGNED_ID, ({ workerId }) => {
3514
3728
  assignedWorkerId = workerId;
3729
+ savePersistedWorkerId(config.workerIdFile, workerId);
3515
3730
  src_default.info({ workerId: config.workerId, assignedWorkerId }, "worker runtime ID assigned");
3516
3731
  });
3517
3732
  const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
@@ -3520,6 +3735,17 @@ function connectToSandbox(config, executor) {
3520
3735
  sendHeartbeat();
3521
3736
  heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
3522
3737
  }
3738
+ const runReconcile = () => {
3739
+ executor.reconcile().then((externallyTerminated) => {
3740
+ for (const vmId of externallyTerminated) {
3741
+ socket.emit(WorkerEvent.VM_EXTERNALLY_TERMINATED, { vmId });
3742
+ }
3743
+ }).catch((error) => src_default.error({ err: error }, "vm reconcile pass failed"));
3744
+ };
3745
+ function startReconcile() {
3746
+ if (reconcileTimer) return;
3747
+ reconcileTimer = setInterval(runReconcile, RECONCILE_INTERVAL_MS);
3748
+ }
3523
3749
  socket.on("connect", () => {
3524
3750
  src_default.info(
3525
3751
  { workerId: config.workerId, assignedWorkerId, url: config.sandboxUrl },
@@ -3532,10 +3758,26 @@ function connectToSandbox(config, executor) {
3532
3758
  "no reattach handshake from sandbox \u2014 starting heartbeat anyway"
3533
3759
  );
3534
3760
  startHeartbeat();
3761
+ startReconcile();
3535
3762
  }, REATTACH_FALLBACK_MS);
3536
3763
  socket.once(WorkerEvent.REATTACH, (records) => {
3537
3764
  clearTimeout(reattachFallbackTimer);
3538
- executor.reattachAll(records).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(() => {
3765
+ executor.reattachAll(records).then((externallyTerminated) => {
3766
+ for (const vmId of externallyTerminated) {
3767
+ socket.emit(WorkerEvent.VM_EXTERNALLY_TERMINATED, { vmId });
3768
+ }
3769
+ }).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(() => {
3770
+ for (const record of records) {
3771
+ const handle = executor.get(record.vmId);
3772
+ if (record.state === "creating" && handle) {
3773
+ const result = {
3774
+ vmId: record.vmId,
3775
+ ok: true,
3776
+ metadata: handle.metadata
3777
+ };
3778
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3779
+ }
3780
+ }
3539
3781
  for (const record of records) {
3540
3782
  if (record.runMode !== "job" || !executor.get(record.vmId)) continue;
3541
3783
  try {
@@ -3551,21 +3793,36 @@ function connectToSandbox(config, executor) {
3551
3793
  }
3552
3794
  }
3553
3795
  startHeartbeat();
3796
+ startReconcile();
3554
3797
  });
3555
3798
  });
3556
3799
  });
3557
3800
  socket.on("disconnect", (reason) => {
3558
3801
  src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
3559
3802
  clearInterval(heartbeatTimer);
3803
+ clearInterval(reconcileTimer);
3560
3804
  clearTimeout(reattachFallbackTimer);
3561
3805
  heartbeatTimer = void 0;
3806
+ reconcileTimer = void 0;
3562
3807
  socket.off(WorkerEvent.REATTACH);
3563
3808
  });
3564
3809
  socket.on("connect_error", (error) => {
3565
- src_default.error({
3566
- err: error,
3567
- sandboxUrl: config.sandboxUrl
3568
- }, "sandbox connection error");
3810
+ src_default.error(
3811
+ {
3812
+ err: error,
3813
+ sandboxUrl: config.sandboxUrl
3814
+ },
3815
+ "sandbox connection error"
3816
+ );
3817
+ });
3818
+ socket.io.on("reconnect_attempt", (attempt) => {
3819
+ src_default.warn({ workerId: config.workerId, attempt }, "reconnecting to sandbox");
3820
+ });
3821
+ socket.io.on("reconnect_error", (error) => {
3822
+ src_default.warn({ workerId: config.workerId, err: error }, "sandbox reconnect attempt failed");
3823
+ });
3824
+ socket.io.on("reconnect", (attempt) => {
3825
+ src_default.info({ workerId: config.workerId, attempt }, "reconnected to sandbox");
3569
3826
  });
3570
3827
  registerCommandHandlers(socket, executor);
3571
3828
  return socket;
@@ -3675,19 +3932,42 @@ var VncRelay = class {
3675
3932
 
3676
3933
  // src/services/serial-relay.ts
3677
3934
  var import_net3 = __toESM(require("net"));
3935
+ var import_ws = __toESM(require("ws"));
3678
3936
  var SerialRelay = class {
3679
3937
  constructor(vmId, source, socket) {
3680
3938
  this.vmId = vmId;
3681
3939
  this.source = source;
3682
3940
  this.socket = socket;
3683
3941
  this.conn = null;
3942
+ this.webSocket = null;
3943
+ this.reconnectTimer = null;
3944
+ this.connecting = false;
3945
+ this.browserCount = 0;
3946
+ this.pendingInput = [];
3947
+ this.destroyed = false;
3684
3948
  this.inputHandler = null;
3949
+ this.attachHandler = null;
3950
+ this.detachHandler = null;
3685
3951
  }
3686
3952
  connect() {
3953
+ this.destroyed = false;
3687
3954
  if (this.source.type === "tcp") {
3688
3955
  this.connectTcp();
3689
- } else {
3956
+ } else if (this.source.type === "pipe") {
3690
3957
  this.connectPipe();
3958
+ } else {
3959
+ this.attachHandler = ({ vmId }) => {
3960
+ if (vmId !== this.vmId) return;
3961
+ this.browserCount += 1;
3962
+ if (this.browserCount === 1) void this.connectWebSocket();
3963
+ };
3964
+ this.detachHandler = ({ vmId }) => {
3965
+ if (vmId !== this.vmId) return;
3966
+ this.browserCount = Math.max(0, this.browserCount - 1);
3967
+ if (this.browserCount === 0) this.disconnectWebSocket();
3968
+ };
3969
+ this.socket.on(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
3970
+ this.socket.on(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
3691
3971
  }
3692
3972
  this.inputHandler = (e) => {
3693
3973
  if (e.vmId !== this.vmId) return;
@@ -3696,6 +3976,15 @@ var SerialRelay = class {
3696
3976
  } else if (this.source.type === "pipe") {
3697
3977
  const proc = this.source.process;
3698
3978
  proc.stdin?.write(e.data);
3979
+ } else if (this.source.type === "websocket") {
3980
+ if (this.webSocket?.readyState === import_ws.default.OPEN) {
3981
+ this.webSocket.send(e.data);
3982
+ } else {
3983
+ this.browserCount = Math.max(1, this.browserCount);
3984
+ this.pendingInput.push(e.data);
3985
+ if (this.pendingInput.length > 100) this.pendingInput.shift();
3986
+ void this.connectWebSocket();
3987
+ }
3699
3988
  }
3700
3989
  };
3701
3990
  this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
@@ -3714,7 +4003,9 @@ var SerialRelay = class {
3714
4003
  sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
3715
4004
  sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
3716
4005
  });
3717
- sock.once("error", () => setTimeout(attempt, 1e3));
4006
+ sock.once("error", () => {
4007
+ if (!this.destroyed) setTimeout(attempt, 1e3);
4008
+ });
3718
4009
  };
3719
4010
  attempt();
3720
4011
  }
@@ -3729,13 +4020,65 @@ var SerialRelay = class {
3729
4020
  (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d })
3730
4021
  );
3731
4022
  }
4023
+ async connectWebSocket() {
4024
+ if (this.destroyed || this.browserCount === 0 || this.source.type !== "websocket" || this.webSocket || this.connecting) {
4025
+ return;
4026
+ }
4027
+ try {
4028
+ this.connecting = true;
4029
+ const protocols = await this.source.protocols();
4030
+ if (this.destroyed || this.browserCount === 0) return;
4031
+ const webSocket = new import_ws.default(this.source.url, protocols);
4032
+ this.webSocket = webSocket;
4033
+ webSocket.on("open", () => {
4034
+ for (const data of this.pendingInput.splice(0)) webSocket.send(data);
4035
+ src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell connected");
4036
+ });
4037
+ webSocket.on("message", (data) => {
4038
+ const chunk = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
4039
+ this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: chunk });
4040
+ });
4041
+ webSocket.on("error", (error) => {
4042
+ src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell error");
4043
+ });
4044
+ webSocket.on("close", () => {
4045
+ if (this.webSocket === webSocket) this.webSocket = null;
4046
+ src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell disconnected");
4047
+ this.scheduleWebSocketReconnect();
4048
+ });
4049
+ } catch (error) {
4050
+ src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell connect failed");
4051
+ this.scheduleWebSocketReconnect();
4052
+ } finally {
4053
+ this.connecting = false;
4054
+ }
4055
+ }
4056
+ scheduleWebSocketReconnect() {
4057
+ if (this.destroyed || this.browserCount === 0 || this.reconnectTimer) return;
4058
+ this.reconnectTimer = setTimeout(() => {
4059
+ this.reconnectTimer = null;
4060
+ void this.connectWebSocket();
4061
+ }, 1e3);
4062
+ }
4063
+ disconnectWebSocket() {
4064
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
4065
+ this.reconnectTimer = null;
4066
+ this.pendingInput = [];
4067
+ const webSocket = this.webSocket;
4068
+ this.webSocket = null;
4069
+ webSocket?.close();
4070
+ }
3732
4071
  destroy() {
4072
+ this.destroyed = true;
3733
4073
  if (this.inputHandler) this.socket.off(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
4074
+ if (this.attachHandler) this.socket.off(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
4075
+ if (this.detachHandler) this.socket.off(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
3734
4076
  try {
3735
4077
  this.conn?.destroy();
3736
4078
  } catch {
3737
4079
  }
3738
4080
  this.conn = null;
4081
+ this.disconnectWebSocket();
3739
4082
  src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
3740
4083
  }
3741
4084
  };
@@ -3743,9 +4086,8 @@ var SerialRelay = class {
3743
4086
  // src/console-relays.ts
3744
4087
  function attachConsoleRelays(executor, sandboxSocket) {
3745
4088
  const relays = /* @__PURE__ */ new Map();
3746
- const originalCreate = executor.create.bind(executor);
3747
- executor.create = async (spec) => {
3748
- const handle = await originalCreate(spec);
4089
+ const attach = (handle) => {
4090
+ if (relays.has(handle.vmId)) return;
3749
4091
  const entry = {};
3750
4092
  if (handle.vncPort) {
3751
4093
  const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
@@ -3759,8 +4101,22 @@ function attachConsoleRelays(executor, sandboxSocket) {
3759
4101
  entry.serial = serial;
3760
4102
  }
3761
4103
  if (entry.vnc || entry.serial) relays.set(handle.vmId, entry);
4104
+ };
4105
+ const originalCreate = executor.create.bind(executor);
4106
+ executor.create = async (spec) => {
4107
+ const handle = await originalCreate(spec);
4108
+ attach(handle);
3762
4109
  return handle;
3763
4110
  };
4111
+ const originalReattachAll = executor.reattachAll.bind(executor);
4112
+ executor.reattachAll = async (records) => {
4113
+ const externallyTerminated = await originalReattachAll(records);
4114
+ for (const record of records) {
4115
+ const handle = executor.get(record.vmId);
4116
+ if (handle) attach(handle);
4117
+ }
4118
+ return externallyTerminated;
4119
+ };
3764
4120
  const originalDestroy = executor.destroy.bind(executor);
3765
4121
  executor.destroy = async (vmId) => {
3766
4122
  const entry = relays.get(vmId);
@@ -3855,68 +4211,111 @@ async function startSandboxHealthServer(options) {
3855
4211
  }
3856
4212
 
3857
4213
  // src/cli.ts
4214
+ process.on("unhandledRejection", (reason) => {
4215
+ src_default.error({ err: reason }, "unhandled rejection \u2014 sandbox worker continues running");
4216
+ });
4217
+ process.on("uncaughtException", (error) => {
4218
+ src_default.error({ err: error }, "uncaught exception \u2014 sandbox worker continues running");
4219
+ });
3858
4220
  var program = new import_commander.Command();
3859
4221
  program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
3860
- program.command("start").description("start a worker and connect it to the sandbox control plane").option("--token <token>", "worker authentication token (env: WORKER_TOKEN)").option("--worker-id <id>", "worker pool/authentication ID (env: WORKER_ID; default: hostname)").option("--sandbox-url <url>", "control-plane base URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com/v0)").option("--max-vms <count>", "maximum concurrent VMs (env: MAX_VMS)").action(async (opts) => {
3861
- const config = loadWorkerConfigFromEnv({
3862
- ...process.env,
3863
- ...opts.token ? { WORKER_TOKEN: opts.token } : {},
3864
- ...opts.workerId ? { WORKER_ID: opts.workerId } : {},
3865
- ...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
3866
- ...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
3867
- });
3868
- const socketRef = {};
3869
- const healthServer = await startSandboxHealthServer({
3870
- host: config.healthHost,
3871
- port: config.healthPort,
3872
- pathPrefix: config.healthPathPrefix,
3873
- isReady: () => socketRef.current?.connected === true
3874
- });
3875
- await prepareWorkerImages(config);
3876
- const executor = createExecutor(config);
3877
- const socket = connectToSandbox(config, executor);
3878
- socketRef.current = socket;
3879
- attachConsoleRelays(executor, socket);
3880
- let stopping = false;
3881
- const stop = async (signal) => {
3882
- if (stopping) return;
3883
- stopping = true;
3884
- console.log(`
4222
+ program.command("start").description("start a worker and connect it to the sandbox control plane").option("--token <token>", "worker authentication token (env: WORKER_TOKEN)").option("--worker-id <id>", "worker pool/authentication ID (env: WORKER_ID; default: hostname)").option(
4223
+ "--sandbox-url <url>",
4224
+ "control-plane base URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com/v0)"
4225
+ ).option("--max-vms <count>", "maximum concurrent VMs (env: MAX_VMS)").action(
4226
+ async (opts) => {
4227
+ const config = loadWorkerConfigFromEnv({
4228
+ ...process.env,
4229
+ ...opts.token ? { WORKER_TOKEN: opts.token } : {},
4230
+ ...opts.workerId ? { WORKER_ID: opts.workerId } : {},
4231
+ ...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
4232
+ ...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
4233
+ });
4234
+ const socketRef = {};
4235
+ const healthServer = await startSandboxHealthServer({
4236
+ host: config.healthHost,
4237
+ port: config.healthPort,
4238
+ pathPrefix: config.healthPathPrefix,
4239
+ isReady: () => socketRef.current?.connected === true
4240
+ });
4241
+ await prepareWorkerImages(config);
4242
+ const executor = createExecutor(config);
4243
+ const socket = connectToSandbox(config, executor);
4244
+ socketRef.current = socket;
4245
+ attachConsoleRelays(executor, socket);
4246
+ let stopping = false;
4247
+ const stop = async (signal) => {
4248
+ if (stopping) return;
4249
+ stopping = true;
4250
+ console.log(`
3885
4251
  ${signal} received; shutting down sandbox worker...`);
3886
- socket.disconnect();
3887
- healthServer.close();
3888
- const results = await Promise.allSettled(executor.list().map((vm) => executor.destroy(vm.vmId)));
3889
- const failed = results.filter((result) => result.status === "rejected");
3890
- if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
3891
- process.exit(failed.length ? 1 : 0);
3892
- };
3893
- process.once("SIGINT", () => {
3894
- void stop("SIGINT");
3895
- });
3896
- process.once("SIGTERM", () => {
3897
- void stop("SIGTERM");
3898
- });
3899
- src_default.info(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
3900
- });
4252
+ socket.disconnect();
4253
+ healthServer.close();
4254
+ const results = await Promise.allSettled(
4255
+ executor.list().map((vm) => executor.destroy(vm.vmId))
4256
+ );
4257
+ const failed = results.filter((result) => result.status === "rejected");
4258
+ if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
4259
+ process.exit(failed.length ? 1 : 0);
4260
+ };
4261
+ process.once("SIGINT", () => {
4262
+ void stop("SIGINT");
4263
+ });
4264
+ process.once("SIGTERM", () => {
4265
+ void stop("SIGTERM");
4266
+ });
4267
+ src_default.info(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
4268
+ }
4269
+ );
3901
4270
  program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
3902
- const probes = probeEngines(loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" }));
4271
+ const probes = probeEngines(
4272
+ loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" })
4273
+ );
3903
4274
  for (const probe of probes) {
3904
4275
  const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
3905
4276
  console.log(`${probe.engine.padEnd(15)} ${status}`);
3906
4277
  }
3907
4278
  });
3908
4279
  var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
3909
- images.command("pull").description("download per-stack base images from S3 or an HTTPS mirror into the standard location").requiredOption("--from <location>", "s3://bucket[/prefix] or https:// base URL (default derived from S3_BUCKET_ARTIFACTS)", artifactImageLocation()).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to pull (default: all)").option("--dest <dir>", "destination directory (default: ~/.multiplayer/vm-images/<arch>)").option("--endpoint <url>", "custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)", process.env.S3_ARTIFACTS_ENDPOINT).action(async (opts) => {
3910
- const dest = opts.dest ?? defaultImagesDir(opts.arch);
3911
- const files = await pullImages({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
3912
- for (const file of files) console.log(`pulled ${file}`);
3913
- console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
3914
- });
3915
- images.command("push").description("upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3").requiredOption("--to <location>", "s3://bucket[/prefix] (default derived from S3_BUCKET_ARTIFACTS)", artifactImageLocation()).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to push (default: all found locally)").option("--src <dir>", "source directory (default: ~/.multiplayer/vm-images/<arch>)").option("--endpoint <url>", "custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)", process.env.S3_ARTIFACTS_ENDPOINT).action(async (opts) => {
3916
- const src = opts.src ?? defaultImagesDir(opts.arch);
3917
- const uploaded = await pushImages({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
3918
- for (const uri of uploaded) console.log(`pushed ${uri}`);
3919
- });
4280
+ images.command("pull").description(
4281
+ "download per-stack base images from S3 or an HTTPS mirror into the standard location"
4282
+ ).requiredOption(
4283
+ "--from <location>",
4284
+ "s3://bucket[/prefix] or https:// base URL (default derived from S3_BUCKET_ARTIFACTS)",
4285
+ artifactImageLocation()
4286
+ ).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to pull (default: all)").option("--dest <dir>", "destination directory (default: ~/.multiplayer/vm-images/<arch>)").option(
4287
+ "--endpoint <url>",
4288
+ "custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)",
4289
+ process.env.S3_ARTIFACTS_ENDPOINT
4290
+ ).action(
4291
+ async (opts) => {
4292
+ const dest = opts.dest ?? defaultImagesDir(opts.arch);
4293
+ const files = await pullImages(
4294
+ { location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint },
4295
+ dest
4296
+ );
4297
+ for (const file of files) console.log(`pulled ${file}`);
4298
+ console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
4299
+ }
4300
+ );
4301
+ images.command("push").description("upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3").requiredOption(
4302
+ "--to <location>",
4303
+ "s3://bucket[/prefix] (default derived from S3_BUCKET_ARTIFACTS)",
4304
+ artifactImageLocation()
4305
+ ).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to push (default: all found locally)").option("--src <dir>", "source directory (default: ~/.multiplayer/vm-images/<arch>)").option(
4306
+ "--endpoint <url>",
4307
+ "custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)",
4308
+ process.env.S3_ARTIFACTS_ENDPOINT
4309
+ ).action(
4310
+ async (opts) => {
4311
+ const src = opts.src ?? defaultImagesDir(opts.arch);
4312
+ const uploaded = await pushImages(
4313
+ { location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint },
4314
+ src
4315
+ );
4316
+ for (const uri of uploaded) console.log(`pushed ${uri}`);
4317
+ }
4318
+ );
3920
4319
  program.parseAsync().catch((error) => {
3921
4320
  console.error(`Error: ${error.message}`);
3922
4321
  process.exit(1);