@douglas-agent/sandbank-boxlite 0.6.1

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.
@@ -0,0 +1,863 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { tmpdir } from 'node:os';
4
+ import { writeFileSync, unlinkSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ // ─── Python bridge script (embedded) ──────────────────────────────────────────
7
+ const BRIDGE_SCRIPT = `#!/usr/bin/env python3
8
+ """boxlite_bridge.py — JSON-line bridge between TypeScript and boxlite Python SDK.
9
+
10
+ Protocol:
11
+ → stdin: one JSON object per line {id, action, ...params}
12
+ ← stdout: one JSON object per line {id, result} | {id, error}
13
+ First output line: {ready: true, version: "..."}
14
+ """
15
+ import asyncio, json, sys, os, traceback
16
+ from datetime import datetime, timezone
17
+
18
+ try:
19
+ import boxlite
20
+ except ImportError:
21
+ sys.stdout.write(json.dumps({
22
+ "ready": False,
23
+ "error": "boxlite Python package not found. Install with: pip install boxlite"
24
+ }) + "\\n")
25
+ sys.stdout.flush()
26
+ sys.exit(1)
27
+
28
+
29
+ class Bridge:
30
+ def __init__(self, home=None):
31
+ self._home = home or os.environ.get("BOXLITE_HOME", os.path.expanduser("~/.boxlite"))
32
+ self._runtime = None
33
+ self._boxes = {} # box_id -> box object
34
+ self._simple_boxes = {} # box_id -> SimpleBox (for cleanup)
35
+
36
+ async def _ensure_runtime(self):
37
+ if self._runtime is not None:
38
+ return
39
+
40
+ # Create a Boxlite runtime for the custom home_dir, then use SimpleBox
41
+ # with that runtime. SimpleBox's exec is more stable for rapid consecutive
42
+ # calls than Boxlite.create(BoxOptions).exec().
43
+ Options = getattr(boxlite, "Options", None)
44
+ Boxlite = getattr(boxlite, "Boxlite", None)
45
+ if Options and Boxlite:
46
+ try:
47
+ opts = Options(home_dir=self._home)
48
+ self._boxlite_rt = Boxlite(opts)
49
+ except Exception:
50
+ self._boxlite_rt = None
51
+ else:
52
+ self._boxlite_rt = None
53
+
54
+ # Use SimpleBox with the runtime (handles home_dir correctly)
55
+ self._runtime = "simple_box"
56
+
57
+ async def create(self, params):
58
+ await self._ensure_runtime()
59
+
60
+ image = params.get("image", "")
61
+ rootfs = params.get("rootfs_path")
62
+ now = datetime.now(timezone.utc).isoformat()
63
+
64
+ # Normalize env/ports
65
+ env = params.get("env")
66
+ if isinstance(env, dict):
67
+ env = list(env.items())
68
+ ports = params.get("ports")
69
+ if isinstance(ports, list):
70
+ ports = [tuple(p) for p in ports]
71
+
72
+ if self._runtime == "simple_box":
73
+ # SimpleBox uses: image, memory_mib, cpus, disk_size_gb, env, working_dir
74
+ # rootfs_path overrides image when provided (local OCI layout directory)
75
+ sb_kwargs = {"rootfs_path": rootfs} if rootfs else {"image": image}
76
+ if params.get("cpu") is not None:
77
+ sb_kwargs["cpus"] = params["cpu"]
78
+ if params.get("memory_mb") is not None:
79
+ sb_kwargs["memory_mib"] = params["memory_mb"]
80
+ if params.get("disk_size_gb") is not None:
81
+ sb_kwargs["disk_size_gb"] = params["disk_size_gb"]
82
+ if env is not None:
83
+ sb_kwargs["env"] = env
84
+ if params.get("working_dir") is not None:
85
+ sb_kwargs["working_dir"] = params["working_dir"]
86
+ if ports is not None:
87
+ sb_kwargs["ports"] = ports
88
+
89
+ # Disable auto_remove so runtime.get() works after stop (needed for snapshot restore)
90
+ sb_kwargs["auto_remove"] = params.get("auto_remove", False)
91
+
92
+ # Pass the Boxlite runtime to SimpleBox so it uses the correct home_dir
93
+ if getattr(self, "_boxlite_rt", None) is not None:
94
+ sb_kwargs["runtime"] = self._boxlite_rt
95
+ sb = boxlite.SimpleBox(**sb_kwargs)
96
+ box = await sb.__aenter__()
97
+ box_id = str(getattr(box, "id", None)
98
+ or getattr(getattr(box, "_box", None), "id", None)
99
+ or id(box))
100
+ self._boxes[box_id] = box
101
+ self._simple_boxes[box_id] = sb
102
+ else:
103
+ # Boxlite.create(BoxOptions(...), name=None)
104
+ BoxOptions = getattr(boxlite, "BoxOptions", None)
105
+ if BoxOptions is not None:
106
+ opt_kwargs = {}
107
+ if rootfs:
108
+ opt_kwargs["rootfs_path"] = rootfs
109
+ else:
110
+ opt_kwargs["image"] = image
111
+ if params.get("cpu") is not None:
112
+ opt_kwargs["cpus"] = params["cpu"]
113
+ if params.get("memory_mb") is not None:
114
+ opt_kwargs["memory_mib"] = params["memory_mb"]
115
+ if params.get("disk_size_gb") is not None:
116
+ opt_kwargs["disk_size_gb"] = params["disk_size_gb"]
117
+ if env is not None:
118
+ opt_kwargs["env"] = env
119
+ if params.get("working_dir") is not None:
120
+ opt_kwargs["working_dir"] = params["working_dir"]
121
+ if ports is not None:
122
+ opt_kwargs["ports"] = ports
123
+ opts = BoxOptions(**opt_kwargs)
124
+ box = await self._runtime.create(opts)
125
+ else:
126
+ # Legacy fallback: pass as kwargs
127
+ kwargs = {"rootfs_path": rootfs} if rootfs else {"image": image}
128
+ for k in ("cpu", "memory_mb", "disk_size_gb", "working_dir"):
129
+ if params.get(k) is not None:
130
+ kwargs[k] = params[k]
131
+ if env is not None:
132
+ kwargs["env"] = env
133
+ if ports is not None:
134
+ kwargs["ports"] = ports
135
+ create_fn = getattr(self._runtime, "create", None) or getattr(self._runtime, "create_box", None)
136
+ if create_fn is None:
137
+ raise RuntimeError("boxlite runtime has no create/create_box method")
138
+ box = await create_fn(**kwargs)
139
+
140
+ box_id = str(box.id)
141
+ self._boxes[box_id] = box
142
+
143
+ return {
144
+ "id": box_id,
145
+ "status": "running",
146
+ "image": image,
147
+ "cpu": params.get("cpu", 1),
148
+ "memory_mb": params.get("memory_mb", 512),
149
+ "created_at": now,
150
+ "name": None,
151
+ }
152
+
153
+ async def get(self, box_id):
154
+ box = await self._get_box_async(box_id)
155
+ return {
156
+ "id": box_id,
157
+ "status": self._box_status(box),
158
+ "image": getattr(box, "image", "unknown"),
159
+ "cpu": getattr(box, "cpu", 1),
160
+ "memory_mb": getattr(box, "memory_mb", 512),
161
+ "created_at": str(getattr(box, "created_at", "")),
162
+ "name": getattr(box, "name", None),
163
+ }
164
+
165
+ async def list_boxes(self):
166
+ results = []
167
+ for box_id, box in self._boxes.items():
168
+ results.append({
169
+ "id": box_id,
170
+ "status": self._box_status(box),
171
+ "image": getattr(box, "image", "unknown"),
172
+ "cpu": getattr(box, "cpu", 1),
173
+ "memory_mb": getattr(box, "memory_mb", 512),
174
+ "created_at": str(getattr(box, "created_at", "")),
175
+ "name": getattr(box, "name", None),
176
+ })
177
+ return results
178
+
179
+ async def exec_cmd(self, box_id, cmd, **kwargs):
180
+ box = await self._get_box_async(box_id)
181
+
182
+ result = None
183
+ errors = []
184
+
185
+ # Strategy 1: box.exec(*cmd)
186
+ try:
187
+ result = await box.exec(*cmd)
188
+ except Exception as e:
189
+ errors.append(f"box.exec(*cmd): {e}")
190
+
191
+ # Strategy 2: box.exec(cmd[0], args=cmd[1:])
192
+ if result is None:
193
+ try:
194
+ result = await box.exec(cmd[0], args=cmd[1:])
195
+ except Exception as e:
196
+ errors.append(f"box.exec(cmd[0], args=...): {e}")
197
+
198
+ # Strategy 3: box._box.exec(cmd[0], args=cmd[1:])
199
+ if result is None and hasattr(box, "_box"):
200
+ try:
201
+ result = await box._box.exec(cmd[0], args=cmd[1:])
202
+ except Exception as e:
203
+ errors.append(f"box._box.exec(...): {e}")
204
+
205
+ if result is None:
206
+ raise RuntimeError(f"All exec strategies failed: {'; '.join(errors)}")
207
+
208
+ stdout = ""
209
+ stderr = ""
210
+ exit_code = 0
211
+
212
+ # Execution object (Boxlite runtime API): collect stdout/stderr streams, then wait
213
+ if hasattr(result, "wait") and callable(result.wait):
214
+ async def _collect(stream_fn):
215
+ try:
216
+ buf = bytearray()
217
+ async for chunk in stream_fn():
218
+ if isinstance(chunk, (bytes, bytearray)):
219
+ buf.extend(chunk)
220
+ else:
221
+ buf.extend(str(chunk).encode("utf-8"))
222
+ return buf.decode("utf-8", errors="replace")
223
+ except Exception:
224
+ return ""
225
+
226
+ # Read stdout/stderr concurrently to avoid pipe deadlocks
227
+ tasks = []
228
+ has_stdout = hasattr(result, "stdout") and callable(result.stdout)
229
+ has_stderr = hasattr(result, "stderr") and callable(result.stderr)
230
+ if has_stdout:
231
+ tasks.append(asyncio.create_task(_collect(result.stdout)))
232
+ if has_stderr:
233
+ tasks.append(asyncio.create_task(_collect(result.stderr)))
234
+
235
+ if tasks:
236
+ collected = await asyncio.gather(*tasks)
237
+ idx = 0
238
+ if has_stdout:
239
+ stdout = collected[idx]; idx += 1
240
+ if has_stderr:
241
+ stderr = collected[idx]
242
+
243
+ exec_result = await result.wait()
244
+ exit_code = int(getattr(exec_result, "exit_code",
245
+ getattr(exec_result, "returncode", 0)) or 0)
246
+ else:
247
+ # SimpleBox / legacy: result already has stdout/stderr as attributes
248
+ raw_stdout = getattr(result, "stdout", "")
249
+ raw_stderr = getattr(result, "stderr", "")
250
+ if callable(raw_stdout):
251
+ raw_stdout = raw_stdout()
252
+ if callable(raw_stderr):
253
+ raw_stderr = raw_stderr()
254
+ stdout = str(raw_stdout or "")
255
+ stderr = str(raw_stderr or "")
256
+ exit_code = int(getattr(result, "exit_code",
257
+ getattr(result, "returncode", 0)) or 0)
258
+
259
+ return {
260
+ "stdout": stdout,
261
+ "stderr": stderr,
262
+ "exit_code": exit_code,
263
+ }
264
+
265
+ async def destroy(self, box_id, force=False):
266
+ box = self._boxes.pop(box_id, None)
267
+ sb = self._simple_boxes.pop(box_id, None)
268
+
269
+ await self._ensure_runtime()
270
+ rt = getattr(self, "_boxlite_rt", None)
271
+ if rt is not None:
272
+ for method_name in ("remove", "destroy", "delete"):
273
+ fn = getattr(rt, method_name, None)
274
+ if fn is None:
275
+ continue
276
+ try:
277
+ try:
278
+ await fn(box_id, force=force)
279
+ except TypeError:
280
+ await fn(box_id)
281
+ return
282
+ except Exception:
283
+ if box is None and sb is None:
284
+ raise
285
+ break
286
+
287
+ if sb is not None:
288
+ try:
289
+ await sb.__aexit__(None, None, None)
290
+ except Exception:
291
+ pass
292
+ return
293
+
294
+ if box is not None and self._runtime != "simple_box":
295
+ for method_name in ("destroy", "delete", "remove"):
296
+ fn = getattr(self._runtime, method_name, None)
297
+ if fn is not None:
298
+ try:
299
+ await fn(box_id)
300
+ return
301
+ except Exception:
302
+ continue
303
+ if hasattr(box, "destroy"):
304
+ await box.destroy()
305
+ elif hasattr(box, "stop"):
306
+ await box.stop()
307
+
308
+ async def _refresh_box_handle(self, box_id, started=None):
309
+ await self._ensure_runtime()
310
+ rt = getattr(self, "_boxlite_rt", None)
311
+ if rt is None:
312
+ box = self._boxes.get(box_id)
313
+ if box is None:
314
+ raise ValueError(f"Box not found: {box_id}")
315
+ return box
316
+
317
+ try:
318
+ fresh = await rt.get(box_id)
319
+ except Exception as exc:
320
+ raise ValueError(f"Box not found: {box_id}") from exc
321
+ if fresh is None:
322
+ raise ValueError(f"Box not found: {box_id}")
323
+
324
+ old_sb = self._simple_boxes.get(box_id)
325
+ old_box = self._boxes.get(box_id)
326
+ if old_sb and hasattr(old_sb, "_box"):
327
+ old_sb._box = fresh
328
+ if started is not None:
329
+ old_sb._started = started
330
+ if old_box and hasattr(old_box, "_box"):
331
+ old_box._box = fresh
332
+ else:
333
+ self._boxes[box_id] = fresh
334
+ return fresh
335
+
336
+ async def stop(self, box_id):
337
+ box = await self._get_box_async(box_id)
338
+ if hasattr(box, "stop"):
339
+ await box.stop()
340
+ await self._refresh_box_handle(box_id, False)
341
+
342
+ async def start(self, box_id):
343
+ fresh = await self._refresh_box_handle(box_id, False)
344
+ if fresh and hasattr(fresh, "start"):
345
+ await fresh.start()
346
+ elif fresh and hasattr(fresh, "__aenter__"):
347
+ await fresh.__aenter__()
348
+ else:
349
+ raise RuntimeError(f"Box has no start method: {box_id}")
350
+
351
+ old_sb = self._simple_boxes.get(box_id)
352
+ if old_sb and hasattr(old_sb, "_started"):
353
+ old_sb._started = True
354
+
355
+ def _normalize_status(self, value):
356
+ status_attr = getattr(value, "status", None)
357
+ if status_attr is not None and status_attr is not value:
358
+ return self._normalize_status(status_attr)
359
+ if isinstance(value, dict) and value.get("status") is not None:
360
+ return self._normalize_status(value.get("status"))
361
+
362
+ text = str(value or "").lower()
363
+ for state in ("configured", "stopping", "stopped", "paused", "running"):
364
+ if state in text:
365
+ return state
366
+ return text or "unknown"
367
+
368
+ def _box_status(self, box):
369
+ status = getattr(box, "status", None)
370
+ if status is not None:
371
+ return self._normalize_status(status)
372
+
373
+ inner = getattr(box, "_box", box)
374
+ info_fn = getattr(inner, "info", None)
375
+ if callable(info_fn):
376
+ try:
377
+ info = info_fn()
378
+ state = getattr(info, "state", None)
379
+ if state is not None:
380
+ return self._normalize_status(state)
381
+ except Exception:
382
+ pass
383
+
384
+ return "unknown"
385
+
386
+ async def _get_box_async(self, box_id):
387
+ box = self._boxes.get(box_id)
388
+ if box is not None:
389
+ return box
390
+ return await self._refresh_box_handle(box_id)
391
+
392
+ def _get_box(self, box_id):
393
+ box = self._boxes.get(box_id)
394
+ if box is None:
395
+ raise ValueError(f"Box not found: {box_id}")
396
+ # SimpleBox wraps the real Box — unwrap to get snapshot handle
397
+ inner = getattr(box, "_box", box)
398
+ return inner
399
+
400
+ async def _get_fresh_box(self, box_id):
401
+ try:
402
+ fresh = await self._refresh_box_handle(box_id)
403
+ if fresh is not None:
404
+ return getattr(fresh, "_box", fresh)
405
+ except Exception:
406
+ if self._boxes.get(box_id) is None:
407
+ raise
408
+
409
+ return self._get_box(box_id)
410
+
411
+ async def create_snapshot(self, box_id, name):
412
+ # boxlite snapshot uses fork_qcow2 (rename + COW child). If QEMU is
413
+ # running, its FD still points to the renamed inode, so post-snapshot
414
+ # writes corrupt the snapshot file. Must stop → snapshot → restart.
415
+ inner = await self._get_fresh_box(box_id)
416
+ await inner.stop()
417
+
418
+ rt = getattr(self, "_boxlite_rt", None)
419
+ if rt is None:
420
+ raise RuntimeError("Cannot create snapshot: no Boxlite runtime")
421
+ fresh = await rt.get(box_id)
422
+ if fresh is None:
423
+ raise RuntimeError(f"Cannot get fresh handle for box {box_id}")
424
+ snap_handle = getattr(fresh, "snapshot", None)
425
+ if snap_handle is None:
426
+ raise RuntimeError("Fresh handle has no snapshot support")
427
+
428
+ info = await snap_handle.create(name=name)
429
+ snap_name = str(getattr(info, "name", name))
430
+
431
+ # Restart the VM with the new COW child disk
432
+ await fresh.__aenter__()
433
+ # Update SimpleBox/Box internal references
434
+ old_sb = self._simple_boxes.get(box_id)
435
+ old_box = self._boxes.get(box_id)
436
+ if old_sb and hasattr(old_sb, "_box"):
437
+ old_sb._box = fresh
438
+ old_sb._started = True
439
+ if old_box and hasattr(old_box, "_box"):
440
+ old_box._box = fresh
441
+ else:
442
+ self._boxes[box_id] = fresh
443
+
444
+ return {
445
+ "id": str(getattr(info, "id", snap_name)),
446
+ "box_id": box_id,
447
+ "name": snap_name,
448
+ "created_at": int(getattr(info, "created_at", 0)),
449
+ "size_bytes": int(getattr(info, "size_bytes", 0)),
450
+ "guest_disk_bytes": int(getattr(info, "guest_disk_bytes", 0) or 0),
451
+ "container_disk_bytes": int(getattr(info, "container_disk_bytes", 0) or 0),
452
+ }
453
+
454
+ async def restore_snapshot(self, box_id, name):
455
+ # Same stop → fresh handle pattern as create_snapshot.
456
+ # stop() also invalidates the LiteBox handle (cancels shutdown_token).
457
+ inner = await self._get_fresh_box(box_id)
458
+ await inner.stop()
459
+
460
+ rt = getattr(self, "_boxlite_rt", None)
461
+ if rt is None:
462
+ raise RuntimeError("Cannot restore snapshot: no Boxlite runtime")
463
+ fresh = await rt.get(box_id)
464
+ if fresh is None:
465
+ raise RuntimeError(f"Cannot get fresh handle for box {box_id}")
466
+ fresh_snap = getattr(fresh, "snapshot", None)
467
+ if fresh_snap is None:
468
+ raise RuntimeError("Fresh handle has no snapshot support")
469
+
470
+ await fresh_snap.restore(name)
471
+
472
+ # Restart with the restored disk
473
+ await fresh.__aenter__()
474
+ # Update SimpleBox/Box internal references
475
+ old_sb = self._simple_boxes.get(box_id)
476
+ old_box = self._boxes.get(box_id)
477
+ if old_sb and hasattr(old_sb, "_box"):
478
+ old_sb._box = fresh
479
+ old_sb._started = True
480
+ if old_box and hasattr(old_box, "_box"):
481
+ old_box._box = fresh
482
+ else:
483
+ self._boxes[box_id] = fresh
484
+
485
+ async def list_snapshots(self, box_id):
486
+ inner = await self._get_fresh_box(box_id)
487
+ snap_handle = getattr(inner, "snapshot", None)
488
+ if snap_handle is None:
489
+ raise RuntimeError("Box does not support snapshots")
490
+ snapshots = await snap_handle.list()
491
+ return [{
492
+ "id": str(getattr(s, "id", "")),
493
+ "box_id": box_id,
494
+ "name": str(getattr(s, "name", "")),
495
+ "created_at": int(getattr(s, "created_at", 0)),
496
+ "size_bytes": int(getattr(s, "size_bytes", 0)),
497
+ "guest_disk_bytes": int(getattr(s, "guest_disk_bytes", 0) or 0),
498
+ "container_disk_bytes": int(getattr(s, "container_disk_bytes", 0) or 0),
499
+ } for s in snapshots]
500
+
501
+ async def delete_snapshot(self, box_id, name):
502
+ inner = await self._get_fresh_box(box_id)
503
+ snap_handle = getattr(inner, "snapshot", None)
504
+ if snap_handle is None:
505
+ raise RuntimeError("Box does not support snapshots")
506
+ await snap_handle.remove(name)
507
+
508
+ async def clone_box(self, box_id, name=None):
509
+ inner = await self._get_fresh_box(box_id)
510
+ from boxlite import CloneOptions
511
+ cloned = await inner.clone_box(options=CloneOptions(), name=name)
512
+ cloned_id = cloned.id
513
+ self._boxes[cloned_id] = cloned
514
+ info = cloned.info()
515
+ return {
516
+ "id": cloned_id,
517
+ "name": info.name if hasattr(info, "name") else name,
518
+ "status": str(info.state) if hasattr(info, "state") else "stopped",
519
+ "image": str(info.image) if hasattr(info, "image") else "",
520
+ "cpu": int(info.cpus) if hasattr(info, "cpus") else 0,
521
+ "memory_mb": int(info.memory_mib) if hasattr(info, "memory_mib") else 0,
522
+ "created_at": "",
523
+ }
524
+
525
+ async def cleanup(self, graceful=True):
526
+ if graceful:
527
+ # Process/bridge shutdown must not destroy tenant boxes. Running and
528
+ # stopped boxes can be reacquired from disk by _get_box_async after
529
+ # the next bridge starts.
530
+ self._boxes.clear()
531
+ self._simple_boxes.clear()
532
+ return
533
+
534
+ for box_id in list(self._boxes.keys()):
535
+ try:
536
+ await self.destroy(box_id)
537
+ except Exception:
538
+ pass
539
+
540
+
541
+ def write_json(obj):
542
+ sys.stdout.write(json.dumps(obj) + "\\n")
543
+ sys.stdout.flush()
544
+
545
+
546
+ async def main():
547
+ home = os.environ.get("BOXLITE_BRIDGE_HOME")
548
+ bridge = Bridge(home=home)
549
+ loop = asyncio.get_running_loop()
550
+
551
+ write_json({"ready": True, "version": getattr(boxlite, "__version__", "unknown")})
552
+
553
+ while True:
554
+ line = await loop.run_in_executor(None, sys.stdin.readline)
555
+ if not line:
556
+ break
557
+ line = line.strip()
558
+ if not line:
559
+ continue
560
+
561
+ req_id = 0
562
+ try:
563
+ cmd = json.loads(line)
564
+ req_id = cmd.get("id", 0)
565
+ action = cmd.get("action", "")
566
+
567
+ if action == "create":
568
+ result = await bridge.create(cmd)
569
+ elif action == "get":
570
+ result = await bridge.get(cmd["box_id"])
571
+ elif action == "list":
572
+ result = await bridge.list_boxes()
573
+ elif action == "exec":
574
+ result = await bridge.exec_cmd(cmd["box_id"], cmd["cmd"])
575
+ elif action == "destroy":
576
+ await bridge.destroy(cmd["box_id"], cmd.get("force", False))
577
+ result = {}
578
+ elif action == "start":
579
+ await bridge.start(cmd["box_id"])
580
+ result = {}
581
+ elif action == "stop":
582
+ await bridge.stop(cmd["box_id"])
583
+ result = {}
584
+ elif action == "create_snapshot":
585
+ result = await bridge.create_snapshot(cmd["box_id"], cmd["name"])
586
+ elif action == "restore_snapshot":
587
+ await bridge.restore_snapshot(cmd["box_id"], cmd["name"])
588
+ result = {}
589
+ elif action == "list_snapshots":
590
+ result = await bridge.list_snapshots(cmd["box_id"])
591
+ elif action == "delete_snapshot":
592
+ await bridge.delete_snapshot(cmd["box_id"], cmd["name"])
593
+ result = {}
594
+ elif action == "clone":
595
+ result = await bridge.clone_box(cmd["box_id"], cmd.get("name"))
596
+ elif action == "ping":
597
+ result = {"pong": True}
598
+ else:
599
+ raise ValueError(f"Unknown action: {action}")
600
+
601
+ write_json({"id": req_id, "result": result})
602
+ except Exception as e:
603
+ write_json({"id": req_id, "error": f"{type(e).__name__}: {e}"})
604
+
605
+ await bridge.cleanup()
606
+
607
+
608
+ if __name__ == "__main__":
609
+ asyncio.run(main())
610
+ `;
611
+ /**
612
+ * Create a BoxLite local client that communicates with the boxlite Python SDK
613
+ * via a JSON-line subprocess bridge.
614
+ */
615
+ export function createBoxLiteLocalClient(config) {
616
+ const pythonPath = config.pythonPath ?? 'python3';
617
+ const boxliteHome = config.boxliteHome;
618
+ let process = null;
619
+ let readline = null;
620
+ let requestId = 0;
621
+ let readyPromise = null;
622
+ const pending = new Map();
623
+ // Write bridge script to a temp file
624
+ let bridgeScriptPath = null;
625
+ function getBridgeScriptPath() {
626
+ if (bridgeScriptPath)
627
+ return bridgeScriptPath;
628
+ bridgeScriptPath = join(tmpdir(), `boxlite-bridge-${process?.pid ?? Date.now()}.py`);
629
+ writeFileSync(bridgeScriptPath, BRIDGE_SCRIPT, 'utf-8');
630
+ return bridgeScriptPath;
631
+ }
632
+ function ensureBridge() {
633
+ if (readyPromise)
634
+ return readyPromise;
635
+ readyPromise = new Promise((resolveReady, rejectReady) => {
636
+ const scriptPath = getBridgeScriptPath();
637
+ const env = { ...globalThis.process.env };
638
+ if (boxliteHome) {
639
+ env['BOXLITE_BRIDGE_HOME'] = boxliteHome;
640
+ }
641
+ process = spawn(pythonPath, [scriptPath], {
642
+ stdio: ['pipe', 'pipe', 'pipe'],
643
+ env,
644
+ });
645
+ // Collect stderr for error reporting
646
+ let stderrBuf = '';
647
+ process.stderr?.on('data', (chunk) => {
648
+ stderrBuf += chunk.toString();
649
+ });
650
+ process.on('error', (err) => {
651
+ rejectReady(new Error(`Failed to start boxlite bridge: ${err.message}`));
652
+ cleanup();
653
+ });
654
+ process.on('exit', (code) => {
655
+ if (code !== 0 && code !== null) {
656
+ const msg = stderrBuf || `Bridge exited with code ${code}`;
657
+ rejectReady(new Error(`BoxLite bridge error: ${msg}`));
658
+ // Reject all pending requests
659
+ for (const [id, req] of pending) {
660
+ req.reject(new Error(`BoxLite bridge exited unexpectedly: ${msg}`));
661
+ clearTimeout(req.timer);
662
+ pending.delete(id);
663
+ }
664
+ }
665
+ cleanup();
666
+ });
667
+ readline = createInterface({ input: process.stdout });
668
+ let gotReady = false;
669
+ readline.on('line', (line) => {
670
+ let msg;
671
+ try {
672
+ msg = JSON.parse(line);
673
+ }
674
+ catch {
675
+ return; // Ignore non-JSON output
676
+ }
677
+ // Handle ready signal
678
+ if (!gotReady && 'ready' in msg) {
679
+ gotReady = true;
680
+ if (msg.ready) {
681
+ resolveReady();
682
+ }
683
+ else {
684
+ rejectReady(new Error(`BoxLite bridge init failed: ${msg.error ?? 'unknown error'}`));
685
+ }
686
+ return;
687
+ }
688
+ // Handle response to a request
689
+ const id = msg.id;
690
+ if (id === undefined)
691
+ return;
692
+ const req = pending.get(id);
693
+ if (!req)
694
+ return;
695
+ pending.delete(id);
696
+ clearTimeout(req.timer);
697
+ if (msg.error) {
698
+ req.reject(new Error(`BoxLite local: ${msg.error}`));
699
+ }
700
+ else {
701
+ req.resolve(msg.result);
702
+ }
703
+ });
704
+ });
705
+ return readyPromise;
706
+ }
707
+ function cleanup() {
708
+ if (bridgeScriptPath) {
709
+ try {
710
+ unlinkSync(bridgeScriptPath);
711
+ }
712
+ catch { /* ignore */ }
713
+ bridgeScriptPath = null;
714
+ }
715
+ readline?.close();
716
+ readline = null;
717
+ process = null;
718
+ readyPromise = null;
719
+ }
720
+ async function send(command, timeoutMs = 300_000) {
721
+ await ensureBridge();
722
+ if (!process?.stdin?.writable) {
723
+ throw new Error('BoxLite bridge is not running');
724
+ }
725
+ const id = ++requestId;
726
+ return new Promise((resolve, reject) => {
727
+ const timer = setTimeout(() => {
728
+ pending.delete(id);
729
+ reject(new Error(`BoxLite bridge request timed out after ${timeoutMs}ms`));
730
+ }, timeoutMs);
731
+ pending.set(id, {
732
+ resolve: resolve,
733
+ reject,
734
+ timer,
735
+ });
736
+ process.stdin.write(JSON.stringify({ id, ...command }) + '\n');
737
+ });
738
+ }
739
+ // ─── BoxLiteClient implementation ───
740
+ return {
741
+ async createBox(params) {
742
+ return send({ action: 'create', ...params });
743
+ },
744
+ async getBox(boxId) {
745
+ return send({ action: 'get', box_id: boxId });
746
+ },
747
+ async listBoxes() {
748
+ return send({ action: 'list' });
749
+ },
750
+ async deleteBox(boxId, force) {
751
+ await send({ action: 'destroy', box_id: boxId, force: force ?? false });
752
+ },
753
+ async startBox(boxId) {
754
+ await send({ action: 'start', box_id: boxId });
755
+ },
756
+ async stopBox(boxId) {
757
+ await send({ action: 'stop', box_id: boxId });
758
+ },
759
+ async exec(boxId, req) {
760
+ const timeoutMs = (req.timeout_seconds ?? 300) * 1000;
761
+ const result = await send({
762
+ action: 'exec',
763
+ box_id: boxId,
764
+ cmd: req.cmd,
765
+ ...(req.working_dir ? { working_dir: req.working_dir } : {}),
766
+ ...(req.timeout_seconds ? { timeout_seconds: req.timeout_seconds } : {}),
767
+ }, timeoutMs);
768
+ return {
769
+ stdout: result.stdout ?? '',
770
+ stderr: result.stderr ?? '',
771
+ exitCode: result.exit_code ?? 0,
772
+ };
773
+ },
774
+ async execStream(boxId, req) {
775
+ const result = await this.exec(boxId, req);
776
+ const encoder = new TextEncoder();
777
+ return new ReadableStream({
778
+ start(controller) {
779
+ if (result.stdout)
780
+ controller.enqueue(encoder.encode(result.stdout));
781
+ if (result.stderr)
782
+ controller.enqueue(encoder.encode(result.stderr));
783
+ controller.close();
784
+ },
785
+ });
786
+ },
787
+ async uploadFiles(boxId, path, tarData) {
788
+ // Pipe tar data through exec: base64 decode → tar extract
789
+ const b64 = Buffer.from(tarData).toString('base64');
790
+ // Split into chunks to avoid shell argument limit
791
+ const chunkSize = 50_000;
792
+ const chunks = [];
793
+ for (let i = 0; i < b64.length; i += chunkSize) {
794
+ chunks.push(b64.slice(i, i + chunkSize));
795
+ }
796
+ if (chunks.length === 1) {
797
+ await this.exec(boxId, {
798
+ cmd: ['bash', '-c', `echo '${chunks[0]}' | base64 -d | tar xf - -C '${path}'`],
799
+ });
800
+ }
801
+ else {
802
+ // Write base64 to a temp file in chunks, then decode
803
+ const tmpFile = `/tmp/.boxlite-upload-${Date.now()}`;
804
+ for (const chunk of chunks) {
805
+ await this.exec(boxId, {
806
+ cmd: ['bash', '-c', `printf '%s' '${chunk}' >> ${tmpFile}`],
807
+ });
808
+ }
809
+ await this.exec(boxId, {
810
+ cmd: ['bash', '-c', `base64 -d ${tmpFile} | tar xf - -C '${path}' && rm -f ${tmpFile}`],
811
+ });
812
+ }
813
+ },
814
+ async downloadFiles(boxId, path) {
815
+ const result = await this.exec(boxId, {
816
+ cmd: ['bash', '-c', `tar cf - -C '${path}' . 2>/dev/null | base64`],
817
+ });
818
+ const data = Buffer.from(result.stdout.trim(), 'base64');
819
+ return new ReadableStream({
820
+ start(controller) {
821
+ controller.enqueue(new Uint8Array(data));
822
+ controller.close();
823
+ },
824
+ });
825
+ },
826
+ async createSnapshot(boxId, name) {
827
+ return send({ action: 'create_snapshot', box_id: boxId, name });
828
+ },
829
+ async restoreSnapshot(boxId, name) {
830
+ await send({ action: 'restore_snapshot', box_id: boxId, name });
831
+ },
832
+ async listSnapshots(boxId) {
833
+ return send({ action: 'list_snapshots', box_id: boxId });
834
+ },
835
+ async deleteSnapshot(boxId, name) {
836
+ await send({ action: 'delete_snapshot', box_id: boxId, name });
837
+ },
838
+ async cloneBox(boxId, name) {
839
+ return send({ action: 'clone', box_id: boxId, ...(name ? { name } : {}) });
840
+ },
841
+ async dispose() {
842
+ if (process?.stdin?.writable) {
843
+ process.stdin.end();
844
+ }
845
+ // Give the bridge a moment to cleanup
846
+ await new Promise(resolve => {
847
+ if (!process) {
848
+ resolve();
849
+ return;
850
+ }
851
+ const timeout = setTimeout(() => {
852
+ process?.kill();
853
+ resolve();
854
+ }, 3000);
855
+ process.on('exit', () => {
856
+ clearTimeout(timeout);
857
+ resolve();
858
+ });
859
+ });
860
+ cleanup();
861
+ },
862
+ };
863
+ }