@multiplayer-app/sandbox 2.0.0-wip-yjs-state-changes.0 → 3.0.0-wip-yjs-state-changes.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 (68) hide show
  1. package/README.md +4 -0
  2. package/dist/cli.js +3011 -97
  3. package/dist/cli.js.map +7 -1
  4. package/dist/executor/detect.d.ts +1 -1
  5. package/dist/executor/docker-executor.d.ts +1 -1
  6. package/dist/executor/docker-sandbox-executor.d.ts +1 -1
  7. package/dist/executor/fargate-executor.d.ts +1 -1
  8. package/dist/executor/firecracker-executor.d.ts +1 -1
  9. package/dist/executor/index.d.ts +1 -1
  10. package/dist/executor/lambda-microvm-executor.d.ts +1 -1
  11. package/dist/executor/mock-executor.d.ts +1 -1
  12. package/dist/executor/qemu-executor.d.ts +1 -1
  13. package/dist/executor/remote-executor.d.ts +1 -1
  14. package/dist/executor/types.d.ts +1 -1
  15. package/dist/images.d.ts +1 -1
  16. package/dist/index.js +3005 -29
  17. package/dist/index.js.map +7 -1
  18. package/dist/vendor/builder-types/access.d.ts +25 -0
  19. package/dist/vendor/builder-types/access.d.ts.map +1 -0
  20. package/dist/vendor/builder-types/events.d.ts +80 -0
  21. package/dist/vendor/builder-types/events.d.ts.map +1 -0
  22. package/dist/vendor/builder-types/index.d.ts +11 -0
  23. package/dist/vendor/builder-types/index.d.ts.map +1 -0
  24. package/dist/vendor/builder-types/release.d.ts +49 -0
  25. package/dist/vendor/builder-types/release.d.ts.map +1 -0
  26. package/dist/vendor/builder-types/vm.d.ts +101 -0
  27. package/dist/vendor/builder-types/vm.d.ts.map +1 -0
  28. package/dist/vendor/builder-types/worker.d.ts +25 -0
  29. package/dist/vendor/builder-types/worker.d.ts.map +1 -0
  30. package/package.json +15 -6
  31. package/dist/config.js +0 -144
  32. package/dist/config.js.map +0 -1
  33. package/dist/console-relays.js +0 -46
  34. package/dist/console-relays.js.map +0 -1
  35. package/dist/executor/detect.js +0 -184
  36. package/dist/executor/detect.js.map +0 -1
  37. package/dist/executor/docker-executor.js +0 -331
  38. package/dist/executor/docker-executor.js.map +0 -1
  39. package/dist/executor/docker-sandbox-executor.js +0 -343
  40. package/dist/executor/docker-sandbox-executor.js.map +0 -1
  41. package/dist/executor/fargate-executor.js +0 -293
  42. package/dist/executor/fargate-executor.js.map +0 -1
  43. package/dist/executor/firecracker-executor.js +0 -352
  44. package/dist/executor/firecracker-executor.js.map +0 -1
  45. package/dist/executor/index.js +0 -166
  46. package/dist/executor/index.js.map +0 -1
  47. package/dist/executor/lambda-microvm-executor.js +0 -211
  48. package/dist/executor/lambda-microvm-executor.js.map +0 -1
  49. package/dist/executor/mock-executor.js +0 -198
  50. package/dist/executor/mock-executor.js.map +0 -1
  51. package/dist/executor/mock-executor.test.js +0 -81
  52. package/dist/executor/mock-executor.test.js.map +0 -1
  53. package/dist/executor/qemu-executor.js +0 -383
  54. package/dist/executor/qemu-executor.js.map +0 -1
  55. package/dist/executor/reattach-utils.js +0 -11
  56. package/dist/executor/reattach-utils.js.map +0 -1
  57. package/dist/executor/remote-executor.js +0 -125
  58. package/dist/executor/remote-executor.js.map +0 -1
  59. package/dist/executor/types.js +0 -3
  60. package/dist/executor/types.js.map +0 -1
  61. package/dist/images.js +0 -163
  62. package/dist/images.js.map +0 -1
  63. package/dist/services/serial-relay.js +0 -74
  64. package/dist/services/serial-relay.js.map +0 -1
  65. package/dist/services/vnc-relay.js +0 -101
  66. package/dist/services/vnc-relay.js.map +0 -1
  67. package/dist/socket-client.js +0 -119
  68. package/dist/socket-client.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,105 +1,3019 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- /* eslint-disable no-console -- console output is the CLI's user interface */
