@multiplayer-app/sandbox 7.0.0 → 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.0";
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
  }
@@ -207,25 +235,28 @@ function parseArnList(raw) {
207
235
  const arns = raw.split(",").map((s) => s.trim()).filter(Boolean);
208
236
  return arns.length ? arns : void 0;
209
237
  }
210
- function resolveSandboxEndpoint(rawUrl, explicitSocketPath) {
238
+ function resolveSandboxEndpoint(rawUrl) {
211
239
  const url = new URL(rawUrl);
212
- const basePath = url.pathname.replace(/\/+$/, "") || "/v0";
240
+ const basePath = url.pathname.replace(/\/+$/, "");
213
241
  return {
214
242
  // Socket.IO treats a URL pathname as a namespace, so connect to the origin
215
- // and pass the derived HTTP transport path separately.
243
+ // and pass the derived HTTP transport path separately. SANDBOX_URL's pathname is the
244
+ // sole source of truth for the socket path — it must include whatever base the control
245
+ // plane expects (e.g. "/v0/gapi" or "/internal/v0/gapi"), and "/workers/ws" is appended.
216
246
  sandboxUrl: url.origin,
217
- workerSocketPath: explicitSocketPath ?? `${basePath}/sapi/workers/ws`
247
+ workerSocketPath: `${basePath}/workers/ws`
218
248
  };
219
249
  }
220
250
  function loadWorkerConfigFromEnv(env = process.env) {
221
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`);
222
253
  const token = env.WORKER_TOKEN?.trim() || void 0;
223
254
  const sandboxEndpoint = resolveSandboxEndpoint(
224
- env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0",
225
- env.WORKER_SOCKET_PATH
255
+ env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0/gapi"
226
256
  );
227
257
  return {
228
258
  workerId,
259
+ workerIdFile,
229
260
  token,
230
261
  sandboxUrl: sandboxEndpoint.sandboxUrl,
231
262
  workerSocketPath: sandboxEndpoint.workerSocketPath,
@@ -260,7 +291,10 @@ function loadWorkerConfigFromEnv(env = process.env) {
260
291
  // 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
261
292
  maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
262
293
  suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
263
- 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)
264
298
  },
265
299
  fargate: {
266
300
  region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
@@ -2465,8 +2499,8 @@ var RemoteExecutor = class {
2465
2499
  body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
2466
2500
  };
2467
2501
  }
2468
- request(method, path7, body) {
2469
- const url = new URL(this.base + path7);
2502
+ request(method, path8, body) {
2503
+ const url = new URL(this.base + path8);
2470
2504
  const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
2471
2505
  const payload = body !== void 0 ? JSON.stringify(body) : void 0;
2472
2506
  return new Promise((resolve, reject) => {
@@ -2487,7 +2521,7 @@ var RemoteExecutor = class {
2487
2521
  res.on("end", () => {
2488
2522
  const text = Buffer.concat(chunks).toString();
2489
2523
  if (res.statusCode && res.statusCode >= 400) {
2490
- reject(new Error(`vm-agent ${method} ${path7} \u2192 ${res.statusCode}: ${text}`));
2524
+ reject(new Error(`vm-agent ${method} ${path8} \u2192 ${res.statusCode}: ${text}`));
2491
2525
  return;
2492
2526
  }
2493
2527
  if (res.statusCode === 204 || !text) {
@@ -2514,6 +2548,23 @@ var import_https2 = __toESM(require("https"));
2514
2548
  var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
2515
2549
  var AUTH_TOKEN_TTL_MINUTES = 60;
2516
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
+ }
2517
2568
  var LambdaMicrovmExecutor = class {
2518
2569
  constructor(config) {
2519
2570
  this.config = config;
@@ -2523,10 +2574,15 @@ var LambdaMicrovmExecutor = class {
2523
2574
  async create(spec) {
2524
2575
  const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
2525
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
+ ];
2526
2581
  const run = await this.client.send(
2527
2582
  new import_client_lambda_microvms.RunMicrovmCommand({
2528
2583
  imageIdentifier,
2529
- ingressNetworkConnectors: this.config.ingressConnectorArns,
2584
+ // Required by CreateMicrovmShellAuthToken and the /shell WebSocket used by vm-console.
2585
+ ingressNetworkConnectors,
2530
2586
  egressNetworkConnectors: this.config.egressConnectorArns,
2531
2587
  executionRoleArn: this.config.executionRoleArn,
2532
2588
  maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
@@ -2543,9 +2599,11 @@ var LambdaMicrovmExecutor = class {
2543
2599
  vmId: spec.vmId,
2544
2600
  sessionId: spec.sessionId,
2545
2601
  stack: spec.stack,
2546
- appUrl: spec.appUrl,
2547
- env: spec.env
2548
- })
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
2549
2607
  })
2550
2608
  );
2551
2609
  if (!run.microvmId || !run.endpoint) {
@@ -2608,6 +2666,31 @@ var LambdaMicrovmExecutor = class {
2608
2666
  list() {
2609
2667
  return [...this.vms.values()].map((v) => v.handle);
2610
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
+ }
2611
2694
  async proxyHttp(vmId, request) {
2612
2695
  const vm = this.mustGet(vmId);
2613
2696
  const auth = await this.getAuthHeaders(vm);
@@ -2675,6 +2758,47 @@ var LambdaMicrovmExecutor = class {
2675
2758
  );
2676
2759
  return handle;
2677
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
+ }
2678
2802
  mustGet(vmId) {
2679
2803
  const vm = this.vms.get(vmId);
2680
2804
  if (!vm) throw new Error(`vm ${vmId} not found`);
@@ -3247,6 +3371,8 @@ function detectEngines(config) {
3247
3371
  }
3248
3372
 
3249
3373
  // src/executor/index.ts
3374
+ var VmResourceGoneError = class extends Error {
3375
+ };
3250
3376
  function findNotAttachedVms(executor, records) {
3251
3377
  return records.filter((record) => !executor.get(record.vmId));
3252
3378
  }
@@ -3276,11 +3402,25 @@ var MultiEngineExecutor = class {
3276
3402
  constructor(executors, defaultEngine) {
3277
3403
  this.executors = executors;
3278
3404
  this.defaultEngine = defaultEngine;
3405
+ this.creations = /* @__PURE__ */ new Map();
3279
3406
  }
3280
3407
  get engines() {
3281
3408
  return [...this.executors.keys()];
3282
3409
  }
3283
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) {
3284
3424
  const engine = spec.engine ?? this.defaultEngine;
3285
3425
  const executor = this.executors.get(engine);
3286
3426
  if (!executor) {
@@ -3291,22 +3431,27 @@ var MultiEngineExecutor = class {
3291
3431
  const handle = await executor.create(spec);
3292
3432
  return { ...handle, engine };
3293
3433
  }
3294
- 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) {
3295
3440
  return this.route(vmId).pause(vmId);
3296
3441
  }
3297
- resume(vmId) {
3442
+ async resume(vmId) {
3298
3443
  return this.route(vmId).resume(vmId);
3299
3444
  }
3300
- stop(vmId) {
3445
+ async stop(vmId) {
3301
3446
  return this.route(vmId).stop(vmId);
3302
3447
  }
3303
- restore(vmId) {
3448
+ async restore(vmId) {
3304
3449
  return this.route(vmId).restore(vmId);
3305
3450
  }
3306
- destroy(vmId) {
3451
+ async destroy(vmId) {
3307
3452
  return this.route(vmId).destroy(vmId);
3308
3453
  }
3309
- proxyHttp(vmId, request) {
3454
+ async proxyHttp(vmId, request) {
3310
3455
  return this.route(vmId).proxyHttp(vmId, request);
3311
3456
  }
3312
3457
  get(vmId) {
@@ -3325,15 +3470,14 @@ var MultiEngineExecutor = class {
3325
3470
  }
3326
3471
  return void 0;
3327
3472
  }
3328
- exec(vmId, request) {
3473
+ async exec(vmId, request) {
3329
3474
  const executor = this.route(vmId);
3330
- 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");
3331
3476
  return executor.exec(vmId, request);
3332
3477
  }
3333
- writeFiles(vmId, files) {
3478
+ async writeFiles(vmId, files) {
3334
3479
  const executor = this.route(vmId);
3335
- if (!executor.writeFiles)
3336
- 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");
3337
3481
  return executor.writeFiles(vmId, files);
3338
3482
  }
3339
3483
  onExit(vmId, handler) {
@@ -3341,6 +3485,15 @@ var MultiEngineExecutor = class {
3341
3485
  if (!executor.onExit) throw new Error("executor does not support job run mode");
3342
3486
  executor.onExit(vmId, handler);
3343
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
+ }
3344
3497
  /**
3345
3498
  * Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
3346
3499
  * restart, most likely). Runs in parallel — probing many VMs serially would needlessly
@@ -3349,9 +3502,16 @@ var MultiEngineExecutor = class {
3349
3502
  * never thrown — a VM that can't be reattached just stays unavailable until something
3350
3503
  * touches it again (create/proxy/lifecycle), which already errors gracefully today.
3351
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
+ */
3352
3512
  async reattachAll(records) {
3353
3513
  const pending = findNotAttachedVms(this, records);
3354
- if (!pending.length) return;
3514
+ if (!pending.length) return [];
3355
3515
  const results = await Promise.allSettled(
3356
3516
  pending.map(async (record) => {
3357
3517
  const executor = record.engine ? this.executors.get(record.engine) : void 0;
@@ -3360,18 +3520,25 @@ var MultiEngineExecutor = class {
3360
3520
  if (!executor.reattach)
3361
3521
  throw new Error(`engine "${record.engine}" does not support reattach`);
3362
3522
  const handle = await executor.reattach(record);
3363
- if (!handle) throw new Error("resource no longer exists");
3523
+ if (!handle) throw new VmResourceGoneError();
3364
3524
  })
3365
3525
  );
3526
+ const externallyTerminated = [];
3366
3527
  results.forEach((result, i) => {
3367
3528
  const vmId = pending[i].vmId;
3368
- if (result.status === "fulfilled") src_default.info({ vmId }, "vm reattached");
3369
- 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 {
3370
3535
  src_default.warn(
3371
3536
  { vmId, err: result.reason },
3372
3537
  "vm reattach failed \u2014 staying unavailable until next touch"
3373
3538
  );
3539
+ }
3374
3540
  });
3541
+ return externallyTerminated;
3375
3542
  }
3376
3543
  route(vmId) {
3377
3544
  for (const executor of this.executors.values()) {
@@ -3393,8 +3560,36 @@ function createExecutor(config) {
3393
3560
  // src/socket-client.ts
3394
3561
  var import_os9 = __toESM(require("os"));
3395
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
3396
3588
  var HEARTBEAT_INTERVAL_MS = 1e4;
3589
+ var RECONCILE_INTERVAL_MS = 6e4;
3397
3590
  var REATTACH_FALLBACK_MS = 5e3;
3591
+ var RECONNECTION_DELAY_MS = 1e3;
3592
+ var RECONNECTION_DELAY_MAX_MS = 1e4;
3398
3593
  function primaryIp() {
3399
3594
  for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
3400
3595
  for (const iface of ifaces ?? []) {
@@ -3423,7 +3618,11 @@ function wrap(handler) {
3423
3618
  return (payload, callback) => {
3424
3619
  handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
3425
3620
  src_default.error({ err: error }, "command failed");
3426
- 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
+ }
3427
3626
  });
3428
3627
  };
3429
3628
  }
@@ -3431,20 +3630,32 @@ function registerCommandHandlers(socket, executor) {
3431
3630
  socket.on(
3432
3631
  WorkerEvent.VM_CREATE,
3433
3632
  wrap(async (spec) => {
3434
- const handle = await executor.create(spec);
3435
- if (spec.runMode === "job") {
3436
- try {
3633
+ try {
3634
+ const handle = await executor.create(spec);
3635
+ if (spec.runMode === "job") {
3437
3636
  executor.onExit(
3438
3637
  spec.vmId,
3439
- (result) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result })
3638
+ (result2) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result2 })
3440
3639
  );
3441
- } catch (error) {
3442
- await executor.destroy(spec.vmId).catch(() => {
3443
- });
3444
- throw error;
3445
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;
3446
3658
  }
3447
- return handle;
3448
3659
  })
3449
3660
  );
3450
3661
  socket.on(
@@ -3496,7 +3707,7 @@ function registerCommandHandlers(socket, executor) {
3496
3707
  );
3497
3708
  }
3498
3709
  function connectToSandbox(config, executor) {
3499
- let assignedWorkerId;
3710
+ let assignedWorkerId = loadPersistedWorkerId(config.workerIdFile);
3500
3711
  const socket = (0, import_socket.io)(config.sandboxUrl, {
3501
3712
  path: config.workerSocketPath,
3502
3713
  auth: (callback) => callback({
@@ -3505,12 +3716,17 @@ function connectToSandbox(config, executor) {
3505
3716
  ...config.token ? { token: config.token } : {}
3506
3717
  }),
3507
3718
  transports: ["websocket"],
3508
- reconnection: true
3719
+ reconnection: true,
3720
+ reconnectionAttempts: Infinity,
3721
+ reconnectionDelay: RECONNECTION_DELAY_MS,
3722
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX_MS
3509
3723
  });
3510
3724
  let heartbeatTimer;
3725
+ let reconcileTimer;
3511
3726
  let reattachFallbackTimer;
3512
3727
  socket.on(WorkerEvent.ASSIGNED_ID, ({ workerId }) => {
3513
3728
  assignedWorkerId = workerId;
3729
+ savePersistedWorkerId(config.workerIdFile, workerId);
3514
3730
  src_default.info({ workerId: config.workerId, assignedWorkerId }, "worker runtime ID assigned");
3515
3731
  });
3516
3732
  const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
@@ -3519,6 +3735,17 @@ function connectToSandbox(config, executor) {
3519
3735
  sendHeartbeat();
3520
3736
  heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
3521
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
+ }
3522
3749
  socket.on("connect", () => {
3523
3750
  src_default.info(
3524
3751
  { workerId: config.workerId, assignedWorkerId, url: config.sandboxUrl },
@@ -3531,10 +3758,26 @@ function connectToSandbox(config, executor) {
3531
3758
  "no reattach handshake from sandbox \u2014 starting heartbeat anyway"
3532
3759
  );
3533
3760
  startHeartbeat();
3761
+ startReconcile();
3534
3762
  }, REATTACH_FALLBACK_MS);
3535
3763
  socket.once(WorkerEvent.REATTACH, (records) => {
3536
3764
  clearTimeout(reattachFallbackTimer);
3537
- 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
+ }
3538
3781
  for (const record of records) {
3539
3782
  if (record.runMode !== "job" || !executor.get(record.vmId)) continue;
3540
3783
  try {
@@ -3550,21 +3793,36 @@ function connectToSandbox(config, executor) {
3550
3793
  }
3551
3794
  }
3552
3795
  startHeartbeat();
3796
+ startReconcile();
3553
3797
  });
3554
3798
  });
3555
3799
  });
3556
3800
  socket.on("disconnect", (reason) => {
3557
3801
  src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
3558
3802
  clearInterval(heartbeatTimer);
3803
+ clearInterval(reconcileTimer);
3559
3804
  clearTimeout(reattachFallbackTimer);
3560
3805
  heartbeatTimer = void 0;
3806
+ reconcileTimer = void 0;
3561
3807
  socket.off(WorkerEvent.REATTACH);
3562
3808
  });
3563
3809
  socket.on("connect_error", (error) => {
3564
- src_default.error({
3565
- err: error,
3566
- sandboxUrl: config.sandboxUrl
3567
- }, "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");
3568
3826
  });
3569
3827
  registerCommandHandlers(socket, executor);
3570
3828
  return socket;
@@ -3674,19 +3932,42 @@ var VncRelay = class {
3674
3932
 
3675
3933
  // src/services/serial-relay.ts
3676
3934
  var import_net3 = __toESM(require("net"));
3935
+ var import_ws = __toESM(require("ws"));
3677
3936
  var SerialRelay = class {
3678
3937
  constructor(vmId, source, socket) {
3679
3938
  this.vmId = vmId;
3680
3939
  this.source = source;
3681
3940
  this.socket = socket;
3682
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;
3683
3948
  this.inputHandler = null;
3949
+ this.attachHandler = null;
3950
+ this.detachHandler = null;
3684
3951
  }
3685
3952
  connect() {
3953
+ this.destroyed = false;
3686
3954
  if (this.source.type === "tcp") {
3687
3955
  this.connectTcp();
3688
- } else {
3956
+ } else if (this.source.type === "pipe") {
3689
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);
3690
3971
  }
3691
3972
  this.inputHandler = (e) => {
3692
3973
  if (e.vmId !== this.vmId) return;
@@ -3695,6 +3976,15 @@ var SerialRelay = class {
3695
3976
  } else if (this.source.type === "pipe") {
3696
3977
  const proc = this.source.process;
3697
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
+ }
3698
3988
  }
3699
3989
  };
3700
3990
  this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
@@ -3713,7 +4003,9 @@ var SerialRelay = class {
3713
4003
  sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
3714
4004
  sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
3715
4005
  });
3716
- sock.once("error", () => setTimeout(attempt, 1e3));
4006
+ sock.once("error", () => {
4007
+ if (!this.destroyed) setTimeout(attempt, 1e3);
4008
+ });
3717
4009
  };
3718
4010
  attempt();
3719
4011
  }
@@ -3728,13 +4020,65 @@ var SerialRelay = class {
3728
4020
  (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d })
3729
4021
  );
3730
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
+ }
3731
4071
  destroy() {
4072
+ this.destroyed = true;
3732
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);
3733
4076
  try {
3734
4077
  this.conn?.destroy();
3735
4078
  } catch {
3736
4079
  }
3737
4080
  this.conn = null;
4081
+ this.disconnectWebSocket();
3738
4082
  src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
3739
4083
  }
3740
4084
  };
@@ -3742,9 +4086,8 @@ var SerialRelay = class {
3742
4086
  // src/console-relays.ts
3743
4087
  function attachConsoleRelays(executor, sandboxSocket) {
3744
4088
  const relays = /* @__PURE__ */ new Map();
3745
- const originalCreate = executor.create.bind(executor);
3746
- executor.create = async (spec) => {
3747
- const handle = await originalCreate(spec);
4089
+ const attach = (handle) => {
4090
+ if (relays.has(handle.vmId)) return;
3748
4091
  const entry = {};
3749
4092
  if (handle.vncPort) {
3750
4093
  const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
@@ -3758,8 +4101,22 @@ function attachConsoleRelays(executor, sandboxSocket) {
3758
4101
  entry.serial = serial;
3759
4102
  }
3760
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);
3761
4109
  return handle;
3762
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
+ };
3763
4120
  const originalDestroy = executor.destroy.bind(executor);
3764
4121
  executor.destroy = async (vmId) => {
3765
4122
  const entry = relays.get(vmId);
@@ -3854,68 +4211,111 @@ async function startSandboxHealthServer(options) {
3854
4211
  }
3855
4212
 
3856
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
+ });
3857
4220
  var program = new import_commander.Command();
3858
4221
  program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
3859
- 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) => {
3860
- const config = loadWorkerConfigFromEnv({
3861
- ...process.env,
3862
- ...opts.token ? { WORKER_TOKEN: opts.token } : {},
3863
- ...opts.workerId ? { WORKER_ID: opts.workerId } : {},
3864
- ...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
3865
- ...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
3866
- });
3867
- const socketRef = {};
3868
- const healthServer = await startSandboxHealthServer({
3869
- host: config.healthHost,
3870
- port: config.healthPort,
3871
- pathPrefix: config.healthPathPrefix,
3872
- isReady: () => socketRef.current?.connected === true
3873
- });
3874
- await prepareWorkerImages(config);
3875
- const executor = createExecutor(config);
3876
- const socket = connectToSandbox(config, executor);
3877
- socketRef.current = socket;
3878
- attachConsoleRelays(executor, socket);
3879
- let stopping = false;
3880
- const stop = async (signal) => {
3881
- if (stopping) return;
3882
- stopping = true;
3883
- 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(`
3884
4251
  ${signal} received; shutting down sandbox worker...`);
3885
- socket.disconnect();
3886
- healthServer.close();
3887
- const results = await Promise.allSettled(executor.list().map((vm) => executor.destroy(vm.vmId)));
3888
- const failed = results.filter((result) => result.status === "rejected");
3889
- if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
3890
- process.exit(failed.length ? 1 : 0);
3891
- };
3892
- process.once("SIGINT", () => {
3893
- void stop("SIGINT");
3894
- });
3895
- process.once("SIGTERM", () => {
3896
- void stop("SIGTERM");
3897
- });
3898
- src_default.info(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
3899
- });
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
+ );
3900
4270
  program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
3901
- const probes = probeEngines(loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" }));
4271
+ const probes = probeEngines(
4272
+ loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" })
4273
+ );
3902
4274
  for (const probe of probes) {
3903
4275
  const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
3904
4276
  console.log(`${probe.engine.padEnd(15)} ${status}`);
3905
4277
  }
3906
4278
  });
3907
4279
  var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
3908
- 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) => {
3909
- const dest = opts.dest ?? defaultImagesDir(opts.arch);
3910
- const files = await pullImages({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
3911
- for (const file of files) console.log(`pulled ${file}`);
3912
- console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
3913
- });
3914
- 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) => {
3915
- const src = opts.src ?? defaultImagesDir(opts.arch);
3916
- const uploaded = await pushImages({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
3917
- for (const uri of uploaded) console.log(`pushed ${uri}`);
3918
- });
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
+ );
3919
4319
  program.parseAsync().catch((error) => {
3920
4320
  console.error(`Error: ${error.message}`);
3921
4321
  process.exit(1);