5
- // Worker CLI. Published as the global `sandbox` executable and also available locally via
6
- // `pnpm --filter @multiplayer-app/sandbox run cli -- <args>`.
7
- require("dotenv/config");
8
- const commander_1 = require("commander");
9
- const package_json_1 = require("../package.json");
10
- const config_1 = require("./config");
11
- const executor_1 = require("./executor");
12
- const detect_1 = require("./executor/detect");
13
- const images_1 = require("./images");
14
- const socket_client_1 = require("./socket-client");
15
- const console_relays_1 = require("./console-relays");
16
- const program = new commander_1.Command();
17
- program
18
- .name('sandbox')
19
- .description('Run and manage a Multiplayer sandbox worker')
20
- .version(package_json_1.version);
21
- program
22
- .command('start')
23
- .description('start a worker and connect it to the sandbox control plane')
24
- .option('--token <token>', 'worker authentication token (env: WORKER_TOKEN)')
25
- .option('--worker-id <id>', 'stable worker ID (env: WORKER_ID; default: hostname)')
26
- .option('--sandbox-url <url>', 'control-plane URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com)')
27
- .option('--max-vms <count>', 'maximum concurrent VMs (env: MAX_VMS)')
28
- .action(async (opts) => {
29
- const config = (0, config_1.loadWorkerConfigFromEnv)({
30
- ...process.env,
31
- ...(opts.token ? { WORKER_TOKEN: opts.token } : {}),
32
- ...(opts.workerId ? { WORKER_ID: opts.workerId } : {}),
33
- ...(opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {}),
34
- ...(opts.maxVms ? { MAX_VMS: opts.maxVms } : {}),
35
- });
36
- const executor = (0, executor_1.createExecutor)(config);
37
- const socket = (0, socket_client_1.connectToSandbox)(config, executor);
38
- (0, console_relays_1.attachConsoleRelays)(executor, socket);
39
- let stopping = false;
40
- const stop = async (signal) => {
41
- if (stopping)
42
- return;
43
- stopping = true;
44
- console.log(`\n${signal} received; shutting down sandbox worker...`);
45
- socket.disconnect();
46
- const results = await Promise.allSettled(executor.list().map((vm) => executor.destroy(vm.vmId)));
47
- const failed = results.filter((result) => result.status === 'rejected');
48
- if (failed.length)
49
- console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
50
- process.exit(failed.length ? 1 : 0);
51
- };
52
- process.once('SIGINT', () => { void stop('SIGINT'); });
53
- process.once('SIGTERM', () => { void stop('SIGTERM'); });
54
- console.log(`Starting sandbox worker ${config.workerId} → ${config.sandboxUrl}`);
55
- // Socket.IO's reconnect timers keep the process alive until a termination signal arrives.
56
- });
57
- program
58
- .command('detect')
59
- .description('probe this host and report which micro-VM engines it can run')
60
- .action(() => {
61
- const probes = (0, detect_1.probeEngines)((0, config_1.loadWorkerConfigFromEnv)({ ...process.env, WORKER_ID: '-', WORKER_TOKEN: '-' }));
62
- for (const probe of probes) {
63
- const status = probe.available ? 'available' : `unavailable — ${probe.reason}`;
64
- console.log(`${probe.engine.padEnd(15)} ${status}`);
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_config5 = require("dotenv/config");
28
+ var import_commander = require("commander");
29
+
30
+ // package.json
31
+ var version = "3.0.0-wip-yjs-state-changes.0";
32
+
33
+ // src/config.ts
34
+ var import_os2 = __toESM(require("os"));
35
+ var import_path2 = __toESM(require("path"));
36
+
37
+ // src/images.ts
38
+ var import_fs = __toESM(require("fs"));
39
+ var import_os = __toESM(require("os"));
40
+ var import_path = __toESM(require("path"));
41
+ var import_promises = require("stream/promises");
42
+ var import_stream = require("stream");
43
+
44
+ // ../builder-types/src/vm.ts
45
+ var VM_STACKS = ["generic", "node", "python", "static"];
46
+
47
+ // ../builder-types/src/events.ts
48
+ var WorkerEvent = {
49
+ // worker → sandbox
50
+ HEARTBEAT: "worker:heartbeat",
51
+ // sandbox → worker, once right after auth on every (re)connect — the VMs sandbox's
52
+ // records say this workerId owns, so the worker can try to reattach before its first
53
+ // heartbeat. Always sent, even with an empty array, as a handshake signal.
54
+ REATTACH: "worker:reattach",
55
+ // sandbox → worker (VM lifecycle commands)
56
+ VM_CREATE: "vm:create",
57
+ VM_PAUSE: "vm:pause",
58
+ VM_RESUME: "vm:resume",
59
+ VM_STOP: "vm:stop",
60
+ VM_RESTORE: "vm:restore",
61
+ VM_DESTROY: "vm:destroy",
62
+ // sandbox / gateway → worker (HTTP proxy)
63
+ PROXY_HTTP: "proxy:http",
64
+ // sandbox → worker (guest workspace commands)
65
+ VM_EXEC: "vm:exec",
66
+ VM_WRITE_FILES: "vm:write-files",
67
+ // worker → sandbox (VNC/terminal media relay)
68
+ VNC_INIT: "vnc:init",
69
+ VNC_FRAME: "vnc:frame",
70
+ VNC_COPY_FRAME: "vnc:copy-frame",
71
+ TERMINAL_DATA: "terminal:data",
72
+ // sandbox → worker (browser input forwarded downstream)
73
+ VNC_MOUSE: "vnc:mouse",
74
+ VNC_KEYBOARD: "vnc:keyboard",
75
+ TERMINAL_INPUT: "terminal:input"
76
+ };
77
+ var VM_ACTION_EVENTS = {
78
+ pause: WorkerEvent.VM_PAUSE,
79
+ resume: WorkerEvent.VM_RESUME,
80
+ stop: WorkerEvent.VM_STOP,
81
+ restore: WorkerEvent.VM_RESTORE
82
+ };
83
+
84
+ // src/images.ts
85
+ var IMAGE_FILES = ["vmlinuz", "initramfs.img"];
86
+ function hostArch() {
87
+ switch (process.arch) {
88
+ case "arm64":
89
+ return "aarch64";
90
+ case "x64":
91
+ return "x86_64";
92
+ default:
93
+ return process.arch;
94
+ }
95
+ }
96
+ function defaultImagesDir(arch = hostArch()) {
97
+ return import_path.default.join(import_os.default.homedir(), ".multiplayer", "vm-images", arch);
98
+ }
99
+ function resolveStackImages(imagesDir, stack) {
100
+ const kernelPath = import_path.default.join(imagesDir, stack, "vmlinuz");
101
+ const initrdPath = import_path.default.join(imagesDir, stack, "initramfs.img");
102
+ return import_fs.default.existsSync(kernelPath) && import_fs.default.existsSync(initrdPath) ? { kernelPath, initrdPath } : void 0;
103
+ }
104
+ function availableStacks(imagesDir) {
105
+ return VM_STACKS.filter((stack) => resolveStackImages(imagesDir, stack) !== void 0);
106
+ }
107
+ function parseS3Uri(uri) {
108
+ const match = /^s3:\/\/([^/]+)\/?(.*)$/.exec(uri);
109
+ if (!match) throw new Error(`invalid S3 URI "${uri}" \u2014 expected s3://bucket[/prefix]`);
110
+ return { bucket: match[1], prefix: match[2].replace(/\/+$/, "") };
111
+ }
112
+ function s3Key(prefix, arch, stack, file) {
113
+ return [prefix, arch, stack, file].filter(Boolean).join("/");
114
+ }
115
+ async function makeS3Client(endpoint) {
116
+ const { S3Client } = await import("@aws-sdk/client-s3");
117
+ return new S3Client({
118
+ ...endpoint ? { endpoint, forcePathStyle: true } : {},
119
+ ...process.env.AWS_REGION ? {} : { region: "us-east-1" },
120
+ // aws-chunked checksum streaming breaks older S3-compatible stores (MinIO)
121
+ requestChecksumCalculation: "WHEN_REQUIRED"
122
+ });
123
+ }
124
+ async function downloadFromS3(opts, stack, file, dest) {
125
+ const { GetObjectCommand } = await import("@aws-sdk/client-s3");
126
+ const { bucket, prefix } = parseS3Uri(opts.location);
127
+ const client = await makeS3Client(opts.endpoint);
128
+ const key = s3Key(prefix, opts.arch, stack, file);
129
+ const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
130
+ if (!response.Body) throw new Error(`empty response for s3://${bucket}/${key}`);
131
+ await (0, import_promises.pipeline)(response.Body, import_fs.default.createWriteStream(dest));
132
+ }
133
+ async function downloadFromHttp(opts, stack, file, dest) {
134
+ const url = `${opts.location.replace(/\/+$/, "")}/${opts.arch}/${stack}/${file}`;
135
+ const response = await fetch(url);
136
+ if (!response.ok || !response.body) {
137
+ throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
138
+ }
139
+ await (0, import_promises.pipeline)(import_stream.Readable.fromWeb(response.body), import_fs.default.createWriteStream(dest));
140
+ }
141
+ async function pullImages(opts, destDir) {
142
+ const isS3 = opts.location.startsWith("s3://");
143
+ const downloaded = [];
144
+ for (const stack of opts.stacks ?? VM_STACKS) {
145
+ const stackDir = import_path.default.join(destDir, stack);
146
+ import_fs.default.mkdirSync(stackDir, { recursive: true });
147
+ for (const file of IMAGE_FILES) {
148
+ const dest = import_path.default.join(stackDir, file);
149
+ const partial = `${dest}.partial`;
150
+ if (isS3) await downloadFromS3(opts, stack, file, partial);
151
+ else await downloadFromHttp(opts, stack, file, partial);
152
+ import_fs.default.renameSync(partial, dest);
153
+ downloaded.push(dest);
154
+ }
155
+ }
156
+ return downloaded;
157
+ }
158
+ async function pushImages(opts, srcDir) {
159
+ const { PutObjectCommand } = await import("@aws-sdk/client-s3");
160
+ const { bucket, prefix } = parseS3Uri(opts.location);
161
+ const client = await makeS3Client(opts.endpoint);
162
+ const uploaded = [];
163
+ const stacks = opts.stacks ?? availableStacks(srcDir);
164
+ if (!stacks.length) {
165
+ throw new Error(`no stack images found under ${srcDir} \u2014 run scripts/setup-vm-images.sh first`);
166
+ }
167
+ for (const stack of stacks) {
168
+ for (const file of IMAGE_FILES) {
169
+ const src = import_path.default.join(srcDir, stack, file);
170
+ if (!import_fs.default.existsSync(src)) {
171
+ throw new Error(`${src} not found \u2014 run scripts/setup-vm-images.sh first`);
172
+ }
173
+ const key = s3Key(prefix, opts.arch, stack, file);
174
+ await client.send(new PutObjectCommand({
175
+ Bucket: bucket,
176
+ Key: key,
177
+ Body: import_fs.default.readFileSync(src)
178
+ }));
179
+ uploaded.push(`s3://${bucket}/${key}`);
180
+ }
181
+ }
182
+ return uploaded;
183
+ }
184
+
185
+ // src/config.ts
186
+ function parseStackMap(raw) {
187
+ const map = {};
188
+ if (!raw) return map;
189
+ for (const pair of raw.split(",")) {
190
+ const idx = pair.indexOf(":");
191
+ if (idx === -1) continue;
192
+ const stack = pair.slice(0, idx).trim();
193
+ const arn = pair.slice(idx + 1).trim();
194
+ if (stack && arn) map[stack] = arn;
195
+ }
196
+ return map;
197
+ }
198
+ function parseArnList(raw) {
199
+ if (!raw) return void 0;
200
+ const arns = raw.split(",").map((s) => s.trim()).filter(Boolean);
201
+ return arns.length ? arns : void 0;
202
+ }
203
+ function loadWorkerConfigFromEnv(env = process.env) {
204
+ const workerId = env.WORKER_ID?.trim() || import_os2.default.hostname();
205
+ const token = env.WORKER_TOKEN;
206
+ if (!token) {
207
+ throw new Error("WORKER_TOKEN is required");
208
+ }
209
+ return {
210
+ workerId,
211
+ token,
212
+ sandboxUrl: env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com",
213
+ workerSocketPath: env.WORKER_SOCKET_PATH ?? "/v0/sapi/workers/ws",
214
+ maxVms: Number(env.MAX_VMS) || 10,
215
+ region: env.WORKER_REGION,
216
+ remote: {
217
+ // No default: an unset AGENT_URL means the remote engine is not configured on this worker
218
+ agentUrl: env.AGENT_URL ?? "",
219
+ authToken: env.AGENT_TOKEN
220
+ },
221
+ firecracker: {
222
+ bin: env.FIRECRACKER_BIN ?? "firecracker",
223
+ // Per-stack base images (<imagesDir>/<stack>/vmlinuz + initramfs.img) from
224
+ // `worker-cli images pull` / setup-vm-images.sh; engine detection checks
225
+ // existence, so missing images just disable the engine.
226
+ imagesDir: env.FIRECRACKER_IMAGES_DIR ?? env.VM_IMAGES_DIR ?? defaultImagesDir(),
227
+ // Explicit single-image override, applied to every stack when both are set
228
+ kernelPath: env.FIRECRACKER_KERNEL,
229
+ initrdPath: env.FIRECRACKER_INITRD,
230
+ vmDataPath: env.FIRECRACKER_VM_DATA ?? import_path2.default.join(import_os2.default.tmpdir(), "firecracker-vms")
231
+ },
232
+ lambdaMicrovm: {
233
+ region: env.MICROVM_AWS_REGION ?? env.AWS_REGION,
234
+ imageArn: env.MICROVM_IMAGE_ARN ?? "",
235
+ stackImageArns: parseStackMap(env.MICROVM_STACK_IMAGE_ARNS),
236
+ ingressConnectorArns: parseArnList(env.MICROVM_INGRESS_CONNECTORS),
237
+ egressConnectorArns: parseArnList(env.MICROVM_EGRESS_CONNECTORS),
238
+ executionRoleArn: env.MICROVM_EXECUTION_ROLE_ARN,
239
+ // 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
240
+ maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
241
+ suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
242
+ maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800)
243
+ },
244
+ fargate: {
245
+ region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
246
+ cluster: env.FARGATE_CLUSTER ?? "",
247
+ taskDefinition: env.FARGATE_TASK_DEFINITION,
248
+ // Per-stack task definitions: "node:builder-node:3,python:builder-python:1"
249
+ stackTaskDefinitions: parseStackMap(env.FARGATE_STACK_TASK_DEFS),
250
+ subnets: env.FARGATE_SUBNETS?.split(",").map((s) => s.trim()).filter(Boolean) ?? [],
251
+ securityGroups: env.FARGATE_SECURITY_GROUPS?.split(",").map((s) => s.trim()).filter(Boolean),
252
+ // ENABLED when the worker runs outside the task VPC (proxies to the task's public IP)
253
+ assignPublicIp: env.FARGATE_ASSIGN_PUBLIC_IP === "true",
254
+ guestPort: Number(env.FARGATE_GUEST_PORT) || 8080,
255
+ startTimeoutMs: Number(env.FARGATE_START_TIMEOUT_MS) || 18e4
256
+ },
257
+ docker: {
258
+ bin: env.DOCKER_BIN ?? "docker",
259
+ // Local images from `scripts/setup-docker-images.sh`: <imagePrefix>-<stack>:<imageTag>
260
+ imagePrefix: env.DOCKER_IMAGE_PREFIX ?? "multiplayer-builder-vm",
261
+ imageTag: env.DOCKER_IMAGE_TAG ?? "local",
262
+ stackImages: parseStackMap(env.DOCKER_STACK_IMAGES),
263
+ network: env.DOCKER_NETWORK,
264
+ portRangeStart: Number(env.DOCKER_PORT_MIN) || 9100,
265
+ portRangeEnd: Number(env.DOCKER_PORT_MAX) || 9199,
266
+ guestPort: Number(env.DOCKER_GUEST_PORT) || 8080
267
+ },
268
+ dockerSandbox: {
269
+ bin: env.SBX_BIN ?? "sbx",
270
+ // Bridged from the same local images `docker` (imagePrefix-imageTag) already uses —
271
+ // `docker save` + `sbx template load`, see docker-sandbox-executor.ts.
272
+ imagePrefix: env.SBX_IMAGE_PREFIX ?? "multiplayer-builder-vm",
273
+ imageTag: env.SBX_IMAGE_TAG ?? "local",
274
+ stackTemplates: parseStackMap(env.SBX_STACK_TEMPLATES),
275
+ // `sbx create shell` doesn't run the image's own CMD, so the engine launches
276
+ // it explicitly — defaults match each Dockerfile's own WORKDIR /app + CMD.
277
+ stackAgentCommands: {
278
+ node: "node app.js",
279
+ python: "python app.py",
280
+ static: "node app.js",
281
+ ...parseStackMap(env.SBX_STACK_AGENT_COMMANDS)
282
+ },
283
+ workspaceRoot: env.SBX_WORKSPACE_DIR ?? import_path2.default.join(import_os2.default.tmpdir(), "sbx-vms"),
284
+ portRangeStart: Number(env.SBX_PORT_MIN) || 9200,
285
+ portRangeEnd: Number(env.SBX_PORT_MAX) || 9299,
286
+ guestPort: Number(env.SBX_GUEST_PORT) || 8080,
287
+ // sbx rejects sandboxes below 1 GiB memory.
288
+ minMemoryMib: Number(env.SBX_MIN_MEMORY_MIB) || 1024
289
+ },
290
+ qemu: {
291
+ bin: env.QEMU_BIN ?? "qemu-system-aarch64",
292
+ // Per-stack base images (<imagesDir>/<stack>/vmlinuz + initramfs.img) from
293
+ // `worker-cli images pull` / setup-vm-images.sh; engine detection checks
294
+ // existence, so missing images just disable the engine.
295
+ imagesDir: env.QEMU_IMAGES_DIR ?? env.VM_IMAGES_DIR ?? defaultImagesDir(),
296
+ // Explicit single-image override, applied to every stack when both are set
297
+ kernelPath: env.QEMU_KERNEL,
298
+ initrdPath: env.QEMU_INITRD,
299
+ vmDataPath: env.QEMU_VM_DATA ?? import_path2.default.join(import_os2.default.tmpdir(), "qemu-vms"),
300
+ portRangeStart: Number(env.QEMU_PORT_MIN) || 9e3,
301
+ portRangeEnd: Number(env.QEMU_PORT_MAX) || 9999,
302
+ // Accelerator: hvf (macOS host), kvm (Linux + /dev/kvm), tcg (software, any host — slow)
303
+ accel: env.QEMU_ACCEL ?? (process.platform === "darwin" ? "hvf" : "kvm"),
304
+ // CPU model: host requires hvf/kvm; max is best for tcg on aarch64; qemu64 for tcg x86_64
305
+ cpu: env.QEMU_CPU ?? "host",
306
+ // Machine type: virt (aarch64), q35 (x86_64)
307
+ machine: env.QEMU_MACHINE ?? "virt"
308
+ }
309
+ };
310
+ }
311
+
312
+ // ../logger/src/logger.ts
313
+ var import_bunyan = __toESM(require("bunyan"));
314
+ var import_os3 = require("os");
315
+
316
+ // ../logger/src/config.ts
317
+ var NODE_ENV = process.env.NODE_ENV || "development";
318
+ var isProduction = NODE_ENV === "production";
319
+ var APP_NAME = process.env.npm_package_name?.split("/").pop() || "tests";
320
+ var LOG_LEVEL = process.env.LOG_LEVEL || (isProduction ? "info" : "debug");
321
+
322
+ // ../logger/src/logger.ts
323
+ var HOST_NAME = (0, import_os3.hostname)();
324
+ var loggerOptions = {
325
+ name: APP_NAME,
326
+ level: LOG_LEVEL,
327
+ hostname: HOST_NAME,
328
+ serializers: import_bunyan.default.stdSerializers
329
+ };
330
+ var logger = import_bunyan.default.createLogger(loggerOptions);
331
+
332
+ // ../logger/src/kafkajs-logger-creator.ts
333
+ var import_bunyan2 = __toESM(require("bunyan"));
334
+ var import_os4 = require("os");
335
+ var HOST_NAME2 = (0, import_os4.hostname)();
336
+
337
+ // ../logger/src/index.ts
338
+ var src_default = logger;
339
+
340
+ // src/executor/mock-executor.ts
341
+ var import_child_process = require("child_process");
342
+ var import_promises2 = __toESM(require("fs/promises"));
343
+ var import_http = __toESM(require("http"));
344
+ var import_os5 = __toESM(require("os"));
345
+ var import_path3 = __toESM(require("path"));
346
+ var DEFAULT_EXEC_TIMEOUT_MS = 6e4;
347
+ var MAX_EXEC_TIMEOUT_MS = 3e5;
348
+ var MAX_OUTPUT_BYTES = 1024 * 1024;
349
+ var MAX_FILE_BYTES = 5 * 1024 * 1024;
350
+ var CONTENT_TYPES = {
351
+ ".html": "text/html; charset=utf-8",
352
+ ".js": "text/javascript; charset=utf-8",
353
+ ".mjs": "text/javascript; charset=utf-8",
354
+ ".css": "text/css; charset=utf-8",
355
+ ".json": "application/json; charset=utf-8",
356
+ ".svg": "image/svg+xml",
357
+ ".png": "image/png",
358
+ ".txt": "text/plain; charset=utf-8",
359
+ ".md": "text/plain; charset=utf-8"
360
+ };
361
+ function resolveWorkspacePath(workspaceDir, relativePath) {
362
+ const normalized = import_path3.default.normalize(relativePath).replace(/^([/\\])+/, "");
363
+ const resolved = import_path3.default.resolve(workspaceDir, normalized);
364
+ if (resolved !== workspaceDir && !resolved.startsWith(workspaceDir + import_path3.default.sep)) {
365
+ throw new Error(`Path escapes the VM workspace: ${relativePath}`);
366
+ }
367
+ return resolved;
368
+ }
369
+ function startStandInServer(spec, workspaceDir) {
370
+ const server = import_http.default.createServer((req, res) => {
371
+ void (async () => {
372
+ const requestPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
373
+ const relative = requestPath === "/" ? "index.html" : requestPath;
374
+ try {
375
+ const filePath = resolveWorkspacePath(workspaceDir, relative);
376
+ const content = await import_promises2.default.readFile(filePath);
377
+ res.writeHead(200, { "content-type": CONTENT_TYPES[import_path3.default.extname(filePath).toLowerCase()] ?? "application/octet-stream" });
378
+ res.end(content);
379
+ } catch {
380
+ res.writeHead(200, { "content-type": "text/plain" });
381
+ res.end(`mock vm ${spec.vmId} (${spec.stack}) \u2014 session ${spec.sessionId} \u2014 ${req.method} ${req.url}
382
+ `);
383
+ }
384
+ })();
385
+ });
386
+ return new Promise((resolve, reject) => {
387
+ server.once("error", reject);
388
+ server.listen(0, "127.0.0.1", () => {
389
+ resolve({ server, port: server.address().port });
390
+ });
391
+ });
392
+ }
393
+ var MockExecutor = class {
394
+ constructor() {
395
+ this.vms = /* @__PURE__ */ new Map();
396
+ }
397
+ async create(spec) {
398
+ const workspaceDir = await import_promises2.default.mkdtemp(import_path3.default.join(import_os5.default.tmpdir(), `mock-vm-${spec.vmId}-`));
399
+ const { server, port } = await startStandInServer(spec, workspaceDir);
400
+ const handle = {
401
+ vmId: spec.vmId,
402
+ sessionId: spec.sessionId,
403
+ stack: spec.stack,
404
+ resources: spec.resources,
405
+ running: true
406
+ };
407
+ this.vms.set(spec.vmId, { handle, server, port, workspaceDir });
408
+ src_default.info({ vmId: spec.vmId, port, workspaceDir }, "mock vm created");
409
+ return handle;
410
+ }
411
+ async pause(vmId) {
412
+ return this.setRunning(vmId, false);
413
+ }
414
+ async resume(vmId) {
415
+ return this.setRunning(vmId, true);
416
+ }
417
+ async stop(vmId) {
418
+ return this.setRunning(vmId, false);
419
+ }
420
+ async restore(vmId) {
421
+ return this.setRunning(vmId, true);
422
+ }
423
+ async destroy(vmId) {
424
+ const vm = this.vms.get(vmId);
425
+ if (!vm) return;
426
+ await new Promise((resolve) => vm.server.close(() => resolve()));
427
+ await import_promises2.default.rm(vm.workspaceDir, { recursive: true, force: true });
428
+ this.vms.delete(vmId);
429
+ src_default.info({ vmId }, "mock vm destroyed");
430
+ }
431
+ get(vmId) {
432
+ return this.vms.get(vmId)?.handle;
433
+ }
434
+ list() {
435
+ return [...this.vms.values()].map((vm) => vm.handle);
436
+ }
437
+ async proxyHttp(vmId, request) {
438
+ const vm = this.requireRunningVm(vmId);
439
+ return new Promise((resolve, reject) => {
440
+ const proxyReq = import_http.default.request(
441
+ {
442
+ host: "127.0.0.1",
443
+ port: vm.port,
444
+ method: request.method,
445
+ path: request.path,
446
+ headers: request.headers
447
+ },
448
+ (proxyRes) => {
449
+ const chunks = [];
450
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
451
+ proxyRes.on("end", () => {
452
+ resolve({
453
+ statusCode: proxyRes.statusCode ?? 502,
454
+ headers: Object.fromEntries(
455
+ Object.entries(proxyRes.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])
456
+ ),
457
+ body: Buffer.concat(chunks)
458
+ });
459
+ });
460
+ }
461
+ );
462
+ proxyReq.on("error", reject);
463
+ if (request.body) proxyReq.write(request.body);
464
+ proxyReq.end();
465
+ });
466
+ }
467
+ async writeFiles(vmId, files) {
468
+ const vm = this.requireRunningVm(vmId);
469
+ const written = [];
470
+ for (const file of files) {
471
+ const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
472
+ if (buffer.byteLength > MAX_FILE_BYTES) {
473
+ throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES}): ${file.path}`);
474
+ }
475
+ const filePath = resolveWorkspacePath(vm.workspaceDir, file.path);
476
+ await import_promises2.default.mkdir(import_path3.default.dirname(filePath), { recursive: true });
477
+ await import_promises2.default.writeFile(filePath, buffer);
478
+ written.push({ path: import_path3.default.relative(vm.workspaceDir, filePath), bytes: buffer.byteLength });
479
+ }
480
+ src_default.info({ vmId, files: written.length }, "mock vm files written");
481
+ return { written };
482
+ }
483
+ async exec(vmId, request) {
484
+ const vm = this.requireRunningVm(vmId);
485
+ const cwd = request.cwd ? resolveWorkspacePath(vm.workspaceDir, request.cwd) : vm.workspaceDir;
486
+ const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, MAX_EXEC_TIMEOUT_MS);
487
+ const startedAt = Date.now();
488
+ return new Promise((resolve) => {
489
+ (0, import_child_process.execFile)(
490
+ "/bin/sh",
491
+ ["-c", request.command],
492
+ {
493
+ cwd,
494
+ timeout: timeoutMs,
495
+ maxBuffer: MAX_OUTPUT_BYTES,
496
+ env: { ...process.env, HOME: vm.workspaceDir }
497
+ },
498
+ (error, stdout, stderr) => {
499
+ const failed = error;
500
+ const timedOut = Boolean(failed?.killed);
501
+ const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
502
+ resolve({
503
+ exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
504
+ stdout: stdout.slice(0, MAX_OUTPUT_BYTES),
505
+ stderr: stderr.slice(0, MAX_OUTPUT_BYTES),
506
+ durationMs: Date.now() - startedAt,
507
+ truncated: Boolean(truncated),
508
+ timedOut
509
+ });
510
+ }
511
+ );
512
+ });
513
+ }
514
+ requireRunningVm(vmId) {
515
+ const vm = this.vms.get(vmId);
516
+ if (!vm || !vm.handle.running) {
517
+ throw new Error(`vm ${vmId} is not running`);
518
+ }
519
+ return vm;
520
+ }
521
+ setRunning(vmId, running) {
522
+ const vm = this.vms.get(vmId);
523
+ if (!vm) throw new Error(`vm ${vmId} not found`);
524
+ vm.handle = { ...vm.handle, running };
525
+ return vm.handle;
526
+ }
527
+ };
528
+
529
+ // src/executor/qemu-executor.ts
530
+ var import_child_process2 = require("child_process");
531
+ var import_net = __toESM(require("net"));
532
+ var import_path4 = __toESM(require("path"));
533
+ var import_promises3 = __toESM(require("fs/promises"));
534
+ var import_os6 = __toESM(require("os"));
535
+
536
+ // src/executor/reattach-utils.ts
537
+ function withTimeout(promise, ms) {
538
+ return Promise.race([
539
+ promise,
540
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms))
541
+ ]);
542
+ }
543
+
544
+ // src/executor/qemu-executor.ts
545
+ var QemuExecutor = class {
546
+ constructor(config) {
547
+ this.config = config;
548
+ this.vms = /* @__PURE__ */ new Map();
549
+ this.usedHttpPorts = /* @__PURE__ */ new Set();
550
+ this.usedDisplays = /* @__PURE__ */ new Set();
551
+ // VNC display indices
552
+ this.usedSerialPorts = /* @__PURE__ */ new Set();
553
+ this.bin = config.bin ?? "qemu-system-aarch64";
554
+ this.vmDataPath = config.vmDataPath ?? import_path4.default.join(import_os6.default.tmpdir(), "qemu-vms");
555
+ this.httpPortStart = config.httpPortRangeStart ?? 9e3;
556
+ this.httpPortEnd = config.httpPortRangeEnd ?? 9099;
557
+ this.vncBasePort = config.vncBasePort ?? 5900;
558
+ this.serialBasePort = config.serialBasePort ?? 8e3;
559
+ this.accel = config.accel ?? "hvf";
560
+ this.cpu = config.cpu ?? "host";
561
+ this.machine = config.machine ?? "virt";
562
+ }
563
+ allocateHttpPort() {
564
+ for (let p = this.httpPortStart; p <= this.httpPortEnd; p++) {
565
+ if (!this.usedHttpPorts.has(p)) {
566
+ this.usedHttpPorts.add(p);
567
+ return p;
568
+ }
569
+ }
570
+ throw new Error("no free HTTP ports in QEMU port range");
571
+ }
572
+ allocateVncDisplay() {
573
+ for (let d = 1; d <= 99; d++) {
574
+ if (!this.usedDisplays.has(d)) {
575
+ this.usedDisplays.add(d);
576
+ return d;
577
+ }
578
+ }
579
+ throw new Error("no free VNC displays");
580
+ }
581
+ allocateSerialPort() {
582
+ for (let p = this.serialBasePort; p <= this.serialBasePort + 99; p++) {
583
+ if (!this.usedSerialPorts.has(p)) {
584
+ this.usedSerialPorts.add(p);
585
+ return p;
586
+ }
587
+ }
588
+ throw new Error("no free serial ports");
589
+ }
590
+ /** Explicit kernel/initrd override wins; otherwise the stack's base image, falling back to generic. */
591
+ resolveImages(stack) {
592
+ if (this.config.kernelPath && this.config.initrdPath) {
593
+ return { kernelPath: this.config.kernelPath, initrdPath: this.config.initrdPath };
594
+ }
595
+ const resolved = this.config.imagesDir ? resolveStackImages(this.config.imagesDir, stack) ?? resolveStackImages(this.config.imagesDir, "generic") : void 0;
596
+ if (!resolved) {
597
+ throw new Error(`no base image for stack "${stack}" under ${this.config.imagesDir} \u2014 run "worker-cli images pull"`);
598
+ }
599
+ return resolved;
600
+ }
601
+ async create(spec) {
602
+ const { kernelPath, initrdPath } = this.resolveImages(spec.stack);
603
+ const hostPort = this.allocateHttpPort();
604
+ const vncDisplay = this.allocateVncDisplay();
605
+ const serialPort = this.allocateSerialPort();
606
+ const vncPort = this.vncBasePort + vncDisplay;
607
+ const vmDir = import_path4.default.join(this.vmDataPath, spec.vmId);
608
+ await import_promises3.default.mkdir(vmDir, { recursive: true });
609
+ const qmpSocketPath = import_path4.default.join(vmDir, "qmp.sock");
610
+ const args = [
611
+ "-machine",
612
+ this.machine,
613
+ "-cpu",
614
+ this.cpu,
615
+ "-accel",
616
+ this.accel,
617
+ "-smp",
618
+ String(spec.resources.vcpuCount),
619
+ "-m",
620
+ String(spec.resources.memMib),
621
+ "-kernel",
622
+ kernelPath,
623
+ "-initrd",
624
+ initrdPath,
625
+ // console=tty0 sends output to VGA framebuffer (VNC); ttyAMA0 to serial.
626
+ // APP_URL_B64: base64-encoded presigned artifact URL — kernel cmdline params are
627
+ // space-separated, so the raw URL (query string, &) can't be passed directly.
628
+ "-append",
629
+ [
630
+ `console=tty0 console=ttyAMA0 net.ifnames=0 init=/init VM_ID=${spec.vmId} STACK=${spec.stack}`,
631
+ spec.sessionId ? `SESSION_ID=${spec.sessionId}` : "",
632
+ spec.appUrl ? `APP_URL_B64=${Buffer.from(spec.appUrl).toString("base64")}` : ""
633
+ ].filter(Boolean).join(" "),
634
+ // Networking
635
+ "-netdev",
636
+ `user,id=net0,hostfwd=tcp::${hostPort}-:8080`,
637
+ "-device",
638
+ "virtio-net-pci,netdev=net0",
639
+ // Display: virtio-gpu-pci works on both aarch64 and x86_64; virtio-vga is x86-only
640
+ "-device",
641
+ "virtio-gpu-pci",
642
+ "-display",
643
+ "none",
644
+ "-vnc",
645
+ `127.0.0.1:${vncDisplay}`,
646
+ // Serial console via TCP (xterm.js connects here)
647
+ "-serial",
648
+ `tcp:127.0.0.1:${serialPort},server,nowait`,
649
+ // Control
650
+ "-no-reboot",
651
+ "-qmp",
652
+ `unix:${qmpSocketPath},server,nowait`
653
+ ];
654
+ src_default.info({ vmId: spec.vmId, bin: this.bin, args }, "spawning qemu");
655
+ const proc = (0, import_child_process2.spawn)(this.bin, args, { stdio: ["ignore", "pipe", "pipe"] });
656
+ proc.stdout?.on("data", (d) => src_default.info({ vmId: spec.vmId }, d.toString().trimEnd()));
657
+ proc.stderr?.on("data", (d) => src_default.error({ vmId: spec.vmId }, d.toString().trimEnd()));
658
+ proc.on("exit", (code, signal) => {
659
+ const log = code === 0 || code === null ? src_default.info.bind(src_default) : src_default.error.bind(src_default);
660
+ log({ vmId: spec.vmId, code, signal }, "qemu process exited");
661
+ const vm2 = this.vms.get(spec.vmId);
662
+ if (vm2) {
663
+ this.usedHttpPorts.delete(vm2.hostPort);
664
+ this.usedDisplays.delete(vm2.vncDisplay);
665
+ this.usedSerialPorts.delete(vm2.serialPort);
666
+ this.vms.delete(spec.vmId);
667
+ }
668
+ });
669
+ const metadata = { hostPort, qmpSocketPath, vncDisplay, serialPort, pid: proc.pid };
670
+ const handle = {
671
+ vmId: spec.vmId,
672
+ sessionId: spec.sessionId,
673
+ stack: spec.stack,
674
+ resources: spec.resources,
675
+ running: true,
676
+ vncPort,
677
+ serialPort,
678
+ metadata
679
+ };
680
+ const vm = { handle, process: proc, pid: proc.pid, hostPort, qmpSocketPath, vncDisplay, serialPort };
681
+ this.vms.set(spec.vmId, vm);
682
+ await waitForQmp(qmpSocketPath);
683
+ src_default.info({ vmId: spec.vmId, hostPort, vncPort, serialPort }, "qemu vm ready");
684
+ return handle;
685
+ }
686
+ async pause(vmId) {
687
+ await this.qmp(vmId, "stop");
688
+ return this.setRunning(vmId, false);
689
+ }
690
+ async resume(vmId) {
691
+ await this.qmp(vmId, "cont");
692
+ return this.setRunning(vmId, true);
693
+ }
694
+ async stop(vmId) {
695
+ await this.qmp(vmId, "stop");
696
+ return this.setRunning(vmId, false);
697
+ }
698
+ async restore(vmId) {
699
+ await this.qmp(vmId, "cont");
700
+ return this.setRunning(vmId, true);
701
+ }
702
+ async destroy(vmId) {
703
+ const vm = this.vms.get(vmId);
704
+ if (!vm) return;
705
+ try {
706
+ await this.qmp(vmId, "quit");
707
+ } catch {
708
+ this.killVm(vm);
709
+ }
710
+ if (vm.process) {
711
+ const proc = vm.process;
712
+ await new Promise((resolve) => {
713
+ if (proc.exitCode !== null) return resolve();
714
+ proc.once("exit", resolve);
715
+ setTimeout(() => {
716
+ this.killVm(vm);
717
+ resolve();
718
+ }, 3e3);
719
+ });
720
+ } else {
721
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
722
+ this.killVm(vm);
723
+ }
724
+ this.usedHttpPorts.delete(vm.hostPort);
725
+ this.usedDisplays.delete(vm.vncDisplay);
726
+ this.usedSerialPorts.delete(vm.serialPort);
727
+ this.vms.delete(vmId);
728
+ await import_promises3.default.rm(import_path4.default.join(this.vmDataPath, vmId), { recursive: true, force: true });
729
+ src_default.info({ vmId }, "qemu vm destroyed");
730
+ }
731
+ get(vmId) {
732
+ return this.vms.get(vmId)?.handle;
733
+ }
734
+ list() {
735
+ return [...this.vms.values()].map((v) => v.handle);
736
+ }
737
+ /**
738
+ * Recover a QEMU VM after a worker restart: the process may well have survived (it's not
739
+ * killed just because its Node parent exited), so probe its QMP socket — if it answers,
740
+ * lifecycle control and HTTP proxying resume normally. There's no live ChildProcess to
741
+ * reattach to, so destroy() falls back to killing the stored pid.
742
+ */
743
+ async reattach(record) {
744
+ const meta = record.runtimeMetadata;
745
+ if (!meta?.qmpSocketPath || meta.hostPort === void 0 || meta.vncDisplay === void 0 || meta.serialPort === void 0) {
746
+ return void 0;
747
+ }
748
+ try {
749
+ await withTimeout(sendQmpCommand(meta.qmpSocketPath, "query-status"), 5e3);
750
+ } catch {
751
+ return void 0;
752
+ }
753
+ const handle = {
754
+ vmId: record.vmId,
755
+ sessionId: record.sessionId,
756
+ stack: record.stack,
757
+ resources: record.resources,
758
+ running: record.state === "running",
759
+ vncPort: this.vncBasePort + meta.vncDisplay,
760
+ serialPort: meta.serialPort,
761
+ metadata: meta
762
+ };
763
+ this.usedHttpPorts.add(meta.hostPort);
764
+ this.usedDisplays.add(meta.vncDisplay);
765
+ this.usedSerialPorts.add(meta.serialPort);
766
+ this.vms.set(record.vmId, {
767
+ handle,
768
+ pid: meta.pid,
769
+ hostPort: meta.hostPort,
770
+ qmpSocketPath: meta.qmpSocketPath,
771
+ vncDisplay: meta.vncDisplay,
772
+ serialPort: meta.serialPort
773
+ });
774
+ src_default.info({ vmId: record.vmId }, "qemu vm reattached");
775
+ return handle;
776
+ }
777
+ killVm(vm) {
778
+ if (vm.process) {
779
+ vm.process.kill("SIGKILL");
780
+ return;
781
+ }
782
+ if (vm.pid) {
783
+ try {
784
+ process.kill(vm.pid, "SIGKILL");
785
+ } catch {
786
+ }
787
+ }
788
+ }
789
+ async proxyHttp(vmId, request) {
790
+ const vm = this.vms.get(vmId);
791
+ if (!vm?.handle.running) throw new Error(`vm ${vmId} is not running`);
792
+ const { default: http7 } = await import("http");
793
+ return new Promise((resolve, reject) => {
794
+ const req = http7.request(
795
+ { host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
796
+ (res) => {
797
+ const chunks = [];
798
+ res.on("data", (c) => chunks.push(c));
799
+ res.on(
800
+ "end",
801
+ () => resolve({
802
+ statusCode: res.statusCode ?? 502,
803
+ headers: Object.fromEntries(
804
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
805
+ ),
806
+ body: Buffer.concat(chunks)
807
+ })
808
+ );
809
+ }
810
+ );
811
+ req.on("error", reject);
812
+ if (request.body) req.write(request.body);
813
+ req.end();
814
+ });
815
+ }
816
+ setRunning(vmId, running) {
817
+ const vm = this.vms.get(vmId);
818
+ if (!vm) throw new Error(`vm ${vmId} not found`);
819
+ vm.handle = { ...vm.handle, running };
820
+ return vm.handle;
821
+ }
822
+ async qmp(vmId, execute) {
823
+ const vm = this.vms.get(vmId);
824
+ if (!vm) throw new Error(`vm ${vmId} not found`);
825
+ await sendQmpCommand(vm.qmpSocketPath, execute);
826
+ }
827
+ };
828
+ function waitForQmp(socketPath, timeoutMs = 3e4) {
829
+ return new Promise((resolve, reject) => {
830
+ const deadline = Date.now() + timeoutMs;
831
+ const attempt = () => {
832
+ if (Date.now() > deadline) return reject(new Error(`timed out waiting for QMP socket at ${socketPath}`));
833
+ const sock = import_net.default.createConnection(socketPath);
834
+ sock.once("connect", () => {
835
+ sock.destroy();
836
+ resolve();
837
+ });
838
+ sock.once("error", () => setTimeout(attempt, 500));
839
+ };
840
+ attempt();
841
+ });
842
+ }
843
+ async function sendQmpCommand(socketPath, execute) {
844
+ return new Promise((resolve, reject) => {
845
+ const sock = import_net.default.createConnection(socketPath);
846
+ let buf = "";
847
+ let ready = false;
848
+ const send = (obj) => sock.write(JSON.stringify(obj) + "\r\n");
849
+ sock.on("data", (data) => {
850
+ buf += data.toString();
851
+ let idx;
852
+ while ((idx = buf.indexOf("\n")) !== -1) {
853
+ const line = buf.slice(0, idx).trim();
854
+ buf = buf.slice(idx + 1);
855
+ if (!line) continue;
856
+ let msg;
857
+ try {
858
+ msg = JSON.parse(line);
859
+ } catch {
860
+ continue;
861
+ }
862
+ if (msg["QMP"]) {
863
+ send({ execute: "qmp_capabilities" });
864
+ } else if (!ready && "return" in msg) {
865
+ ready = true;
866
+ send({ execute });
867
+ } else if (ready && "return" in msg) {
868
+ sock.destroy();
869
+ resolve();
870
+ } else if (msg["error"]) {
871
+ sock.destroy();
872
+ reject(new Error(`QMP error for "${execute}": ${JSON.stringify(msg["error"])}`));
873
+ }
874
+ }
875
+ });
876
+ sock.on("error", (e) => {
877
+ if (execute === "quit") resolve();
878
+ else reject(e);
879
+ });
880
+ sock.on("close", () => resolve());
881
+ });
882
+ }
883
+
884
+ // src/executor/firecracker-executor.ts
885
+ var import_child_process3 = require("child_process");
886
+ var import_child_process4 = require("child_process");
887
+ var import_util = require("util");
888
+ var import_http2 = __toESM(require("http"));
889
+ var import_net2 = __toESM(require("net"));
890
+ var import_path5 = __toESM(require("path"));
891
+ var import_promises4 = __toESM(require("fs/promises"));
892
+ var import_os7 = __toESM(require("os"));
893
+ var execAsync = (0, import_util.promisify)(import_child_process4.exec);
894
+ var FirecrackerExecutor = class {
895
+ constructor(config) {
896
+ this.config = config;
897
+ this.vms = /* @__PURE__ */ new Map();
898
+ this.usedSubnets = /* @__PURE__ */ new Set();
899
+ this.tapCounter = 0;
900
+ this.networkReady = false;
901
+ this.bin = config.bin ?? "firecracker";
902
+ this.vmDataPath = config.vmDataPath ?? import_path5.default.join(import_os7.default.tmpdir(), "firecracker-vms");
903
+ }
904
+ allocateSubnet() {
905
+ for (let s = 1; s <= 254; s++) {
906
+ if (!this.usedSubnets.has(s)) {
907
+ this.usedSubnets.add(s);
908
+ return s;
909
+ }
910
+ }
911
+ throw new Error("no free TAP subnets (max 254 concurrent VMs)");
912
+ }
913
+ // Called once before the first VM; sets up host-level IP forwarding + masquerade.
914
+ async ensureNetworking() {
915
+ if (this.networkReady) return;
916
+ await execAsync("sysctl -w net.ipv4.ip_forward=1");
917
+ const alreadySet = await execAsync(
918
+ "iptables -t nat -C POSTROUTING -s 172.16.0.0/12 ! -d 172.16.0.0/12 -j MASQUERADE"
919
+ ).catch(() => null);
920
+ if (!alreadySet) {
921
+ await execAsync("iptables -t nat -A POSTROUTING -s 172.16.0.0/12 ! -d 172.16.0.0/12 -j MASQUERADE");
922
+ }
923
+ this.networkReady = true;
924
+ }
925
+ /** Explicit kernel/initrd override wins; otherwise the stack's base image, falling back to generic. */
926
+ resolveImages(stack) {
927
+ if (this.config.kernelPath && this.config.initrdPath) {
928
+ return { kernelPath: this.config.kernelPath, initrdPath: this.config.initrdPath };
929
+ }
930
+ const resolved = this.config.imagesDir ? resolveStackImages(this.config.imagesDir, stack) ?? resolveStackImages(this.config.imagesDir, "generic") : void 0;
931
+ if (!resolved) {
932
+ throw new Error(`no base image for stack "${stack}" under ${this.config.imagesDir} \u2014 run "worker-cli images pull"`);
933
+ }
934
+ return resolved;
935
+ }
936
+ async create(spec) {
937
+ const { kernelPath, initrdPath } = this.resolveImages(spec.stack);
938
+ await this.ensureNetworking();
939
+ const subnet = this.allocateSubnet();
940
+ const tapName = `fctap${++this.tapCounter}`;
941
+ const hostIp = `172.16.${subnet}.1`;
942
+ const guestIp = `172.16.${subnet}.2`;
943
+ const vmDir = import_path5.default.join(this.vmDataPath, spec.vmId);
944
+ await import_promises4.default.mkdir(vmDir, { recursive: true });
945
+ const apiSocketPath = import_path5.default.join(vmDir, "api.sock");
946
+ await import_promises4.default.rm(apiSocketPath, { force: true });
947
+ await execAsync(`ip tuntap add dev ${tapName} mode tap`);
948
+ await execAsync(`ip addr add ${hostIp}/30 dev ${tapName}`);
949
+ await execAsync(`ip link set ${tapName} up`);
950
+ const proc = (0, import_child_process3.spawn)(this.bin, ["--api-sock", apiSocketPath], {
951
+ stdio: ["pipe", "pipe", "pipe"]
952
+ });
953
+ proc.stdout?.on("data", (d) => src_default.debug({ vmId: spec.vmId }, d.toString().trimEnd()));
954
+ proc.stderr?.on("data", (d) => src_default.debug({ vmId: spec.vmId }, d.toString().trimEnd()));
955
+ proc.on("exit", (code, signal) => {
956
+ src_default.info({ vmId: spec.vmId, code, signal }, "firecracker process exited");
957
+ const vm = this.vms.get(spec.vmId);
958
+ if (vm) {
959
+ this.usedSubnets.delete(vm.subnet);
960
+ this.vms.delete(spec.vmId);
961
+ }
962
+ execAsync(`ip link del ${tapName}`).catch(() => {
963
+ });
964
+ });
965
+ await waitForSocket(apiSocketPath);
966
+ const macHex = (n) => n.toString(16).padStart(2, "0");
967
+ await fcRequest(apiSocketPath, "PUT", "/machine-config", {
968
+ vcpu_count: spec.resources.vcpuCount,
969
+ mem_size_mib: spec.resources.memMib
970
+ });
971
+ await fcRequest(apiSocketPath, "PUT", "/boot-source", {
972
+ kernel_image_path: kernelPath,
973
+ initrd_path: initrdPath,
974
+ boot_args: [
975
+ "console=ttyS0",
976
+ "reboot=k",
977
+ "panic=1",
978
+ "pci=off",
979
+ "net.ifnames=0",
980
+ "init=/init",
981
+ `VM_ID=${spec.vmId}`,
982
+ ...spec.sessionId ? [`SESSION_ID=${spec.sessionId}`] : [],
983
+ `STACK=${spec.stack}`,
984
+ // base64: kernel cmdline is space-separated, raw presigned URLs are not safe
985
+ ...spec.appUrl ? [`APP_URL_B64=${Buffer.from(spec.appUrl).toString("base64")}`] : [],
986
+ `GUEST_IP=${guestIp}`,
987
+ `HOST_IP=${hostIp}`
988
+ ].join(" ")
989
+ });
990
+ await fcRequest(apiSocketPath, "PUT", "/network-interfaces/eth0", {
991
+ iface_id: "eth0",
992
+ // AA = locally-administered unicast; encode subnet as last two bytes
993
+ guest_mac: `AA:FC:00:00:${macHex(subnet >> 8)}:${macHex(subnet & 255)}`,
994
+ host_dev_name: tapName
995
+ });
996
+ await fcRequest(apiSocketPath, "PUT", "/actions", { action_type: "InstanceStart" });
997
+ await waitForHttp(guestIp, 8080);
998
+ const metadata = { apiSocketPath, tapName, subnet, guestIp, pid: proc.pid };
999
+ const handle = {
1000
+ vmId: spec.vmId,
1001
+ sessionId: spec.sessionId,
1002
+ stack: spec.stack,
1003
+ resources: spec.resources,
1004
+ running: true,
1005
+ metadata
1006
+ };
1007
+ this.vms.set(spec.vmId, { handle, process: proc, pid: proc.pid, apiSocketPath, tapName, subnet, guestIp });
1008
+ src_default.info({ vmId: spec.vmId, guestIp, tapName }, "firecracker vm ready");
1009
+ return handle;
1010
+ }
1011
+ async pause(vmId) {
1012
+ await this.patchVmState(vmId, "Paused");
1013
+ return this.setRunning(vmId, false);
1014
+ }
1015
+ async resume(vmId) {
1016
+ await this.patchVmState(vmId, "Resumed");
1017
+ return this.setRunning(vmId, true);
1018
+ }
1019
+ async stop(vmId) {
1020
+ await this.patchVmState(vmId, "Paused");
1021
+ return this.setRunning(vmId, false);
1022
+ }
1023
+ async restore(vmId) {
1024
+ await this.patchVmState(vmId, "Resumed");
1025
+ return this.setRunning(vmId, true);
1026
+ }
1027
+ async destroy(vmId) {
1028
+ const vm = this.vms.get(vmId);
1029
+ if (!vm) return;
1030
+ if (vm.process) {
1031
+ const proc = vm.process;
1032
+ proc.kill("SIGKILL");
1033
+ await new Promise((resolve) => {
1034
+ if (proc.exitCode !== null) return resolve();
1035
+ proc.once("exit", resolve);
1036
+ setTimeout(() => {
1037
+ proc.kill("SIGKILL");
1038
+ resolve();
1039
+ }, 3e3);
1040
+ });
1041
+ } else if (vm.pid) {
1042
+ try {
1043
+ process.kill(vm.pid, "SIGKILL");
1044
+ } catch {
1045
+ }
1046
+ }
1047
+ this.usedSubnets.delete(vm.subnet);
1048
+ this.vms.delete(vmId);
1049
+ await execAsync(`ip link del ${vm.tapName}`).catch(() => {
1050
+ });
1051
+ await import_promises4.default.rm(import_path5.default.join(this.vmDataPath, vmId), { recursive: true, force: true });
1052
+ src_default.info({ vmId }, "firecracker vm destroyed");
1053
+ }
1054
+ get(vmId) {
1055
+ return this.vms.get(vmId)?.handle;
1056
+ }
1057
+ getSerialSource(vmId) {
1058
+ const vm = this.vms.get(vmId);
1059
+ if (!vm?.process) return void 0;
1060
+ return { type: "pipe", process: vm.process };
1061
+ }
1062
+ list() {
1063
+ return [...this.vms.values()].map((v) => v.handle);
1064
+ }
1065
+ /**
1066
+ * Recover a Firecracker VM after a worker restart: the process may well have survived, so
1067
+ * probe its API socket — if it answers, lifecycle control and HTTP proxying resume
1068
+ * normally. There's no live ChildProcess to reattach to, so destroy() falls back to
1069
+ * killing the stored pid, and the serial console is unavailable (see getSerialSource).
1070
+ */
1071
+ async reattach(record) {
1072
+ const meta = record.runtimeMetadata;
1073
+ if (!meta?.apiSocketPath || !meta.tapName || meta.subnet === void 0 || !meta.guestIp) {
1074
+ return void 0;
1075
+ }
1076
+ try {
1077
+ await withTimeout(fcRequest(meta.apiSocketPath, "GET", "/", {}), 5e3);
1078
+ } catch {
1079
+ return void 0;
1080
+ }
1081
+ const handle = {
1082
+ vmId: record.vmId,
1083
+ sessionId: record.sessionId,
1084
+ stack: record.stack,
1085
+ resources: record.resources,
1086
+ running: record.state === "running",
1087
+ metadata: meta
1088
+ };
1089
+ this.usedSubnets.add(meta.subnet);
1090
+ const tapNumber = Number(meta.tapName.replace(/^fctap/, ""));
1091
+ if (Number.isFinite(tapNumber)) this.tapCounter = Math.max(this.tapCounter, tapNumber);
1092
+ this.vms.set(record.vmId, {
1093
+ handle,
1094
+ pid: meta.pid,
1095
+ apiSocketPath: meta.apiSocketPath,
1096
+ tapName: meta.tapName,
1097
+ subnet: meta.subnet,
1098
+ guestIp: meta.guestIp
1099
+ });
1100
+ src_default.info({ vmId: record.vmId, guestIp: meta.guestIp, tapName: meta.tapName }, "firecracker vm reattached");
1101
+ return handle;
1102
+ }
1103
+ async proxyHttp(vmId, request) {
1104
+ const vm = this.vms.get(vmId);
1105
+ if (!vm?.handle.running) throw new Error(`vm ${vmId} is not running`);
1106
+ return new Promise((resolve, reject) => {
1107
+ const req = import_http2.default.request(
1108
+ {
1109
+ host: vm.guestIp,
1110
+ port: 8080,
1111
+ method: request.method,
1112
+ path: request.path,
1113
+ headers: request.headers
1114
+ },
1115
+ (res) => {
1116
+ const chunks = [];
1117
+ res.on("data", (c) => chunks.push(c));
1118
+ res.on(
1119
+ "end",
1120
+ () => resolve({
1121
+ statusCode: res.statusCode ?? 502,
1122
+ headers: Object.fromEntries(
1123
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
1124
+ ),
1125
+ body: Buffer.concat(chunks)
1126
+ })
1127
+ );
1128
+ }
1129
+ );
1130
+ req.on("error", reject);
1131
+ if (request.body) req.write(request.body);
1132
+ req.end();
1133
+ });
1134
+ }
1135
+ async patchVmState(vmId, state) {
1136
+ const vm = this.vms.get(vmId);
1137
+ if (!vm) throw new Error(`vm ${vmId} not found`);
1138
+ await fcRequest(vm.apiSocketPath, "PATCH", "/vm", { state });
1139
+ }
1140
+ setRunning(vmId, running) {
1141
+ const vm = this.vms.get(vmId);
1142
+ if (!vm) throw new Error(`vm ${vmId} not found`);
1143
+ vm.handle = { ...vm.handle, running };
1144
+ return vm.handle;
1145
+ }
1146
+ };
1147
+ function fcRequest(socketPath, method, urlPath, body) {
1148
+ return new Promise((resolve, reject) => {
1149
+ const payload = JSON.stringify(body);
1150
+ const req = import_http2.default.request(
1151
+ {
1152
+ socketPath,
1153
+ method,
1154
+ path: urlPath,
1155
+ headers: {
1156
+ "Content-Type": "application/json",
1157
+ "Content-Length": Buffer.byteLength(payload),
1158
+ Accept: "application/json"
1159
+ }
1160
+ },
1161
+ (res) => {
1162
+ const chunks = [];
1163
+ res.on("data", (c) => chunks.push(c));
1164
+ res.on("end", () => {
1165
+ const text = Buffer.concat(chunks).toString();
1166
+ if (res.statusCode && res.statusCode >= 400) {
1167
+ reject(new Error(`Firecracker ${method} ${urlPath} \u2192 ${res.statusCode}: ${text}`));
1168
+ } else {
1169
+ try {
1170
+ resolve(JSON.parse(text));
1171
+ } catch {
1172
+ resolve({});
1173
+ }
1174
+ }
1175
+ });
1176
+ }
1177
+ );
1178
+ req.on("error", reject);
1179
+ req.write(payload);
1180
+ req.end();
1181
+ });
1182
+ }
1183
+ function waitForSocket(socketPath, timeoutMs = 1e4) {
1184
+ return new Promise((resolve, reject) => {
1185
+ const deadline = Date.now() + timeoutMs;
1186
+ const attempt = () => {
1187
+ if (Date.now() > deadline) return reject(new Error(`timed out waiting for Firecracker socket ${socketPath}`));
1188
+ const sock = import_net2.default.createConnection(socketPath);
1189
+ sock.once("connect", () => {
1190
+ sock.destroy();
1191
+ resolve();
1192
+ });
1193
+ sock.once("error", () => setTimeout(attempt, 100));
1194
+ };
1195
+ attempt();
1196
+ });
1197
+ }
1198
+ function waitForHttp(host, port, timeoutMs = 6e4) {
1199
+ return new Promise((resolve, reject) => {
1200
+ const deadline = Date.now() + timeoutMs;
1201
+ const attempt = () => {
1202
+ if (Date.now() > deadline) return reject(new Error(`timed out waiting for HTTP at ${host}:${port}`));
1203
+ const req = import_http2.default.get(`http://${host}:${port}/health`, (res) => {
1204
+ if (res.statusCode && res.statusCode < 500) return resolve();
1205
+ res.resume();
1206
+ setTimeout(attempt, 1e3);
1207
+ });
1208
+ req.on("error", () => setTimeout(attempt, 1e3));
1209
+ req.setTimeout(2e3, () => {
1210
+ req.destroy();
1211
+ setTimeout(attempt, 1e3);
1212
+ });
1213
+ };
1214
+ attempt();
1215
+ });
1216
+ }
1217
+
1218
+ // src/executor/docker-executor.ts
1219
+ var import_child_process5 = require("child_process");
1220
+ var import_util2 = require("util");
1221
+ var import_http3 = __toESM(require("http"));
1222
+ var DEFAULT_EXEC_TIMEOUT_MS2 = 6e4;
1223
+ var MAX_EXEC_TIMEOUT_MS2 = 3e5;
1224
+ var MAX_OUTPUT_BYTES2 = 1024 * 1024;
1225
+ var MAX_FILE_BYTES2 = 5 * 1024 * 1024;
1226
+ var WORKSPACE_DIR = "/workspace";
1227
+ var execFileAsync = (0, import_util2.promisify)(import_child_process5.execFile);
1228
+ function assertSafeWorkspacePath(relativePath) {
1229
+ const normalized = relativePath.replace(/^([/\\])+/, "");
1230
+ if (normalized.split(/[/\\]/).includes("..")) {
1231
+ throw new Error(`Path escapes the VM workspace: ${relativePath}`);
1232
+ }
1233
+ return normalized;
1234
+ }
1235
+ var DockerExecutor = class {
1236
+ constructor(config) {
1237
+ this.config = config;
1238
+ this.vms = /* @__PURE__ */ new Map();
1239
+ this.usedPorts = /* @__PURE__ */ new Set();
1240
+ this.bin = config.bin;
1241
+ }
1242
+ allocatePort() {
1243
+ for (let p = this.config.portRangeStart; p <= this.config.portRangeEnd; p++) {
1244
+ if (!this.usedPorts.has(p)) {
1245
+ this.usedPorts.add(p);
1246
+ return p;
1247
+ }
1248
+ }
1249
+ throw new Error("no free ports in docker port range");
1250
+ }
1251
+ async resolveImage(stack) {
1252
+ const explicit = this.config.stackImages[stack];
1253
+ if (explicit) return explicit;
1254
+ const { imagePrefix, imageTag } = this.config;
1255
+ const stackImage = `${imagePrefix}-${stack}:${imageTag}`;
1256
+ if (await this.imageExists(stackImage)) return stackImage;
1257
+ const genericImage = `${imagePrefix}-generic:${imageTag}`;
1258
+ if (await this.imageExists(genericImage)) return genericImage;
1259
+ throw new Error(`no local docker image for stack "${stack}" (${stackImage}) \u2014 run scripts/setup-docker-images.sh`);
1260
+ }
1261
+ async imageExists(image) {
1262
+ try {
1263
+ await execFileAsync(this.bin, ["image", "inspect", image]);
1264
+ return true;
1265
+ } catch {
1266
+ return false;
1267
+ }
1268
+ }
1269
+ async create(spec) {
1270
+ const image = await this.resolveImage(spec.stack);
1271
+ const hostPort = this.allocatePort();
1272
+ const containerName = `multiplayer-vm-${spec.vmId}`;
1273
+ const args = [
1274
+ "run",
1275
+ "-d",
1276
+ "--name",
1277
+ containerName,
1278
+ "-p",
1279
+ `127.0.0.1:${hostPort}:${this.config.guestPort}`,
1280
+ "--memory",
1281
+ `${spec.resources.memMib}m`,
1282
+ "--cpus",
1283
+ String(spec.resources.vcpuCount),
1284
+ "-e",
1285
+ `VM_ID=${spec.vmId}`,
1286
+ "-e",
1287
+ `STACK=${spec.stack}`,
1288
+ ...spec.sessionId ? ["-e", `SESSION_ID=${spec.sessionId}`] : [],
1289
+ ...spec.appUrl ? ["-e", `APP_URL=${spec.appUrl}`] : [],
1290
+ ...this.config.network ? ["--network", this.config.network] : [],
1291
+ image
1292
+ ];
1293
+ src_default.info({ vmId: spec.vmId, bin: this.bin, args }, "starting docker container");
1294
+ let containerId;
1295
+ try {
1296
+ const { stdout } = await execFileAsync(this.bin, args);
1297
+ containerId = stdout.trim();
1298
+ } catch (error) {
1299
+ this.usedPorts.delete(hostPort);
1300
+ throw error;
1301
+ }
1302
+ const handle = {
1303
+ vmId: spec.vmId,
1304
+ sessionId: spec.sessionId,
1305
+ stack: spec.stack,
1306
+ resources: spec.resources,
1307
+ running: true,
1308
+ metadata: { containerId, containerName, hostPort }
1309
+ };
1310
+ this.vms.set(spec.vmId, { handle, containerId, containerName, hostPort });
1311
+ this.watchExit(spec.vmId, containerId);
1312
+ await this.waitForAgent(hostPort);
1313
+ src_default.info({ vmId: spec.vmId, containerId, hostPort }, "docker vm ready");
1314
+ return handle;
1315
+ }
1316
+ /** Frees the allocated port once the container exits on its own (crash, OOM kill, `docker stop` from outside us). */
1317
+ watchExit(vmId, containerId) {
1318
+ execFileAsync(this.bin, ["wait", containerId]).catch(() => {
1319
+ }).then(() => {
1320
+ const vm = this.vms.get(vmId);
1321
+ if (vm?.containerId === containerId) {
1322
+ vm.handle = { ...vm.handle, running: false };
1323
+ }
1324
+ });
1325
+ }
1326
+ async pause(vmId) {
1327
+ const vm = this.mustGet(vmId);
1328
+ await execFileAsync(this.bin, ["pause", vm.containerId]);
1329
+ return this.setRunning(vmId, false);
1330
+ }
1331
+ async resume(vmId) {
1332
+ const vm = this.mustGet(vmId);
1333
+ await execFileAsync(this.bin, ["unpause", vm.containerId]);
1334
+ return this.setRunning(vmId, true);
1335
+ }
1336
+ async stop(vmId) {
1337
+ const vm = this.mustGet(vmId);
1338
+ await execFileAsync(this.bin, ["stop", vm.containerId]);
1339
+ return this.setRunning(vmId, false);
1340
+ }
1341
+ async restore(vmId) {
1342
+ const vm = this.mustGet(vmId);
1343
+ await execFileAsync(this.bin, ["start", vm.containerId]);
1344
+ return this.setRunning(vmId, true);
1345
+ }
1346
+ async destroy(vmId) {
1347
+ const vm = this.vms.get(vmId);
1348
+ if (!vm) return;
1349
+ await execFileAsync(this.bin, ["rm", "-f", vm.containerId]).catch(
1350
+ (error) => src_default.warn({ vmId, err: error }, "docker rm on destroy failed")
1351
+ );
1352
+ this.usedPorts.delete(vm.hostPort);
1353
+ this.vms.delete(vmId);
1354
+ src_default.info({ vmId }, "docker vm destroyed");
1355
+ }
1356
+ get(vmId) {
1357
+ return this.vms.get(vmId)?.handle;
1358
+ }
1359
+ list() {
1360
+ return [...this.vms.values()].map((v) => v.handle);
1361
+ }
1362
+ /**
1363
+ * Recover a container after a worker restart: it's an independent process the
1364
+ * Docker daemon owns, so just re-inspect it by ID and rebuild the local handle.
1365
+ */
1366
+ async reattach(record) {
1367
+ const meta = record.runtimeMetadata;
1368
+ if (!meta?.containerId || meta.hostPort === void 0) return void 0;
1369
+ let running;
1370
+ try {
1371
+ const { stdout } = await execFileAsync(this.bin, ["inspect", "-f", "{{.State.Running}}", meta.containerId]);
1372
+ running = stdout.trim() === "true";
1373
+ } catch {
1374
+ return void 0;
1375
+ }
1376
+ const handle = {
1377
+ vmId: record.vmId,
1378
+ sessionId: record.sessionId,
1379
+ stack: record.stack,
1380
+ resources: record.resources,
1381
+ running,
1382
+ metadata: meta
1383
+ };
1384
+ this.usedPorts.add(meta.hostPort);
1385
+ this.vms.set(record.vmId, {
1386
+ handle,
1387
+ containerId: meta.containerId,
1388
+ containerName: meta.containerName ?? `multiplayer-vm-${record.vmId}`,
1389
+ hostPort: meta.hostPort
1390
+ });
1391
+ if (running) this.watchExit(record.vmId, meta.containerId);
1392
+ src_default.info({ vmId: record.vmId, containerId: meta.containerId }, "docker vm reattached");
1393
+ return handle;
1394
+ }
1395
+ async proxyHttp(vmId, request) {
1396
+ const vm = this.mustGet(vmId);
1397
+ if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
1398
+ return new Promise((resolve, reject) => {
1399
+ const req = import_http3.default.request(
1400
+ { host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
1401
+ (res) => {
1402
+ const chunks = [];
1403
+ res.on("data", (c) => chunks.push(c));
1404
+ res.on(
1405
+ "end",
1406
+ () => resolve({
1407
+ statusCode: res.statusCode ?? 502,
1408
+ headers: Object.fromEntries(
1409
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
1410
+ ),
1411
+ body: Buffer.concat(chunks)
1412
+ })
1413
+ );
1414
+ }
1415
+ );
1416
+ req.on("error", reject);
1417
+ if (request.body) req.write(request.body);
1418
+ req.end();
1419
+ });
1420
+ }
1421
+ async exec(vmId, request) {
1422
+ const vm = this.mustGet(vmId);
1423
+ const cwd = request.cwd ? `${WORKSPACE_DIR}/${assertSafeWorkspacePath(request.cwd)}` : WORKSPACE_DIR;
1424
+ const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS2, MAX_EXEC_TIMEOUT_MS2);
1425
+ const startedAt = Date.now();
1426
+ return new Promise((resolve) => {
1427
+ (0, import_child_process5.execFile)(
1428
+ this.bin,
1429
+ ["exec", "-w", cwd, vm.containerId, "/bin/sh", "-c", request.command],
1430
+ { timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES2 },
1431
+ (error, stdout, stderr) => {
1432
+ const failed = error;
1433
+ const timedOut = Boolean(failed?.killed);
1434
+ const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
1435
+ resolve({
1436
+ exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
1437
+ stdout: stdout.slice(0, MAX_OUTPUT_BYTES2),
1438
+ stderr: stderr.slice(0, MAX_OUTPUT_BYTES2),
1439
+ durationMs: Date.now() - startedAt,
1440
+ truncated: Boolean(truncated),
1441
+ timedOut
1442
+ });
1443
+ }
1444
+ );
1445
+ });
1446
+ }
1447
+ async writeFiles(vmId, files) {
1448
+ const vm = this.mustGet(vmId);
1449
+ const written = [];
1450
+ for (const file of files) {
1451
+ const relativePath = assertSafeWorkspacePath(file.path);
1452
+ const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
1453
+ if (buffer.byteLength > MAX_FILE_BYTES2) {
1454
+ throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES2}): ${file.path}`);
1455
+ }
1456
+ const dir = relativePath.includes("/") ? relativePath.slice(0, relativePath.lastIndexOf("/")) : "";
1457
+ if (dir) {
1458
+ await execFileAsync(this.bin, ["exec", vm.containerId, "mkdir", "-p", `${WORKSPACE_DIR}/${dir}`]);
1459
+ }
1460
+ await this.copyIntoContainer(vm.containerId, `${WORKSPACE_DIR}/${relativePath}`, buffer);
1461
+ written.push({ path: relativePath, bytes: buffer.byteLength });
1462
+ }
1463
+ src_default.info({ vmId, files: written.length }, "docker vm files written");
1464
+ return { written };
1465
+ }
1466
+ /** `docker cp` needs a tar stream on stdin; build the smallest valid ustar archive for one file. */
1467
+ copyIntoContainer(containerId, destPath, content) {
1468
+ const entryName = destPath.replace(/^\//, "");
1469
+ const tarStream = buildSingleFileTar(entryName, content);
1470
+ return new Promise((resolve, reject) => {
1471
+ const proc = (0, import_child_process5.spawn)(this.bin, ["cp", "-", `${containerId}:/`], { stdio: ["pipe", "ignore", "pipe"] });
1472
+ let stderr = "";
1473
+ proc.stderr?.on("data", (d) => {
1474
+ stderr += d.toString();
1475
+ });
1476
+ proc.on("error", reject);
1477
+ proc.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`docker cp exited ${code}: ${stderr}`)));
1478
+ proc.stdin.end(tarStream);
1479
+ });
1480
+ }
1481
+ mustGet(vmId) {
1482
+ const vm = this.vms.get(vmId);
1483
+ if (!vm) throw new Error(`vm ${vmId} not found`);
1484
+ return vm;
1485
+ }
1486
+ setRunning(vmId, running) {
1487
+ const vm = this.mustGet(vmId);
1488
+ vm.handle = { ...vm.handle, running };
1489
+ return vm.handle;
1490
+ }
1491
+ async waitForAgent(port, timeoutMs = 3e4) {
1492
+ const deadline = Date.now() + timeoutMs;
1493
+ for (; ; ) {
1494
+ const ok = await new Promise((resolve) => {
1495
+ const req = import_http3.default.get({ host: "127.0.0.1", port, path: "/", timeout: 2e3 }, (res) => {
1496
+ res.resume();
1497
+ resolve(true);
1498
+ });
1499
+ req.on("error", () => resolve(false));
1500
+ req.on("timeout", () => {
1501
+ req.destroy();
1502
+ resolve(false);
1503
+ });
1504
+ });
1505
+ if (ok) return;
1506
+ if (Date.now() > deadline) throw new Error(`timed out waiting for docker vm agent on port ${port}`);
1507
+ await new Promise((resolve) => setTimeout(resolve, 500));
65
1508
  }
1509
+ }
1510
+ };
1511
+ function buildSingleFileTar(name, content) {
1512
+ const header = Buffer.alloc(512);
1513
+ header.write(name, 0, "utf8");
1514
+ header.write("0000644\0", 100, "utf8");
1515
+ header.write("0000000\0", 108, "utf8");
1516
+ header.write("0000000\0", 116, "utf8");
1517
+ header.write(content.byteLength.toString(8).padStart(11, "0") + "\0", 124, "utf8");
1518
+ header.write(Math.floor(Date.now() / 1e3).toString(8).padStart(11, "0") + "\0", 136, "utf8");
1519
+ header.write(" ", 148, "utf8");
1520
+ header.write("0", 156, "utf8");
1521
+ header.write("ustar\0", 257, "utf8");
1522
+ header.write("00", 263, "utf8");
1523
+ let checksum = 0;
1524
+ for (const byte of header) checksum += byte;
1525
+ header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "utf8");
1526
+ const paddedContentLength = Math.ceil(content.byteLength / 512) * 512;
1527
+ const paddedContent = Buffer.alloc(paddedContentLength);
1528
+ content.copy(paddedContent);
1529
+ return Buffer.concat([header, paddedContent, Buffer.alloc(1024)]);
1530
+ }
1531
+
1532
+ // src/executor/docker-sandbox-executor.ts
1533
+ var import_child_process6 = require("child_process");
1534
+ var import_util3 = require("util");
1535
+ var import_promises5 = __toESM(require("fs/promises"));
1536
+ var import_os8 = __toESM(require("os"));
1537
+ var import_path6 = __toESM(require("path"));
1538
+ var import_http4 = __toESM(require("http"));
1539
+ var DEFAULT_EXEC_TIMEOUT_MS3 = 6e4;
1540
+ var MAX_EXEC_TIMEOUT_MS3 = 3e5;
1541
+ var MAX_OUTPUT_BYTES3 = 1024 * 1024;
1542
+ var MAX_FILE_BYTES3 = 5 * 1024 * 1024;
1543
+ var WORKSPACE_DIR2 = "/workspace";
1544
+ var AGENT_DIR = "/app";
1545
+ var execFileAsync2 = (0, import_util3.promisify)(import_child_process6.execFile);
1546
+ function assertSafeWorkspacePath2(relativePath) {
1547
+ const normalized = relativePath.replace(/^([/\\])+/, "");
1548
+ if (normalized.split(/[/\\]/).includes("..")) {
1549
+ throw new Error(`Path escapes the VM workspace: ${relativePath}`);
1550
+ }
1551
+ return normalized;
1552
+ }
1553
+ function sandboxNameFor(vmId) {
1554
+ return `mp-${vmId.replace(/[^a-zA-Z0-9.-]/g, "-")}`;
1555
+ }
1556
+ var DockerSandboxExecutor = class {
1557
+ constructor(config) {
1558
+ this.config = config;
1559
+ this.vms = /* @__PURE__ */ new Map();
1560
+ this.usedPorts = /* @__PURE__ */ new Set();
1561
+ this.loadedTemplates = /* @__PURE__ */ new Set();
1562
+ this.bin = config.bin;
1563
+ }
1564
+ allocatePort() {
1565
+ for (let p = this.config.portRangeStart; p <= this.config.portRangeEnd; p++) {
1566
+ if (!this.usedPorts.has(p)) {
1567
+ this.usedPorts.add(p);
1568
+ return p;
1569
+ }
1570
+ }
1571
+ throw new Error("no free ports in docker-sandbox port range");
1572
+ }
1573
+ resolveAgentCommand(stack) {
1574
+ return this.config.stackAgentCommands[stack] ?? this.config.stackAgentCommands.generic ?? "node app.js";
1575
+ }
1576
+ /** Local `docker` image tag for a stack, same resolution order as DockerExecutor.resolveImage. */
1577
+ localImageFor(stack) {
1578
+ const { imagePrefix, imageTag } = this.config;
1579
+ return `${imagePrefix}-${stack}:${imageTag}`;
1580
+ }
1581
+ /** Bridges the local docker image into sbx's own image store once per process; returns its ref. */
1582
+ async ensureTemplateLoaded(stack) {
1583
+ const explicit = this.config.stackTemplates[stack];
1584
+ if (explicit) return explicit;
1585
+ const localImage = this.localImageFor(stack);
1586
+ const templateRef = `docker.io/library/${localImage}`;
1587
+ if (this.loadedTemplates.has(templateRef)) return templateRef;
1588
+ const tmpFile = import_path6.default.join(import_os8.default.tmpdir(), `sbx-template-${stack}-${Date.now()}.tar`);
1589
+ try {
1590
+ await execFileAsync2("docker", ["save", localImage, "-o", tmpFile]);
1591
+ await execFileAsync2(this.bin, ["template", "load", tmpFile]);
1592
+ } catch (error) {
1593
+ throw new Error(`failed to bridge local image "${localImage}" into sbx \u2014 run scripts/setup-docker-images.sh: ${error}`);
1594
+ } finally {
1595
+ await import_promises5.default.rm(tmpFile, { force: true });
1596
+ }
1597
+ this.loadedTemplates.add(templateRef);
1598
+ return templateRef;
1599
+ }
1600
+ async create(spec) {
1601
+ const templateRef = await this.ensureTemplateLoaded(spec.stack);
1602
+ const agentCommand = this.resolveAgentCommand(spec.stack);
1603
+ const sandboxName = sandboxNameFor(spec.vmId);
1604
+ const hostPort = this.allocatePort();
1605
+ const vmDir = import_path6.default.join(this.config.workspaceRoot, spec.vmId);
1606
+ await import_promises5.default.mkdir(vmDir, { recursive: true });
1607
+ const memMib = Math.max(spec.resources.memMib, this.config.minMemoryMib);
1608
+ const createArgs = [
1609
+ "create",
1610
+ "shell",
1611
+ vmDir,
1612
+ "--template",
1613
+ templateRef,
1614
+ "--name",
1615
+ sandboxName,
1616
+ "-p",
1617
+ `127.0.0.1:${hostPort}:${this.config.guestPort}`,
1618
+ "-m",
1619
+ `${memMib}m`,
1620
+ "--cpus",
1621
+ String(spec.resources.vcpuCount),
1622
+ "-e",
1623
+ `VM_ID=${spec.vmId}`,
1624
+ "-e",
1625
+ `STACK=${spec.stack}`,
1626
+ ...spec.sessionId ? ["-e", `SESSION_ID=${spec.sessionId}`] : [],
1627
+ ...spec.appUrl ? ["-e", `APP_URL=${spec.appUrl}`] : []
1628
+ ];
1629
+ src_default.info({ vmId: spec.vmId, bin: this.bin, args: createArgs }, "creating docker sandbox");
1630
+ try {
1631
+ await execFileAsync2(this.bin, createArgs);
1632
+ this.launchAgent(sandboxName, agentCommand);
1633
+ } catch (error) {
1634
+ this.usedPorts.delete(hostPort);
1635
+ await import_promises5.default.rm(vmDir, { recursive: true, force: true }).catch(() => {
1636
+ });
1637
+ throw error;
1638
+ }
1639
+ const handle = {
1640
+ vmId: spec.vmId,
1641
+ sessionId: spec.sessionId,
1642
+ stack: spec.stack,
1643
+ resources: { ...spec.resources, memMib },
1644
+ running: true,
1645
+ metadata: { sandboxName, hostPort, vmDir, agentCommand }
1646
+ };
1647
+ this.vms.set(spec.vmId, { handle, sandboxName, vmDir, hostPort, agentCommand });
1648
+ await this.waitForAgent(hostPort);
1649
+ src_default.info({ vmId: spec.vmId, sandboxName, hostPort }, "docker sandbox ready");
1650
+ return handle;
1651
+ }
1652
+ /**
1653
+ * `sbx create shell` doesn't run the image's own CMD — launch the guest agent explicitly.
1654
+ *
1655
+ * Fire-and-forget: `sbx exec -d` (detach) launches the background command correctly but its
1656
+ * own CLI process then hangs indefinitely instead of returning — confirmed live (the agent
1657
+ * was already answering on its port while the CLI call itself sat stuck for 14+ minutes,
1658
+ * needing a kill -9). `waitForAgent`'s port poll is the real readiness signal here, not this
1659
+ * process exiting, so it's spawned detached and never awaited.
1660
+ */
1661
+ launchAgent(sandboxName, agentCommand) {
1662
+ const proc = (0, import_child_process6.spawn)(this.bin, ["exec", "-d", "-w", AGENT_DIR, sandboxName, "/bin/sh", "-c", agentCommand], {
1663
+ stdio: "ignore"
1664
+ });
1665
+ proc.on("error", (error) => src_default.warn({ sandboxName, err: error }, "sbx exec -d failed to spawn"));
1666
+ proc.unref();
1667
+ }
1668
+ async pause(vmId) {
1669
+ return this.stop(vmId);
1670
+ }
1671
+ async resume(vmId) {
1672
+ return this.restore(vmId);
1673
+ }
1674
+ async stop(vmId) {
1675
+ const vm = this.mustGet(vmId);
1676
+ await execFileAsync2(this.bin, ["stop", vm.sandboxName]);
1677
+ return this.setRunning(vmId, false);
1678
+ }
1679
+ async restore(vmId) {
1680
+ const vm = this.mustGet(vmId);
1681
+ await execFileAsync2(this.bin, ["run", "--name", vm.sandboxName, "--detached"]);
1682
+ const alreadyUp = await this.probeAgent(vm.hostPort);
1683
+ if (!alreadyUp) this.launchAgent(vm.sandboxName, vm.agentCommand);
1684
+ await this.waitForAgent(vm.hostPort);
1685
+ return this.setRunning(vmId, true);
1686
+ }
1687
+ async destroy(vmId) {
1688
+ const vm = this.vms.get(vmId);
1689
+ if (!vm) return;
1690
+ await execFileAsync2(this.bin, ["rm", "-f", vm.sandboxName]).catch(
1691
+ (error) => src_default.warn({ vmId, err: error }, "sbx rm on destroy failed")
1692
+ );
1693
+ await import_promises5.default.rm(vm.vmDir, { recursive: true, force: true }).catch(() => {
1694
+ });
1695
+ this.usedPorts.delete(vm.hostPort);
1696
+ this.vms.delete(vmId);
1697
+ src_default.info({ vmId }, "docker sandbox destroyed");
1698
+ }
1699
+ get(vmId) {
1700
+ return this.vms.get(vmId)?.handle;
1701
+ }
1702
+ list() {
1703
+ return [...this.vms.values()].map((v) => v.handle);
1704
+ }
1705
+ /**
1706
+ * Recover a sandbox after a worker restart: it's owned by the independent `sandboxd`
1707
+ * daemon, so a liveness probe against its name is enough to confirm it's still there.
1708
+ */
1709
+ async reattach(record) {
1710
+ const meta = record.runtimeMetadata;
1711
+ if (!meta?.sandboxName || meta.hostPort === void 0 || !meta.vmDir || !meta.agentCommand) return void 0;
1712
+ let running;
1713
+ try {
1714
+ await execFileAsync2(this.bin, ["exec", meta.sandboxName, "true"]);
1715
+ running = true;
1716
+ } catch {
1717
+ try {
1718
+ await execFileAsync2(this.bin, ["ls", "--json"]);
1719
+ running = false;
1720
+ } catch {
1721
+ return void 0;
1722
+ }
1723
+ }
1724
+ const handle = {
1725
+ vmId: record.vmId,
1726
+ sessionId: record.sessionId,
1727
+ stack: record.stack,
1728
+ resources: record.resources,
1729
+ running,
1730
+ metadata: meta
1731
+ };
1732
+ this.usedPorts.add(meta.hostPort);
1733
+ this.vms.set(record.vmId, {
1734
+ handle,
1735
+ sandboxName: meta.sandboxName,
1736
+ vmDir: meta.vmDir,
1737
+ hostPort: meta.hostPort,
1738
+ agentCommand: meta.agentCommand
1739
+ });
1740
+ src_default.info({ vmId: record.vmId, sandboxName: meta.sandboxName }, "docker sandbox reattached");
1741
+ return handle;
1742
+ }
1743
+ async proxyHttp(vmId, request) {
1744
+ const vm = this.mustGet(vmId);
1745
+ if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
1746
+ return new Promise((resolve, reject) => {
1747
+ const req = import_http4.default.request(
1748
+ { host: "127.0.0.1", port: vm.hostPort, method: request.method, path: request.path, headers: request.headers },
1749
+ (res) => {
1750
+ const chunks = [];
1751
+ res.on("data", (c) => chunks.push(c));
1752
+ res.on(
1753
+ "end",
1754
+ () => resolve({
1755
+ statusCode: res.statusCode ?? 502,
1756
+ headers: Object.fromEntries(
1757
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
1758
+ ),
1759
+ body: Buffer.concat(chunks)
1760
+ })
1761
+ );
1762
+ }
1763
+ );
1764
+ req.on("error", reject);
1765
+ if (request.body) req.write(request.body);
1766
+ req.end();
1767
+ });
1768
+ }
1769
+ async exec(vmId, request) {
1770
+ const vm = this.mustGet(vmId);
1771
+ const cwd = request.cwd ? `${WORKSPACE_DIR2}/${assertSafeWorkspacePath2(request.cwd)}` : WORKSPACE_DIR2;
1772
+ const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS3, MAX_EXEC_TIMEOUT_MS3);
1773
+ const startedAt = Date.now();
1774
+ return new Promise((resolve) => {
1775
+ (0, import_child_process6.execFile)(
1776
+ this.bin,
1777
+ ["exec", "-w", cwd, vm.sandboxName, "/bin/sh", "-c", request.command],
1778
+ { timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES3 },
1779
+ (error, stdout, stderr) => {
1780
+ const failed = error;
1781
+ const timedOut = Boolean(failed?.killed);
1782
+ const truncated = failed?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
1783
+ resolve({
1784
+ exitCode: typeof failed?.code === "number" ? failed.code : failed ? 1 : 0,
1785
+ stdout: stdout.slice(0, MAX_OUTPUT_BYTES3),
1786
+ stderr: stderr.slice(0, MAX_OUTPUT_BYTES3),
1787
+ durationMs: Date.now() - startedAt,
1788
+ truncated: Boolean(truncated),
1789
+ timedOut
1790
+ });
1791
+ }
1792
+ );
1793
+ });
1794
+ }
1795
+ async writeFiles(vmId, files) {
1796
+ const vm = this.mustGet(vmId);
1797
+ const written = [];
1798
+ for (const file of files) {
1799
+ const relativePath = assertSafeWorkspacePath2(file.path);
1800
+ const buffer = Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8");
1801
+ if (buffer.byteLength > MAX_FILE_BYTES3) {
1802
+ throw new Error(`File too large (${buffer.byteLength} bytes, max ${MAX_FILE_BYTES3}): ${file.path}`);
1803
+ }
1804
+ const dir = relativePath.includes("/") ? relativePath.slice(0, relativePath.lastIndexOf("/")) : "";
1805
+ if (dir) {
1806
+ await execFileAsync2(this.bin, ["exec", vm.sandboxName, "mkdir", "-p", `${WORKSPACE_DIR2}/${dir}`]);
1807
+ }
1808
+ const tmpFile = import_path6.default.join(import_os8.default.tmpdir(), `sbx-write-${vmId}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1809
+ await import_promises5.default.writeFile(tmpFile, buffer);
1810
+ try {
1811
+ await execFileAsync2(this.bin, ["cp", tmpFile, `${vm.sandboxName}:${WORKSPACE_DIR2}/${relativePath}`]);
1812
+ } finally {
1813
+ await import_promises5.default.rm(tmpFile, { force: true });
1814
+ }
1815
+ written.push({ path: relativePath, bytes: buffer.byteLength });
1816
+ }
1817
+ src_default.info({ vmId, files: written.length }, "docker sandbox files written");
1818
+ return { written };
1819
+ }
1820
+ mustGet(vmId) {
1821
+ const vm = this.vms.get(vmId);
1822
+ if (!vm) throw new Error(`vm ${vmId} not found`);
1823
+ return vm;
1824
+ }
1825
+ setRunning(vmId, running) {
1826
+ const vm = this.mustGet(vmId);
1827
+ vm.handle = { ...vm.handle, running };
1828
+ return vm.handle;
1829
+ }
1830
+ async probeAgent(port) {
1831
+ return new Promise((resolve) => {
1832
+ const req = import_http4.default.get({ host: "127.0.0.1", port, path: "/", timeout: 2e3 }, (res) => {
1833
+ res.resume();
1834
+ resolve(true);
1835
+ });
1836
+ req.on("error", () => resolve(false));
1837
+ req.on("timeout", () => {
1838
+ req.destroy();
1839
+ resolve(false);
1840
+ });
1841
+ });
1842
+ }
1843
+ async waitForAgent(port, timeoutMs = 3e4) {
1844
+ const deadline = Date.now() + timeoutMs;
1845
+ for (; ; ) {
1846
+ if (await this.probeAgent(port)) return;
1847
+ if (Date.now() > deadline) throw new Error(`timed out waiting for docker sandbox agent on port ${port}`);
1848
+ await new Promise((resolve) => setTimeout(resolve, 500));
1849
+ }
1850
+ }
1851
+ };
1852
+
1853
+ // src/executor/remote-executor.ts
1854
+ var import_http5 = __toESM(require("http"));
1855
+ var import_https = __toESM(require("https"));
1856
+ var RemoteExecutor = class {
1857
+ constructor(config) {
1858
+ // The remote agent is the actual source of truth, but MultiEngineExecutor.route() (used by
1859
+ // pause/resume/stop/restore/destroy/proxyHttp/exec/writeFiles) needs get() to find the
1860
+ // right executor for a vmId — cache the handle each call returns so it does.
1861
+ this.vms = /* @__PURE__ */ new Map();
1862
+ this.base = config.agentUrl.replace(/\/$/, "");
1863
+ this.headers = {
1864
+ "Content-Type": "application/json",
1865
+ ...config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}
1866
+ };
1867
+ }
1868
+ async create(spec) {
1869
+ const handle = await this.request("POST", "/vms", spec);
1870
+ this.vms.set(spec.vmId, handle);
1871
+ return handle;
1872
+ }
1873
+ async pause(vmId) {
1874
+ return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/pause`));
1875
+ }
1876
+ async resume(vmId) {
1877
+ return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/resume`));
1878
+ }
1879
+ async stop(vmId) {
1880
+ return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/stop`));
1881
+ }
1882
+ async restore(vmId) {
1883
+ return this.updateCached(vmId, await this.request("POST", `/vms/${vmId}/restore`));
1884
+ }
1885
+ async destroy(vmId) {
1886
+ await this.request("DELETE", `/vms/${vmId}`);
1887
+ this.vms.delete(vmId);
1888
+ }
1889
+ get(vmId) {
1890
+ return this.vms.get(vmId);
1891
+ }
1892
+ list() {
1893
+ return [...this.vms.values()];
1894
+ }
1895
+ /**
1896
+ * Recover a remote-agent VM after a worker restart. The agent owns the actual VM state
1897
+ * independently of this process, so reattaching is just confirming it still knows about
1898
+ * this vmId and re-populating the local get()/list() cache above.
1899
+ */
1900
+ async reattach(record) {
1901
+ try {
1902
+ const handle = await withTimeout(this.request("GET", `/vms/${record.vmId}`), 5e3);
1903
+ this.vms.set(record.vmId, handle);
1904
+ return handle;
1905
+ } catch {
1906
+ return void 0;
1907
+ }
1908
+ }
1909
+ updateCached(vmId, handle) {
1910
+ this.vms.set(vmId, handle);
1911
+ return handle;
1912
+ }
1913
+ async proxyHttp(vmId, request) {
1914
+ const result = await this.request("POST", `/vms/${vmId}/proxy`, {
1915
+ method: request.method,
1916
+ path: request.path,
1917
+ headers: request.headers,
1918
+ body: request.body?.toString("base64")
1919
+ });
1920
+ return {
1921
+ statusCode: result.statusCode,
1922
+ headers: result.headers,
1923
+ body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
1924
+ };
1925
+ }
1926
+ request(method, path7, body) {
1927
+ const url = new URL(this.base + path7);
1928
+ const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
1929
+ const payload = body !== void 0 ? JSON.stringify(body) : void 0;
1930
+ return new Promise((resolve, reject) => {
1931
+ const req = lib.request(
1932
+ {
1933
+ hostname: url.hostname,
1934
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
1935
+ path: url.pathname,
1936
+ method,
1937
+ headers: {
1938
+ ...this.headers,
1939
+ ...payload ? { "Content-Length": Buffer.byteLength(payload) } : {}
1940
+ }
1941
+ },
1942
+ (res) => {
1943
+ const chunks = [];
1944
+ res.on("data", (c) => chunks.push(c));
1945
+ res.on("end", () => {
1946
+ const text = Buffer.concat(chunks).toString();
1947
+ if (res.statusCode && res.statusCode >= 400) {
1948
+ reject(new Error(`vm-agent ${method} ${path7} \u2192 ${res.statusCode}: ${text}`));
1949
+ return;
1950
+ }
1951
+ if (res.statusCode === 204 || !text) {
1952
+ resolve(void 0);
1953
+ return;
1954
+ }
1955
+ try {
1956
+ resolve(JSON.parse(text));
1957
+ } catch {
1958
+ resolve(text);
1959
+ }
1960
+ });
1961
+ }
1962
+ );
1963
+ req.on("error", reject);
1964
+ if (payload) req.write(payload);
1965
+ req.end();
1966
+ });
1967
+ }
1968
+ };
1969
+
1970
+ // src/executor/lambda-microvm-executor.ts
1971
+ var import_https2 = __toESM(require("https"));
1972
+ var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
1973
+ var AUTH_TOKEN_TTL_MINUTES = 60;
1974
+ var AUTH_TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
1975
+ var LambdaMicrovmExecutor = class {
1976
+ constructor(config) {
1977
+ this.config = config;
1978
+ this.vms = /* @__PURE__ */ new Map();
1979
+ this.client = new import_client_lambda_microvms.LambdaMicrovmsClient(config.region ? { region: config.region } : {});
1980
+ }
1981
+ async create(spec) {
1982
+ const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
1983
+ const maxIdle = this.config.maxIdleSeconds ?? 900;
1984
+ const run = await this.client.send(
1985
+ new import_client_lambda_microvms.RunMicrovmCommand({
1986
+ imageIdentifier,
1987
+ ingressNetworkConnectors: this.config.ingressConnectorArns,
1988
+ egressNetworkConnectors: this.config.egressConnectorArns,
1989
+ executionRoleArn: this.config.executionRoleArn,
1990
+ maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
1991
+ ...maxIdle > 0 ? {
1992
+ idlePolicy: {
1993
+ autoResumeEnabled: true,
1994
+ maxIdleDurationSeconds: maxIdle,
1995
+ suspendedDurationSeconds: this.config.suspendedDurationSeconds ?? 28800
1996
+ }
1997
+ } : {},
1998
+ // The guest's /run lifecycle hook receives this payload — lets the image
1999
+ // identify which builder session/VM it serves.
2000
+ runHookPayload: JSON.stringify({ vmId: spec.vmId, sessionId: spec.sessionId, stack: spec.stack, appUrl: spec.appUrl })
2001
+ })
2002
+ );
2003
+ if (!run.microvmId || !run.endpoint) {
2004
+ throw new Error("run-microvm returned no microvmId/endpoint");
2005
+ }
2006
+ src_default.info(
2007
+ { vmId: spec.vmId, microvmId: run.microvmId, endpoint: run.endpoint, state: run.state },
2008
+ "[LambdaMicrovm] run-microvm accepted"
2009
+ );
2010
+ const handle = {
2011
+ vmId: spec.vmId,
2012
+ sessionId: spec.sessionId,
2013
+ stack: spec.stack,
2014
+ // Resources are fixed at MicroVM image build time; echo the request back for bookkeeping.
2015
+ resources: spec.resources,
2016
+ running: true,
2017
+ metadata: { microvmId: run.microvmId, endpoint: run.endpoint }
2018
+ };
2019
+ const vm = { handle, microvmId: run.microvmId, endpoint: run.endpoint };
2020
+ this.vms.set(spec.vmId, vm);
2021
+ try {
2022
+ await this.waitForState(run.microvmId, "RUNNING", this.config.createTimeoutMs ?? 25e3);
2023
+ } catch (error) {
2024
+ this.vms.delete(spec.vmId);
2025
+ await this.client.send(new import_client_lambda_microvms.TerminateMicrovmCommand({ microvmIdentifier: run.microvmId })).catch(() => {
2026
+ });
2027
+ throw error;
2028
+ }
2029
+ src_default.info({ vmId: spec.vmId, microvmId: run.microvmId }, "[LambdaMicrovm] vm running");
2030
+ return handle;
2031
+ }
2032
+ async pause(vmId) {
2033
+ const vm = this.mustGet(vmId);
2034
+ await this.client.send(new import_client_lambda_microvms.SuspendMicrovmCommand({ microvmIdentifier: vm.microvmId }));
2035
+ return this.setRunning(vmId, false);
2036
+ }
2037
+ async resume(vmId) {
2038
+ const vm = this.mustGet(vmId);
2039
+ await this.client.send(new import_client_lambda_microvms.ResumeMicrovmCommand({ microvmIdentifier: vm.microvmId }));
2040
+ return this.setRunning(vmId, true);
2041
+ }
2042
+ // Suspend preserves memory + disk, so stop/restore are the same checkpoint
2043
+ // operations as pause/resume — there is no separate "powered off" state.
2044
+ stop(vmId) {
2045
+ return this.pause(vmId);
2046
+ }
2047
+ restore(vmId) {
2048
+ return this.resume(vmId);
2049
+ }
2050
+ async destroy(vmId) {
2051
+ const vm = this.vms.get(vmId);
2052
+ if (!vm) return;
2053
+ await this.client.send(new import_client_lambda_microvms.TerminateMicrovmCommand({ microvmIdentifier: vm.microvmId }));
2054
+ this.vms.delete(vmId);
2055
+ src_default.info({ vmId, microvmId: vm.microvmId }, "[LambdaMicrovm] vm terminated");
2056
+ }
2057
+ get(vmId) {
2058
+ return this.vms.get(vmId)?.handle;
2059
+ }
2060
+ list() {
2061
+ return [...this.vms.values()].map((v) => v.handle);
2062
+ }
2063
+ async proxyHttp(vmId, request) {
2064
+ const vm = this.mustGet(vmId);
2065
+ const auth = await this.getAuthHeaders(vm);
2066
+ return new Promise((resolve, reject) => {
2067
+ const req = import_https2.default.request(
2068
+ {
2069
+ host: vm.endpoint,
2070
+ method: request.method,
2071
+ path: request.path,
2072
+ headers: {
2073
+ ...request.headers,
2074
+ ...auth,
2075
+ // Lambda routes to port 8080 by default; make the guest port explicit.
2076
+ "X-aws-proxy-port": "8080",
2077
+ host: vm.endpoint
2078
+ }
2079
+ },
2080
+ (res) => {
2081
+ const chunks = [];
2082
+ res.on("data", (c) => chunks.push(c));
2083
+ res.on(
2084
+ "end",
2085
+ () => resolve({
2086
+ statusCode: res.statusCode ?? 502,
2087
+ headers: Object.fromEntries(
2088
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
2089
+ ),
2090
+ body: Buffer.concat(chunks)
2091
+ })
2092
+ );
2093
+ }
2094
+ );
2095
+ req.on("error", reject);
2096
+ if (request.body) req.write(request.body);
2097
+ req.end();
2098
+ });
2099
+ }
2100
+ /**
2101
+ * Recover a Lambda MicroVM after a worker restart. It's an independent AWS-managed
2102
+ * resource that outlives this process — just re-describe it by ID and, unless it's been
2103
+ * terminated, rebuild the local handle (the auth token refetches lazily as usual).
2104
+ */
2105
+ async reattach(record) {
2106
+ const meta = record.runtimeMetadata;
2107
+ if (!meta?.microvmId || !meta.endpoint) return void 0;
2108
+ const described = await this.client.send(new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: meta.microvmId })).catch(() => void 0);
2109
+ if (!described || described.state === "TERMINATED" || described.state === "TERMINATING") {
2110
+ return void 0;
2111
+ }
2112
+ const handle = {
2113
+ vmId: record.vmId,
2114
+ sessionId: record.sessionId,
2115
+ stack: record.stack,
2116
+ resources: record.resources,
2117
+ running: described.state === "RUNNING",
2118
+ metadata: meta
2119
+ };
2120
+ this.vms.set(record.vmId, { handle, microvmId: meta.microvmId, endpoint: meta.endpoint });
2121
+ src_default.info({ vmId: record.vmId, microvmId: meta.microvmId, state: described.state }, "[LambdaMicrovm] vm reattached");
2122
+ return handle;
2123
+ }
2124
+ mustGet(vmId) {
2125
+ const vm = this.vms.get(vmId);
2126
+ if (!vm) throw new Error(`vm ${vmId} not found`);
2127
+ return vm;
2128
+ }
2129
+ setRunning(vmId, running) {
2130
+ const vm = this.mustGet(vmId);
2131
+ vm.handle = { ...vm.handle, running };
2132
+ return vm.handle;
2133
+ }
2134
+ async getAuthHeaders(vm) {
2135
+ if (vm.authToken && vm.authToken.expiresAt - Date.now() > AUTH_TOKEN_REFRESH_MARGIN_MS) {
2136
+ return vm.authToken.headers;
2137
+ }
2138
+ const resp = await this.client.send(
2139
+ new import_client_lambda_microvms.CreateMicrovmAuthTokenCommand({
2140
+ microvmIdentifier: vm.microvmId,
2141
+ expirationInMinutes: AUTH_TOKEN_TTL_MINUTES,
2142
+ allowedPorts: [{ allPorts: {} }]
2143
+ })
2144
+ );
2145
+ if (!resp.authToken) throw new Error("create-microvm-auth-token returned no token");
2146
+ vm.authToken = {
2147
+ headers: resp.authToken,
2148
+ expiresAt: Date.now() + AUTH_TOKEN_TTL_MINUTES * 6e4
2149
+ };
2150
+ return vm.authToken.headers;
2151
+ }
2152
+ async waitForState(microvmId, target, timeoutMs) {
2153
+ const deadline = Date.now() + timeoutMs;
2154
+ for (; ; ) {
2155
+ const resp = await this.client.send(new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: microvmId }));
2156
+ if (resp.state === target) return;
2157
+ if (resp.state === "TERMINATED" || resp.state === "TERMINATING") {
2158
+ throw new Error(`microvm ${microvmId} entered ${resp.state} while waiting for ${target}`);
2159
+ }
2160
+ if (Date.now() > deadline) {
2161
+ throw new Error(`timed out waiting for microvm ${microvmId} to reach ${target} (still ${resp.state})`);
2162
+ }
2163
+ await new Promise((r) => setTimeout(r, 1e3));
2164
+ }
2165
+ }
2166
+ };
2167
+
2168
+ // src/executor/fargate-executor.ts
2169
+ var import_http6 = __toESM(require("http"));
2170
+ var import_client_ecs = require("@aws-sdk/client-ecs");
2171
+ var FARGATE_MEM_RANGES = {
2172
+ 256: [512, 2048],
2173
+ 512: [1024, 4096],
2174
+ 1024: [2048, 8192],
2175
+ 2048: [4096, 16384],
2176
+ 4096: [8192, 30720]
2177
+ };
2178
+ function fargateResources(vcpuCount, memMib) {
2179
+ const cpu = Math.min(4096, Math.max(256, vcpuCount * 1024));
2180
+ const [min, max] = FARGATE_MEM_RANGES[cpu];
2181
+ let memory = Math.min(max, Math.max(min, memMib));
2182
+ if (memory > 512) memory = Math.ceil(memory / 1024) * 1024;
2183
+ return { cpu: String(cpu), memory: String(Math.min(max, memory)) };
2184
+ }
2185
+ var FargateExecutor = class {
2186
+ constructor(config) {
2187
+ this.config = config;
2188
+ this.vms = /* @__PURE__ */ new Map();
2189
+ this.containerNames = /* @__PURE__ */ new Map();
2190
+ this.client = new import_client_ecs.ECSClient(config.region ? { region: config.region } : {});
2191
+ }
2192
+ /** First container of the task definition — the target for env overrides. Cached per definition. */
2193
+ async resolveContainerName(taskDefinition) {
2194
+ const cached = this.containerNames.get(taskDefinition);
2195
+ if (cached) return cached;
2196
+ const described = await this.client.send(new import_client_ecs.DescribeTaskDefinitionCommand({ taskDefinition }));
2197
+ const name = described.taskDefinition?.containerDefinitions?.[0]?.name;
2198
+ if (!name) throw new Error(`task definition ${taskDefinition} has no containers`);
2199
+ this.containerNames.set(taskDefinition, name);
2200
+ return name;
2201
+ }
2202
+ async waitForRunning(taskArn) {
2203
+ const timeoutMs = this.config.startTimeoutMs ?? 18e4;
2204
+ const deadline = Date.now() + timeoutMs;
2205
+ for (; ; ) {
2206
+ const described = await this.client.send(
2207
+ new import_client_ecs.DescribeTasksCommand({ cluster: this.config.cluster, tasks: [taskArn] })
2208
+ );
2209
+ const task = described.tasks?.[0];
2210
+ if (!task) throw new Error(`task ${taskArn} not found`);
2211
+ if (task.lastStatus === "STOPPED") {
2212
+ throw new Error(`fargate task stopped: ${task.stoppedReason ?? "unknown reason"}`);
2213
+ }
2214
+ if (task.lastStatus === "RUNNING") {
2215
+ const ip = this.config.assignPublicIp ? await this.resolvePublicIp(task.attachments) : task.containers?.[0]?.networkInterfaces?.[0]?.privateIpv4Address;
2216
+ if (!ip) throw new Error(`fargate task ${taskArn} is RUNNING but has no ${this.config.assignPublicIp ? "public" : "private"} IP`);
2217
+ return ip;
2218
+ }
2219
+ if (Date.now() > deadline) {
2220
+ throw new Error(`fargate task did not reach RUNNING within ${timeoutMs}ms (last status: ${task.lastStatus})`);
2221
+ }
2222
+ await new Promise((resolve) => setTimeout(resolve, 3e3));
2223
+ }
2224
+ }
2225
+ async resolvePublicIp(attachments) {
2226
+ const eniId = attachments?.find((a) => a.type === "ElasticNetworkInterface")?.details?.find((d) => d.name === "networkInterfaceId")?.value;
2227
+ if (!eniId) return void 0;
2228
+ const { EC2Client, DescribeNetworkInterfacesCommand } = await import("@aws-sdk/client-ec2");
2229
+ const ec2 = new EC2Client(this.config.region ? { region: this.config.region } : {});
2230
+ const described = await ec2.send(new DescribeNetworkInterfacesCommand({ NetworkInterfaceIds: [eniId] }));
2231
+ return described.NetworkInterfaces?.[0]?.Association?.PublicIp;
2232
+ }
2233
+ async create(spec) {
2234
+ const taskDefinition = this.config.stackTaskDefinitions?.[spec.stack] ?? this.config.taskDefinition;
2235
+ if (!taskDefinition) {
2236
+ throw new Error(`no Fargate task definition configured for stack "${spec.stack}"`);
2237
+ }
2238
+ const containerName = await this.resolveContainerName(taskDefinition);
2239
+ const { cpu, memory } = fargateResources(spec.resources.vcpuCount, spec.resources.memMib);
2240
+ const run = await this.client.send(
2241
+ new import_client_ecs.RunTaskCommand({
2242
+ cluster: this.config.cluster,
2243
+ taskDefinition,
2244
+ launchType: "FARGATE",
2245
+ count: 1,
2246
+ networkConfiguration: {
2247
+ awsvpcConfiguration: {
2248
+ subnets: this.config.subnets,
2249
+ securityGroups: this.config.securityGroups,
2250
+ assignPublicIp: this.config.assignPublicIp ? "ENABLED" : "DISABLED"
2251
+ }
2252
+ },
2253
+ overrides: {
2254
+ cpu,
2255
+ memory,
2256
+ containerOverrides: [
2257
+ {
2258
+ name: containerName,
2259
+ environment: [
2260
+ { name: "VM_ID", value: spec.vmId },
2261
+ ...spec.sessionId ? [{ name: "SESSION_ID", value: spec.sessionId }] : [],
2262
+ { name: "STACK", value: spec.stack },
2263
+ // Release artifact: the container agent pulls, unzips, and starts it
2264
+ ...spec.appUrl ? [{ name: "APP_URL", value: spec.appUrl }] : []
2265
+ ]
2266
+ }
2267
+ ]
2268
+ },
2269
+ tags: [
2270
+ { key: "vmId", value: spec.vmId },
2271
+ ...spec.sessionId ? [{ key: "sessionId", value: spec.sessionId }] : []
2272
+ ]
2273
+ })
2274
+ );
2275
+ const taskArn = run.tasks?.[0]?.taskArn;
2276
+ if (!taskArn) {
2277
+ throw new Error(`RunTask failed: ${run.failures?.[0]?.reason ?? "no task returned"}`);
2278
+ }
2279
+ src_default.info({ vmId: spec.vmId, taskArn, taskDefinition, cpu, memory }, "fargate task started");
2280
+ try {
2281
+ const ip = await this.waitForRunning(taskArn);
2282
+ const handle = {
2283
+ vmId: spec.vmId,
2284
+ sessionId: spec.sessionId,
2285
+ stack: spec.stack,
2286
+ resources: spec.resources,
2287
+ running: true,
2288
+ metadata: { taskArn, ip }
2289
+ };
2290
+ this.vms.set(spec.vmId, { handle, taskArn, ip });
2291
+ src_default.info({ vmId: spec.vmId, ip }, "fargate task running");
2292
+ return handle;
2293
+ } catch (error) {
2294
+ await this.client.send(new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: taskArn, reason: "startup failed" })).catch(() => {
2295
+ });
2296
+ throw error;
2297
+ }
2298
+ }
2299
+ async pause(vmId) {
2300
+ throw new Error(`fargate tasks cannot be paused (vm ${vmId})`);
2301
+ }
2302
+ async resume(vmId) {
2303
+ throw new Error(`fargate tasks cannot be resumed (vm ${vmId})`);
2304
+ }
2305
+ async restore(vmId) {
2306
+ throw new Error(`fargate tasks cannot be restored (vm ${vmId})`);
2307
+ }
2308
+ async stop(vmId) {
2309
+ const vm = this.mustGet(vmId);
2310
+ await this.client.send(
2311
+ new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: vm.taskArn, reason: "stopped via API" })
2312
+ );
2313
+ vm.handle.running = false;
2314
+ return vm.handle;
2315
+ }
2316
+ async destroy(vmId) {
2317
+ const vm = this.vms.get(vmId);
2318
+ if (!vm) return;
2319
+ await this.client.send(new import_client_ecs.StopTaskCommand({ cluster: this.config.cluster, task: vm.taskArn, reason: "destroyed via API" })).catch((error) => src_default.warn({ vmId, err: error }, "fargate stop on destroy failed"));
2320
+ this.vms.delete(vmId);
2321
+ }
2322
+ get(vmId) {
2323
+ return this.vms.get(vmId)?.handle;
2324
+ }
2325
+ list() {
2326
+ return [...this.vms.values()].map((vm) => vm.handle);
2327
+ }
2328
+ async proxyHttp(vmId, request) {
2329
+ const vm = this.mustGet(vmId);
2330
+ if (!vm.handle.running) throw new Error(`vm ${vmId} is not running`);
2331
+ return new Promise((resolve, reject) => {
2332
+ const req = import_http6.default.request(
2333
+ {
2334
+ host: vm.ip,
2335
+ port: this.config.guestPort ?? 8080,
2336
+ method: request.method,
2337
+ path: request.path,
2338
+ headers: request.headers
2339
+ },
2340
+ (res) => {
2341
+ const chunks = [];
2342
+ res.on("data", (c) => chunks.push(c));
2343
+ res.on(
2344
+ "end",
2345
+ () => resolve({
2346
+ statusCode: res.statusCode ?? 502,
2347
+ headers: Object.fromEntries(
2348
+ Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
2349
+ ),
2350
+ body: Buffer.concat(chunks)
2351
+ })
2352
+ );
2353
+ }
2354
+ );
2355
+ req.on("error", reject);
2356
+ if (request.body) req.write(request.body);
2357
+ req.end();
2358
+ });
2359
+ }
2360
+ /**
2361
+ * Recover a Fargate task after a worker restart. The task is an independent AWS resource
2362
+ * that outlives this process — just re-describe it by ARN and, if it's still RUNNING,
2363
+ * rebuild the local handle.
2364
+ */
2365
+ async reattach(record) {
2366
+ const meta = record.runtimeMetadata;
2367
+ if (!meta?.taskArn) return void 0;
2368
+ const described = await this.client.send(new import_client_ecs.DescribeTasksCommand({ cluster: this.config.cluster, tasks: [meta.taskArn] })).catch(() => void 0);
2369
+ const task = described?.tasks?.[0];
2370
+ if (!task || task.lastStatus !== "RUNNING") return void 0;
2371
+ const ip = this.config.assignPublicIp ? await this.resolvePublicIp(task.attachments) : task.containers?.[0]?.networkInterfaces?.[0]?.privateIpv4Address;
2372
+ if (!ip) return void 0;
2373
+ const handle = {
2374
+ vmId: record.vmId,
2375
+ sessionId: record.sessionId,
2376
+ stack: record.stack,
2377
+ resources: record.resources,
2378
+ running: true,
2379
+ metadata: { taskArn: meta.taskArn, ip }
2380
+ };
2381
+ this.vms.set(record.vmId, { handle, taskArn: meta.taskArn, ip });
2382
+ src_default.info({ vmId: record.vmId, taskArn: meta.taskArn, ip }, "fargate task reattached");
2383
+ return handle;
2384
+ }
2385
+ mustGet(vmId) {
2386
+ const vm = this.vms.get(vmId);
2387
+ if (!vm) throw new Error(`vm ${vmId} not found`);
2388
+ return vm;
2389
+ }
2390
+ };
2391
+
2392
+ // src/executor/detect.ts
2393
+ var import_fs2 = __toESM(require("fs"));
2394
+ var import_child_process7 = require("child_process");
2395
+ var ENGINE_PRIORITY = ["firecracker", "qemu", "docker-sandbox", "docker", "lambda-microvm", "fargate", "remote", "mock"];
2396
+ function binaryOnPath(bin) {
2397
+ if (bin.includes("/")) {
2398
+ try {
2399
+ import_fs2.default.accessSync(bin, import_fs2.default.constants.X_OK);
2400
+ return true;
2401
+ } catch {
2402
+ return false;
2403
+ }
2404
+ }
2405
+ try {
2406
+ (0, import_child_process7.execFileSync)("which", [bin], { stdio: "ignore" });
2407
+ return true;
2408
+ } catch {
2409
+ return false;
2410
+ }
2411
+ }
2412
+ function kvmUsable() {
2413
+ try {
2414
+ import_fs2.default.accessSync("/dev/kvm", import_fs2.default.constants.R_OK | import_fs2.default.constants.W_OK);
2415
+ return true;
2416
+ } catch {
2417
+ return false;
2418
+ }
2419
+ }
2420
+ function hasBaseImages(cfg) {
2421
+ if (cfg.kernelPath && cfg.initrdPath) return import_fs2.default.existsSync(cfg.kernelPath) && import_fs2.default.existsSync(cfg.initrdPath);
2422
+ return availableStacks(cfg.imagesDir).length > 0;
2423
+ }
2424
+ function probeQemu(config) {
2425
+ const { bin, accel } = config.qemu;
2426
+ if (!hasBaseImages(config.qemu)) {
2427
+ return { engine: "qemu", available: false, reason: `no base images under ${config.qemu.imagesDir} \u2014 run "worker-cli images pull" or scripts/setup-vm-images.sh` };
2428
+ }
2429
+ if (!binaryOnPath(bin)) {
2430
+ return { engine: "qemu", available: false, reason: `${bin} not found on PATH` };
2431
+ }
2432
+ if (accel === "hvf" && process.platform !== "darwin") {
2433
+ return { engine: "qemu", available: false, reason: "QEMU_ACCEL=hvf requires macOS (use kvm or tcg)" };
2434
+ }
2435
+ if (accel === "kvm" && !kvmUsable()) {
2436
+ return { engine: "qemu", available: false, reason: "QEMU_ACCEL=kvm but /dev/kvm is not accessible" };
2437
+ }
2438
+ return { engine: "qemu", available: true };
2439
+ }
2440
+ function probeFirecracker(config) {
2441
+ const { bin } = config.firecracker;
2442
+ if (process.platform !== "linux") {
2443
+ return { engine: "firecracker", available: false, reason: "requires a Linux host" };
2444
+ }
2445
+ if (!kvmUsable()) {
2446
+ return { engine: "firecracker", available: false, reason: "/dev/kvm is not accessible" };
2447
+ }
2448
+ if (!hasBaseImages(config.firecracker)) {
2449
+ return { engine: "firecracker", available: false, reason: `no base images under ${config.firecracker.imagesDir} \u2014 run "worker-cli images pull" or scripts/setup-vm-images.sh` };
2450
+ }
2451
+ if (!binaryOnPath(bin)) {
2452
+ return { engine: "firecracker", available: false, reason: `${bin} not found on PATH` };
2453
+ }
2454
+ return { engine: "firecracker", available: true };
2455
+ }
2456
+ function hasLocalDockerImage(imagePrefix, imageTag) {
2457
+ return VM_STACKS.some((stack) => {
2458
+ try {
2459
+ (0, import_child_process7.execFileSync)("docker", ["image", "inspect", `${imagePrefix}-${stack}:${imageTag}`], { stdio: "ignore" });
2460
+ return true;
2461
+ } catch {
2462
+ return false;
2463
+ }
2464
+ });
2465
+ }
2466
+ function probeDocker(config) {
2467
+ const { bin, imagePrefix, imageTag } = config.docker;
2468
+ if (!binaryOnPath(bin)) {
2469
+ return { engine: "docker", available: false, reason: `${bin} not found on PATH` };
2470
+ }
2471
+ try {
2472
+ (0, import_child_process7.execFileSync)(bin, ["info"], { stdio: "ignore" });
2473
+ } catch {
2474
+ return { engine: "docker", available: false, reason: "docker daemon not reachable" };
2475
+ }
2476
+ if (!hasLocalDockerImage(imagePrefix, imageTag)) {
2477
+ return { engine: "docker", available: false, reason: "no local docker images \u2014 run scripts/setup-docker-images.sh" };
2478
+ }
2479
+ return { engine: "docker", available: true };
2480
+ }
2481
+ function probeDockerSandbox(config) {
2482
+ const { bin, imagePrefix, imageTag } = config.dockerSandbox;
2483
+ if (!binaryOnPath(bin)) {
2484
+ return { engine: "docker-sandbox", available: false, reason: `${bin} not found on PATH` };
2485
+ }
2486
+ try {
2487
+ (0, import_child_process7.execFileSync)(bin, ["ls"], { stdio: "ignore" });
2488
+ } catch {
2489
+ return { engine: "docker-sandbox", available: false, reason: 'sbx daemon not reachable \u2014 run "sbx daemon start" and "sbx login"' };
2490
+ }
2491
+ if (!hasLocalDockerImage(imagePrefix, imageTag)) {
2492
+ return { engine: "docker-sandbox", available: false, reason: "no local docker images \u2014 run scripts/setup-docker-images.sh" };
2493
+ }
2494
+ return { engine: "docker-sandbox", available: true };
2495
+ }
2496
+ function probeRemote(config) {
2497
+ return config.remote.agentUrl ? { engine: "remote", available: true } : { engine: "remote", available: false, reason: "AGENT_URL not set" };
2498
+ }
2499
+ function probeLambdaMicrovm(config) {
2500
+ const { imageArn, stackImageArns } = config.lambdaMicrovm;
2501
+ return imageArn || Object.keys(stackImageArns).length ? { engine: "lambda-microvm", available: true } : { engine: "lambda-microvm", available: false, reason: "MICROVM_IMAGE_ARN / MICROVM_STACK_IMAGE_ARNS not set" };
2502
+ }
2503
+ function probeFargate(config) {
2504
+ const { cluster, taskDefinition, stackTaskDefinitions, subnets } = config.fargate;
2505
+ if (!cluster) {
2506
+ return { engine: "fargate", available: false, reason: "FARGATE_CLUSTER not set" };
2507
+ }
2508
+ if (!taskDefinition && !Object.keys(stackTaskDefinitions).length) {
2509
+ return { engine: "fargate", available: false, reason: "FARGATE_TASK_DEFINITION / FARGATE_STACK_TASK_DEFS not set" };
2510
+ }
2511
+ if (!subnets.length) {
2512
+ return { engine: "fargate", available: false, reason: "FARGATE_SUBNETS not set" };
2513
+ }
2514
+ return { engine: "fargate", available: true };
2515
+ }
2516
+ function probeEngines(config) {
2517
+ return [
2518
+ { engine: "mock", available: true },
2519
+ probeQemu(config),
2520
+ probeFirecracker(config),
2521
+ probeDocker(config),
2522
+ probeDockerSandbox(config),
2523
+ probeRemote(config),
2524
+ probeLambdaMicrovm(config),
2525
+ probeFargate(config)
2526
+ ];
2527
+ }
2528
+ function detectEngines(config) {
2529
+ const probes = probeEngines(config);
2530
+ for (const probe of probes) {
2531
+ if (probe.available) {
2532
+ src_default.info({ engine: probe.engine }, "micro-VM engine available");
2533
+ } else {
2534
+ src_default.debug({ engine: probe.engine, reason: probe.reason }, "micro-VM engine unavailable");
2535
+ }
2536
+ }
2537
+ const engines = probes.filter((p) => p.available).map((p) => p.engine);
2538
+ const defaultEngine = ENGINE_PRIORITY.find((engine) => engines.includes(engine)) ?? "mock";
2539
+ return { engines, defaultEngine };
2540
+ }
2541
+
2542
+ // src/executor/index.ts
2543
+ function findNotAttachedVms(executor, records) {
2544
+ return records.filter((record) => !executor.get(record.vmId));
2545
+ }
2546
+ function createEngineExecutor(engine, config) {
2547
+ switch (engine) {
2548
+ case "mock":
2549
+ return new MockExecutor();
2550
+ case "qemu":
2551
+ return new QemuExecutor(config.qemu);
2552
+ case "firecracker":
2553
+ return new FirecrackerExecutor(config.firecracker);
2554
+ case "docker":
2555
+ return new DockerExecutor(config.docker);
2556
+ case "docker-sandbox":
2557
+ return new DockerSandboxExecutor(config.dockerSandbox);
2558
+ case "remote":
2559
+ return new RemoteExecutor(config.remote);
2560
+ case "lambda-microvm":
2561
+ return new LambdaMicrovmExecutor(config.lambdaMicrovm);
2562
+ case "fargate":
2563
+ return new FargateExecutor(config.fargate);
2564
+ default:
2565
+ throw new Error(`unknown engine "${engine}"`);
2566
+ }
2567
+ }
2568
+ var MultiEngineExecutor = class {
2569
+ constructor(executors, defaultEngine) {
2570
+ this.executors = executors;
2571
+ this.defaultEngine = defaultEngine;
2572
+ }
2573
+ get engines() {
2574
+ return [...this.executors.keys()];
2575
+ }
2576
+ async create(spec) {
2577
+ const engine = spec.engine ?? this.defaultEngine;
2578
+ const executor = this.executors.get(engine);
2579
+ if (!executor) {
2580
+ throw new Error(`engine "${engine}" is not available on this worker (available: ${this.engines.join(", ")})`);
2581
+ }
2582
+ const handle = await executor.create(spec);
2583
+ return { ...handle, engine };
2584
+ }
2585
+ pause(vmId) {
2586
+ return this.route(vmId).pause(vmId);
2587
+ }
2588
+ resume(vmId) {
2589
+ return this.route(vmId).resume(vmId);
2590
+ }
2591
+ stop(vmId) {
2592
+ return this.route(vmId).stop(vmId);
2593
+ }
2594
+ restore(vmId) {
2595
+ return this.route(vmId).restore(vmId);
2596
+ }
2597
+ destroy(vmId) {
2598
+ return this.route(vmId).destroy(vmId);
2599
+ }
2600
+ proxyHttp(vmId, request) {
2601
+ return this.route(vmId).proxyHttp(vmId, request);
2602
+ }
2603
+ get(vmId) {
2604
+ for (const executor of this.executors.values()) {
2605
+ const handle = executor.get(vmId);
2606
+ if (handle) return handle;
2607
+ }
2608
+ return void 0;
2609
+ }
2610
+ list() {
2611
+ return [...this.executors.values()].flatMap((executor) => executor.list());
2612
+ }
2613
+ getSerialSource(vmId) {
2614
+ for (const executor of this.executors.values()) {
2615
+ if (executor.get(vmId)) return executor.getSerialSource?.(vmId);
2616
+ }
2617
+ return void 0;
2618
+ }
2619
+ exec(vmId, request) {
2620
+ const executor = this.route(vmId);
2621
+ if (!executor.exec) return Promise.reject(new Error("executor does not support guest exec"));
2622
+ return executor.exec(vmId, request);
2623
+ }
2624
+ writeFiles(vmId, files) {
2625
+ const executor = this.route(vmId);
2626
+ if (!executor.writeFiles) return Promise.reject(new Error("executor does not support guest file writes"));
2627
+ return executor.writeFiles(vmId, files);
2628
+ }
2629
+ /**
2630
+ * Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
2631
+ * restart, most likely). Runs in parallel — probing many VMs serially would needlessly
2632
+ * delay the first heartbeat, which reports capacity from `list()` and must reflect
2633
+ * reattached VMs to avoid sandbox overbooking this worker. Best-effort: failures are logged,
2634
+ * never thrown — a VM that can't be reattached just stays unavailable until something
2635
+ * touches it again (create/proxy/lifecycle), which already errors gracefully today.
2636
+ */
2637
+ async reattachAll(records) {
2638
+ const pending = findNotAttachedVms(this, records);
2639
+ if (!pending.length) return;
2640
+ const results = await Promise.allSettled(
2641
+ pending.map(async (record) => {
2642
+ const executor = record.engine ? this.executors.get(record.engine) : void 0;
2643
+ if (!executor) throw new Error(`engine "${record.engine ?? "unknown"}" not available on this worker`);
2644
+ if (!executor.reattach) throw new Error(`engine "${record.engine}" does not support reattach`);
2645
+ const handle = await executor.reattach(record);
2646
+ if (!handle) throw new Error("resource no longer exists");
2647
+ })
2648
+ );
2649
+ results.forEach((result, i) => {
2650
+ const vmId = pending[i].vmId;
2651
+ if (result.status === "fulfilled") src_default.info({ vmId }, "vm reattached");
2652
+ else src_default.warn({ vmId, err: result.reason }, "vm reattach failed \u2014 staying unavailable until next touch");
2653
+ });
2654
+ }
2655
+ route(vmId) {
2656
+ for (const executor of this.executors.values()) {
2657
+ if (executor.get(vmId)) return executor;
2658
+ }
2659
+ throw new Error(`vm ${vmId} not found on this worker`);
2660
+ }
2661
+ };
2662
+ function createExecutor(config) {
2663
+ const { engines, defaultEngine } = detectEngines(config);
2664
+ const executors = /* @__PURE__ */ new Map();
2665
+ for (const engine of engines) {
2666
+ executors.set(engine, createEngineExecutor(engine, config));
2667
+ }
2668
+ src_default.info({ engines, defaultEngine }, "micro-VM engines initialized");
2669
+ return new MultiEngineExecutor(executors, defaultEngine);
2670
+ }
2671
+
2672
+ // src/socket-client.ts
2673
+ var import_os9 = __toESM(require("os"));
2674
+ var import_socket = require("socket.io-client");
2675
+ var HEARTBEAT_INTERVAL_MS = 1e4;
2676
+ var REATTACH_FALLBACK_MS = 5e3;
2677
+ function primaryIp() {
2678
+ for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
2679
+ for (const iface of ifaces ?? []) {
2680
+ if (!iface.internal && iface.family === "IPv4") return iface.address;
2681
+ }
2682
+ }
2683
+ return void 0;
2684
+ }
2685
+ function buildHeartbeat(config, executor) {
2686
+ return {
2687
+ hostname: import_os9.default.hostname(),
2688
+ ip: primaryIp(),
2689
+ region: config.region,
2690
+ version,
2691
+ engines: executor.engines,
2692
+ defaultEngine: executor.defaultEngine,
2693
+ capacity: {
2694
+ maxVms: config.maxVms,
2695
+ usedVms: executor.list().length,
2696
+ cpuCores: import_os9.default.cpus().length,
2697
+ memMib: Math.round(import_os9.default.totalmem() / (1024 * 1024))
2698
+ }
2699
+ };
2700
+ }
2701
+ function wrap(handler) {
2702
+ return (payload, callback) => {
2703
+ handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
2704
+ src_default.error({ err: error }, "command failed");
2705
+ callback({ ok: false, error: error.message });
2706
+ });
2707
+ };
2708
+ }
2709
+ function registerCommandHandlers(socket, executor) {
2710
+ socket.on(WorkerEvent.VM_CREATE, wrap((spec) => executor.create(spec)));
2711
+ socket.on(WorkerEvent.VM_PAUSE, wrap(({ vmId }) => executor.pause(vmId)));
2712
+ socket.on(WorkerEvent.VM_RESUME, wrap(({ vmId }) => executor.resume(vmId)));
2713
+ socket.on(WorkerEvent.VM_STOP, wrap(({ vmId }) => executor.stop(vmId)));
2714
+ socket.on(WorkerEvent.VM_RESTORE, wrap(({ vmId }) => executor.restore(vmId)));
2715
+ socket.on(WorkerEvent.VM_DESTROY, wrap(({ vmId }) => executor.destroy(vmId)));
2716
+ socket.on(
2717
+ WorkerEvent.PROXY_HTTP,
2718
+ wrap(
2719
+ ({ vmId, ...request }) => executor.proxyHttp(vmId, request)
2720
+ )
2721
+ );
2722
+ socket.on(
2723
+ WorkerEvent.VM_EXEC,
2724
+ wrap(({ vmId, ...request }) => {
2725
+ if (!executor.exec) {
2726
+ return Promise.reject(new Error("executor does not support guest exec"));
2727
+ }
2728
+ return executor.exec(vmId, request);
2729
+ })
2730
+ );
2731
+ socket.on(
2732
+ WorkerEvent.VM_WRITE_FILES,
2733
+ wrap(({ vmId, files }) => {
2734
+ if (!executor.writeFiles) {
2735
+ return Promise.reject(new Error("executor does not support guest file writes"));
2736
+ }
2737
+ return executor.writeFiles(vmId, files);
2738
+ })
2739
+ );
2740
+ }
2741
+ function connectToSandbox(config, executor) {
2742
+ const socket = (0, import_socket.io)(config.sandboxUrl, {
2743
+ path: config.workerSocketPath,
2744
+ auth: { workerId: config.workerId, token: config.token },
2745
+ transports: ["websocket"],
2746
+ reconnection: true
2747
+ });
2748
+ let heartbeatTimer;
2749
+ let reattachFallbackTimer;
2750
+ const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
2751
+ function startHeartbeat() {
2752
+ if (heartbeatTimer) return;
2753
+ sendHeartbeat();
2754
+ heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
2755
+ }
2756
+ socket.on("connect", () => {
2757
+ src_default.info({ workerId: config.workerId, url: config.sandboxUrl }, "connected to sandbox");
2758
+ socket.off(WorkerEvent.REATTACH);
2759
+ reattachFallbackTimer = setTimeout(() => {
2760
+ src_default.warn({ workerId: config.workerId }, "no reattach handshake from sandbox \u2014 starting heartbeat anyway");
2761
+ startHeartbeat();
2762
+ }, REATTACH_FALLBACK_MS);
2763
+ socket.once(WorkerEvent.REATTACH, (records) => {
2764
+ clearTimeout(reattachFallbackTimer);
2765
+ executor.reattachAll(records).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(startHeartbeat);
2766
+ });
2767
+ });
2768
+ socket.on("disconnect", (reason) => {
2769
+ src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
2770
+ clearInterval(heartbeatTimer);
2771
+ clearTimeout(reattachFallbackTimer);
2772
+ heartbeatTimer = void 0;
2773
+ socket.off(WorkerEvent.REATTACH);
2774
+ });
2775
+ socket.on("connect_error", (error) => {
2776
+ src_default.error({ err: error }, "sandbox connection error");
2777
+ });
2778
+ registerCommandHandlers(socket, executor);
2779
+ return socket;
2780
+ }
2781
+
2782
+ // src/services/vnc-relay.ts
2783
+ var import_rfb2 = __toESM(require("rfb2"));
2784
+ function bgraToRgba(src) {
2785
+ const dst = Buffer.allocUnsafe(src.length);
2786
+ for (let i = 0; i < src.length; i += 4) {
2787
+ dst[i] = src[i + 2];
2788
+ dst[i + 1] = src[i + 1];
2789
+ dst[i + 2] = src[i];
2790
+ dst[i + 3] = 255;
2791
+ }
2792
+ return dst;
2793
+ }
2794
+ var VncRelay = class {
2795
+ constructor(vmId, host, port, socket) {
2796
+ this.vmId = vmId;
2797
+ this.host = host;
2798
+ this.port = port;
2799
+ this.socket = socket;
2800
+ this.rfbc = null;
2801
+ this.interval = null;
2802
+ this.mouseHandler = null;
2803
+ this.keyHandler = null;
2804
+ }
2805
+ start() {
2806
+ this.rfbc = import_rfb2.default.createConnection({ host: this.host, port: this.port, password: "" });
2807
+ const rfbc = this.rfbc;
2808
+ rfbc.on("connect", () => {
2809
+ rfbc.autoUpdate = true;
2810
+ this.socket.emit(WorkerEvent.VNC_INIT, { vmId: this.vmId, width: rfbc.width, height: rfbc.height });
2811
+ this.interval = setInterval(() => {
2812
+ rfbc.requestUpdate(false, 0, 0, rfbc.width, rfbc.height);
2813
+ }, 300);
2814
+ });
2815
+ rfbc.on("rect", (rect) => {
2816
+ switch (rect.encoding) {
2817
+ case import_rfb2.default.encodings.raw: {
2818
+ const frame = {
2819
+ vmId: this.vmId,
2820
+ x: rect.x,
2821
+ y: rect.y,
2822
+ width: rect.width,
2823
+ height: rect.height,
2824
+ encoding: "raw",
2825
+ data: bgraToRgba(rect.data)
2826
+ };
2827
+ this.socket.emit(WorkerEvent.VNC_FRAME, frame);
2828
+ break;
2829
+ }
2830
+ case import_rfb2.default.encodings.copyRect: {
2831
+ const frame = {
2832
+ vmId: this.vmId,
2833
+ x: rect.x,
2834
+ y: rect.y,
2835
+ src: rect.src,
2836
+ width: rect.width,
2837
+ height: rect.height
2838
+ };
2839
+ this.socket.emit(WorkerEvent.VNC_COPY_FRAME, frame);
2840
+ break;
2841
+ }
2842
+ }
2843
+ });
2844
+ rfbc.on("resize", (rect) => {
2845
+ src_default.info({ vmId: this.vmId, width: rect.width, height: rect.height }, "[VNC] desktop resized");
2846
+ this.socket.emit(WorkerEvent.VNC_INIT, { vmId: this.vmId, width: rect.width, height: rect.height });
2847
+ rfbc.requestUpdate(false, 0, 0, rect.width, rect.height);
2848
+ });
2849
+ rfbc.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[VNC] RFB error"));
2850
+ this.mouseHandler = (e) => {
2851
+ if (e.vmId === this.vmId) rfbc.pointerEvent(e.x, e.y, e.button);
2852
+ };
2853
+ this.keyHandler = (e) => {
2854
+ if (e.vmId === this.vmId) rfbc.keyEvent(e.keyCode, e.isDown);
2855
+ };
2856
+ this.socket.on(WorkerEvent.VNC_MOUSE, this.mouseHandler);
2857
+ this.socket.on(WorkerEvent.VNC_KEYBOARD, this.keyHandler);
2858
+ src_default.info({ vmId: this.vmId, port: this.port }, "[VNC] relay started");
2859
+ }
2860
+ destroy() {
2861
+ if (this.interval) clearInterval(this.interval);
2862
+ if (this.mouseHandler) this.socket.off(WorkerEvent.VNC_MOUSE, this.mouseHandler);
2863
+ if (this.keyHandler) this.socket.off(WorkerEvent.VNC_KEYBOARD, this.keyHandler);
2864
+ try {
2865
+ this.rfbc?.end();
2866
+ } catch {
2867
+ }
2868
+ this.rfbc = null;
2869
+ src_default.info({ vmId: this.vmId }, "[VNC] relay destroyed");
2870
+ }
2871
+ };
2872
+
2873
+ // src/services/serial-relay.ts
2874
+ var import_net3 = __toESM(require("net"));
2875
+ var SerialRelay = class {
2876
+ constructor(vmId, source, socket) {
2877
+ this.vmId = vmId;
2878
+ this.source = source;
2879
+ this.socket = socket;
2880
+ this.conn = null;
2881
+ this.inputHandler = null;
2882
+ }
2883
+ connect() {
2884
+ if (this.source.type === "tcp") {
2885
+ this.connectTcp();
2886
+ } else {
2887
+ this.connectPipe();
2888
+ }
2889
+ this.inputHandler = (e) => {
2890
+ if (e.vmId !== this.vmId) return;
2891
+ if (this.source.type === "tcp" && this.conn) {
2892
+ this.conn.write(e.data);
2893
+ } else if (this.source.type === "pipe") {
2894
+ const proc = this.source.process;
2895
+ proc.stdin?.write(e.data);
2896
+ }
2897
+ };
2898
+ this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
2899
+ }
2900
+ connectTcp() {
2901
+ const { host, port } = this.source;
2902
+ const attempt = () => {
2903
+ const sock = import_net3.default.createConnection(port, host);
2904
+ sock.once("connect", () => {
2905
+ this.conn = sock;
2906
+ src_default.info({ vmId: this.vmId, port }, "[Serial] TCP connected");
2907
+ sock.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
2908
+ sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
2909
+ sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
2910
+ });
2911
+ sock.once("error", () => setTimeout(attempt, 1e3));
2912
+ };
2913
+ attempt();
2914
+ }
2915
+ connectPipe() {
2916
+ const { process: proc } = this.source;
2917
+ proc.stdout?.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
2918
+ proc.stderr?.on("data", (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d }));
2919
+ }
2920
+ destroy() {
2921
+ if (this.inputHandler) this.socket.off(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
2922
+ try {
2923
+ this.conn?.destroy();
2924
+ } catch {
2925
+ }
2926
+ this.conn = null;
2927
+ src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
2928
+ }
2929
+ };
2930
+
2931
+ // src/console-relays.ts
2932
+ function attachConsoleRelays(executor, sandboxSocket) {
2933
+ const relays = /* @__PURE__ */ new Map();
2934
+ const originalCreate = executor.create.bind(executor);
2935
+ executor.create = async (spec) => {
2936
+ const handle = await originalCreate(spec);
2937
+ const entry = {};
2938
+ if (handle.vncPort) {
2939
+ const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
2940
+ vnc.start();
2941
+ entry.vnc = vnc;
2942
+ }
2943
+ const serialSource = handle.serialPort ? { type: "tcp", host: "127.0.0.1", port: handle.serialPort } : executor.getSerialSource?.(handle.vmId);
2944
+ if (serialSource) {
2945
+ const serial = new SerialRelay(handle.vmId, serialSource, sandboxSocket);
2946
+ serial.connect();
2947
+ entry.serial = serial;
2948
+ }
2949
+ if (entry.vnc || entry.serial) relays.set(handle.vmId, entry);
2950
+ return handle;
2951
+ };
2952
+ const originalDestroy = executor.destroy.bind(executor);
2953
+ executor.destroy = async (vmId) => {
2954
+ const entry = relays.get(vmId);
2955
+ entry?.vnc?.destroy();
2956
+ entry?.serial?.destroy();
2957
+ relays.delete(vmId);
2958
+ return originalDestroy(vmId);
2959
+ };
2960
+ }
2961
+
2962
+ // src/cli.ts
2963
+ var program = new import_commander.Command();
2964
+ program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
2965
+ 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>", "stable worker ID (env: WORKER_ID; default: hostname)").option("--sandbox-url <url>", "control-plane URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com)").option("--max-vms <count>", "maximum concurrent VMs (env: MAX_VMS)").action(async (opts) => {
2966
+ const config = loadWorkerConfigFromEnv({
2967
+ ...process.env,
2968
+ ...opts.token ? { WORKER_TOKEN: opts.token } : {},
2969
+ ...opts.workerId ? { WORKER_ID: opts.workerId } : {},
2970
+ ...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
2971
+ ...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
2972
+ });
2973
+ const executor = createExecutor(config);
2974
+ const socket = connectToSandbox(config, executor);
2975
+ attachConsoleRelays(executor, socket);
2976
+ let stopping = false;
2977
+ const stop = async (signal) => {
2978
+ if (stopping) return;
2979
+ stopping = true;
2980
+ console.log(`
2981
+ ${signal} received; shutting down sandbox worker...`);
2982
+ socket.disconnect();
2983
+ const results = await Promise.allSettled(executor.list().map((vm) => executor.destroy(vm.vmId)));
2984
+ const failed = results.filter((result) => result.status === "rejected");
2985
+ if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
2986
+ process.exit(failed.length ? 1 : 0);
2987
+ };
2988
+ process.once("SIGINT", () => {
2989
+ void stop("SIGINT");
2990
+ });
2991
+ process.once("SIGTERM", () => {
2992
+ void stop("SIGTERM");
2993
+ });
2994
+ console.log(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
2995
+ });
2996
+ program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
2997
+ const probes = probeEngines(loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" }));
2998
+ for (const probe of probes) {
2999
+ const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
3000
+ console.log(`${probe.engine.padEnd(15)} ${status}`);
3001
+ }
66
3002
  });
67
- const images = program
68
- .command('images')
69
- .description('manage engine base images (kernel + initramfs for QEMU/Firecracker)');
70
- images
71
- .command('pull')
72
- .description('download per-stack base images from S3 or an HTTPS mirror into the standard location')
73
- .requiredOption('--from <location>', 's3://bucket[/prefix] or https:// base URL (env: VM_IMAGES_URL)', process.env.VM_IMAGES_URL)
74
- .option('--arch <arch>', 'image architecture', (0, images_1.hostArch)())
75
- .option('--stack <stack...>', 'stacks to pull (default: all)')
76
- .option('--dest <dir>', 'destination directory (default: ~/.multiplayer/vm-images/<arch>)')
77
- .option('--endpoint <url>', 'custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)', process.env.VM_IMAGES_ENDPOINT)
78
- .action(async (opts) => {
79
- var _a;
80
- const dest = (_a = opts.dest) !== null && _a !== void 0 ? _a : (0, images_1.defaultImagesDir)(opts.arch);
81
- const files = await (0, images_1.pullImages)({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
82
- for (const file of files)
83
- console.log(`pulled ${file}`);
84
- console.log('\nbase images ready — "sandbox detect" shows which engines they enable');
3003
+ var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
3004
+ 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 (env: VM_IMAGES_URL)", process.env.VM_IMAGES_URL).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: VM_IMAGES_ENDPOINT)", process.env.VM_IMAGES_ENDPOINT).action(async (opts) => {
3005
+ const dest = opts.dest ?? defaultImagesDir(opts.arch);
3006
+ const files = await pullImages({ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, dest);
3007
+ for (const file of files) console.log(`pulled ${file}`);
3008
+ console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
85
3009
  });
86
- images
87
- .command('push')
88
- .description('upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3')
89
- .requiredOption('--to <location>', 's3://bucket[/prefix] (env: VM_IMAGES_URL)', process.env.VM_IMAGES_URL)
90
- .option('--arch <arch>', 'image architecture', (0, images_1.hostArch)())
91
- .option('--stack <stack...>', 'stacks to push (default: all found locally)')
92
- .option('--src <dir>', 'source directory (default: ~/.multiplayer/vm-images/<arch>)')
93
- .option('--endpoint <url>', 'custom S3 endpoint, e.g. a MinIO URL (env: VM_IMAGES_ENDPOINT)', process.env.VM_IMAGES_ENDPOINT)
94
- .action(async (opts) => {
95
- var _a;
96
- const src = (_a = opts.src) !== null && _a !== void 0 ? _a : (0, images_1.defaultImagesDir)(opts.arch);
97
- const uploaded = await (0, images_1.pushImages)({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
98
- for (const uri of uploaded)
99
- console.log(`pushed ${uri}`);
3010
+ images.command("push").description("upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3").requiredOption("--to <location>", "s3://bucket[/prefix] (env: VM_IMAGES_URL)", process.env.VM_IMAGES_URL).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: VM_IMAGES_ENDPOINT)", process.env.VM_IMAGES_ENDPOINT).action(async (opts) => {
3011
+ const src = opts.src ?? defaultImagesDir(opts.arch);
3012
+ const uploaded = await pushImages({ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint }, src);
3013
+ for (const uri of uploaded) console.log(`pushed ${uri}`);
100
3014
  });
101
3015
  program.parseAsync().catch((error) => {
102
- console.error(`Error: ${error.message}`);
103
- process.exit(1);
3016
+ console.error(`Error: ${error.message}`);
3017
+ process.exit(1);
104
3018
  });
105
- //# sourceMappingURL=cli.js.map
3019
+ //# sourceMappingURL=cli.js.map