@dbx-tools/appkit-lakebox 0.0.0-stage → 0.9.131

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 (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -2
  3. package/index.ts +32 -0
  4. package/lib/index.d.ts +28 -0
  5. package/lib/index.js +26 -0
  6. package/lib/src/_package.d.ts +2 -0
  7. package/lib/src/_package.js +11 -0
  8. package/lib/src/_resource-registry.d.ts +41 -0
  9. package/lib/src/_resource-registry.js +128 -0
  10. package/lib/src/_service-snapshots.d.ts +55 -0
  11. package/lib/src/_service-snapshots.js +65 -0
  12. package/lib/src/config.d.ts +40 -0
  13. package/lib/src/config.js +86 -0
  14. package/lib/src/desktop.d.ts +6 -0
  15. package/lib/src/desktop.js +88 -0
  16. package/lib/src/metrics.d.ts +59 -0
  17. package/lib/src/metrics.js +151 -0
  18. package/lib/src/plugin.d.ts +259 -0
  19. package/lib/src/plugin.js +329 -0
  20. package/lib/src/routes.d.ts +75 -0
  21. package/lib/src/routes.js +441 -0
  22. package/lib/src/runtime.d.ts +154 -0
  23. package/lib/src/runtime.js +760 -0
  24. package/lib/src/services.d.ts +101 -0
  25. package/lib/src/services.js +188 -0
  26. package/lib/src/skills.d.ts +5 -0
  27. package/lib/src/skills.js +21 -0
  28. package/lib/src/user.d.ts +46 -0
  29. package/lib/src/user.js +76 -0
  30. package/lib/src/workspace.d.ts +43 -0
  31. package/lib/src/workspace.js +138 -0
  32. package/lib/tsconfig.tsbuildinfo +1 -0
  33. package/package.json +167 -4
  34. package/scripts/desktop-prerequisites.sh +66 -0
  35. package/scripts/desktop-session.sh +307 -0
  36. package/scripts/lakebox-admin.sh +234 -0
  37. package/scripts/lakebox-service.py +449 -0
  38. package/skills/html-page/SKILL.md +40 -0
  39. package/skills/jupyter-lab/SKILL.md +32 -0
  40. package/skills/lakebox-showcase/SKILL.md +66 -0
  41. package/skills/network-speed-test/SKILL.md +29 -0
  42. package/skills/realtime-whiteboard/SKILL.md +28 -0
  43. package/src/_package.ts +11 -0
  44. package/src/_resource-registry.ts +160 -0
  45. package/src/_service-snapshots.ts +84 -0
  46. package/src/config.ts +128 -0
  47. package/src/desktop.ts +97 -0
  48. package/src/metrics.ts +178 -0
  49. package/src/plugin.ts +446 -0
  50. package/src/routes.ts +524 -0
  51. package/src/runtime.ts +952 -0
  52. package/src/services.ts +239 -0
  53. package/src/skills.ts +23 -0
  54. package/src/user.ts +88 -0
  55. package/src/workspace.ts +185 -0
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """Package-owned Lakebox service controller.
3
+
4
+ Runs as one locked Lakebox user. Owns the direct-crun lifecycle for that
5
+ user's services: saved definitions, image preparation through Buildah,
6
+ OCI spec generation, idempotent start and stop, readiness, bounded logs, and
7
+ restoring saved services after a Lakebox start.
8
+
9
+ Commands print one JSON document on stdout. Diagnostics go to service logs.
10
+ """
11
+
12
+ import base64
13
+ import contextlib
14
+ import fcntl
15
+ import json
16
+ import os
17
+ import shutil
18
+ import signal
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import time
23
+
24
+ RUNTIME = "/home/sandbox-agent/.dbx-tools/runtime"
25
+ BUNDLE = f"{RUNTIME}/container-v1/root"
26
+ POLICY = f"{BUNDLE}/etc/containers/policy.json"
27
+ HOME = os.environ["HOME"]
28
+ UID = os.getuid()
29
+ USER_HASH = os.path.basename(os.path.realpath(HOME))
30
+ SERVICES = f"{HOME}/.dbx-tools/services"
31
+ STORAGE = f"{HOME}/.dbx-tools/containers"
32
+ RUN = f"/run/lakebox/{USER_HASH}"
33
+ CRUN_ROOT = f"{RUN}/crun"
34
+ LOG_LIMIT = 1_048_576
35
+ LOG_KEEP = 262_144
36
+ HOST_BINDS = ("/usr", "/etc", "/opt", "/var", RUNTIME)
37
+
38
+
39
+ def runtime_env():
40
+ return {
41
+ "HOME": HOME,
42
+ "USER": os.environ.get("USER", ""),
43
+ "LOGNAME": os.environ.get("USER", ""),
44
+ "XDG_RUNTIME_DIR": RUN,
45
+ "PATH": f"{BUNDLE}/usr/bin:{BUNDLE}/usr/sbin:/usr/local/bin:/usr/bin:/bin",
46
+ "LD_LIBRARY_PATH": f"{BUNDLE}/usr/lib/x86_64-linux-gnu:{BUNDLE}/lib/x86_64-linux-gnu"
47
+ f":{BUNDLE}/usr/lib/aarch64-linux-gnu:{BUNDLE}/lib/aarch64-linux-gnu",
48
+ "CONTAINERS_CONF": f"{BUNDLE}/usr/share/containers/containers.conf",
49
+ "CONTAINERS_REGISTRIES_CONF": f"{BUNDLE}/etc/containers/registries.conf",
50
+ "CONTAINERS_STORAGE_CONF": f"{STORAGE}/storage.conf",
51
+ "BUILDAH_ISOLATION": "chroot",
52
+ }
53
+
54
+
55
+ def namespaced(args, log=None, check=True, capture=False):
56
+ """Runs one runtime command inside the user's single-UID namespace."""
57
+ sink = log if log is not None else subprocess.DEVNULL
58
+ result = subprocess.run(
59
+ ["unshare", "-Ur", "-m", "--", *args],
60
+ env=runtime_env(),
61
+ stdin=subprocess.DEVNULL,
62
+ stdout=subprocess.PIPE if capture else sink,
63
+ stderr=sink,
64
+ text=True,
65
+ )
66
+ if check and result.returncode != 0:
67
+ raise RuntimeError(f"{args[0]} {args[1] if len(args) > 1 else ''} exited {result.returncode}")
68
+ return result
69
+
70
+
71
+ def service_dir(name):
72
+ return f"{SERVICES}/{name}"
73
+
74
+
75
+ def read_definition(name):
76
+ with open(f"{service_dir(name)}/service.json", encoding="utf-8") as handle:
77
+ return json.load(handle)
78
+
79
+
80
+ def write_json(path, value):
81
+ temporary = f"{path}.tmp.{os.getpid()}"
82
+ with open(temporary, "w", encoding="utf-8") as handle:
83
+ json.dump(value, handle, indent=1)
84
+ os.replace(temporary, path)
85
+
86
+
87
+ @contextlib.contextmanager
88
+ def locked(name):
89
+ os.makedirs(service_dir(name), mode=0o700, exist_ok=True)
90
+ with open(f"{service_dir(name)}/.lock", "w", encoding="utf-8") as handle:
91
+ fcntl.flock(handle, fcntl.LOCK_EX)
92
+ yield
93
+
94
+
95
+ def open_log(name):
96
+ path = f"{service_dir(name)}/service.log"
97
+ if os.path.exists(path) and os.path.getsize(path) > LOG_LIMIT:
98
+ with open(path, "rb") as handle:
99
+ handle.seek(-LOG_KEEP, os.SEEK_END)
100
+ tail = handle.read()
101
+ with open(path, "wb") as handle:
102
+ handle.write(tail)
103
+ return open(path, "a", encoding="utf-8")
104
+
105
+
106
+ def crun_state(name):
107
+ result = namespaced(["crun", "--root", CRUN_ROOT, "state", container_id(name)], check=False, capture=True)
108
+ if result.returncode != 0:
109
+ return None
110
+ try:
111
+ return json.loads(result.stdout).get("status")
112
+ except json.JSONDecodeError:
113
+ return None
114
+
115
+
116
+ def container_id(name):
117
+ return f"lakebox-{USER_HASH}-{name}"
118
+
119
+
120
+ def listening_sockets():
121
+ """Yields (port, uid) for every listening TCP socket."""
122
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
123
+ if not os.path.exists(table):
124
+ continue
125
+ with open(table, encoding="utf-8") as handle:
126
+ rows = [line.split() for line in handle.readlines()[1:]]
127
+ for fields in rows:
128
+ if fields[3] == "0A":
129
+ yield int(fields[1].split(":")[1], 16), int(fields[7])
130
+
131
+
132
+ def listener_owner(port):
133
+ """Returns the UID listening on a loopback TCP port, or None."""
134
+ return next((uid for listening, uid in listening_sockets() if listening == port), None)
135
+
136
+
137
+ def port_open(port):
138
+ with contextlib.suppress(OSError):
139
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
140
+ return True
141
+ return False
142
+
143
+
144
+ def ensure_storage():
145
+ os.makedirs(f"{STORAGE}/storage", mode=0o700, exist_ok=True)
146
+ os.makedirs(CRUN_ROOT, mode=0o700, exist_ok=True)
147
+ conf = f"{STORAGE}/storage.conf"
148
+ content = (
149
+ '[storage]\ndriver = "vfs"\n'
150
+ f'runroot = "{RUN}/storage"\ngraphroot = "{STORAGE}/storage"\n'
151
+ '[storage.options.vfs]\nignore_chown_errors = "true"\n'
152
+ )
153
+ if not os.path.exists(conf) or open(conf, encoding="utf-8").read() != content:
154
+ with open(conf, "w", encoding="utf-8") as handle:
155
+ handle.write(content)
156
+
157
+
158
+ def prepare_image(definition, log):
159
+ """Pulls the image when needed and returns the rootfs plus image config."""
160
+ ensure_storage()
161
+ image = definition["image"]
162
+ working = f"lakebox-{definition['name']}"
163
+ if definition.get("pull") == "always":
164
+ namespaced(["buildah", "--signature-policy", POLICY, "pull", image], log)
165
+ marker = f"{service_dir(definition['name'])}/image.json"
166
+ previous = {}
167
+ if os.path.exists(marker):
168
+ with open(marker, encoding="utf-8") as handle:
169
+ previous = json.load(handle)
170
+ if previous.get("image") != image or definition.get("pull") == "always":
171
+ namespaced(["buildah", "rm", working], check=False)
172
+ log.write(f"preparing {image}\n")
173
+ log.flush()
174
+ namespaced(["buildah", "--signature-policy", POLICY, "from", "--name", working, image], log)
175
+ rootfs = namespaced(["buildah", "mount", working], log, capture=True).stdout.strip()
176
+ inspected = namespaced(["buildah", "inspect", "--type", "container", working], log, capture=True)
177
+ config = json.loads(inspected.stdout).get("OCIv1", {}).get("config", {})
178
+ write_json(marker, {"image": image})
179
+ return rootfs, config
180
+
181
+
182
+ def prepare_host_rootfs(name):
183
+ root = f"{service_dir(name)}/rootfs"
184
+ for directory in ("usr", "etc", "opt", "var", "home", "run", "tmp", "proc", "sys", "dev", "root"):
185
+ os.makedirs(f"{root}/{directory}", exist_ok=True)
186
+ for link in ("bin", "sbin", "lib", "lib64"):
187
+ if not os.path.lexists(f"{root}/{link}"):
188
+ os.symlink(f"usr/{link}", f"{root}/{link}")
189
+ for target in (HOME, RUNTIME):
190
+ os.makedirs(f"{root}{target}", exist_ok=True)
191
+ return root
192
+
193
+
194
+ def bind(source, target, read_only=True):
195
+ options = ["rbind", "ro" if read_only else "rw"]
196
+ return {"destination": target, "type": "bind", "source": source, "options": options}
197
+
198
+
199
+ def service_environment(definition, image_config, host):
200
+ environment = dict(item.split("=", 1) for item in image_config.get("Env") or [] if "=" in item)
201
+ environment.setdefault("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
202
+ if host:
203
+ user = os.environ.get("USER", "")
204
+ environment.update(HOME=HOME, USER=user, LOGNAME=user, SHELL="/bin/bash", LANG="C.UTF-8")
205
+ environment["TERM"] = "xterm-256color"
206
+ environment.update(definition.get("env") or {})
207
+ return [f"{key}={value}" for key, value in environment.items()]
208
+
209
+
210
+ def service_command(definition, image_config):
211
+ command = definition.get("command") or [
212
+ *(image_config.get("Entrypoint") or []),
213
+ *(image_config.get("Cmd") or []),
214
+ ]
215
+ if not command:
216
+ raise RuntimeError("The image defines no entrypoint or command; set command")
217
+ return command
218
+
219
+
220
+ def service_mounts(defaults, definition, host):
221
+ """Keeps the default device mounts, binds /proc and /sys, and adds service mounts."""
222
+ mounts = [entry for entry in defaults if entry["destination"] not in ("/proc", "/sys")]
223
+ for entry in mounts:
224
+ if entry["destination"] == "/dev/pts":
225
+ entry["options"] = [option for option in entry["options"] if not option.startswith("gid=")]
226
+ mounts.append({"destination": "/proc", "type": "bind", "source": "/proc",
227
+ "options": ["rbind", "nosuid", "nodev", "noexec"]})
228
+ mounts.append(bind("/sys", "/sys"))
229
+ if host:
230
+ mounts.extend(bind(path, path) for path in HOST_BINDS)
231
+ mounts.append(bind(HOME, HOME, read_only=False))
232
+ mounts.append({"destination": "/tmp", "type": "tmpfs", "source": "tmpfs",
233
+ "options": ["nosuid", "nodev", "mode=1777"]})
234
+ else:
235
+ mounts.extend(bind(path, path) for path in ("/etc/resolv.conf", "/etc/hosts"))
236
+ for mount in definition.get("mounts") or []:
237
+ os.makedirs(mount["source"], exist_ok=True)
238
+ mounts.append(bind(mount["source"], mount["target"], mount.get("readOnly", False)))
239
+ return mounts
240
+
241
+
242
+ def apply_namespaces(linux, process, host):
243
+ """Drops network, cgroup, and UTS namespaces; host services map the real UID."""
244
+ namespaces = [entry for entry in linux["namespaces"] if entry["type"] not in ("network", "cgroup", "uts")]
245
+ if host:
246
+ namespaces.append({"type": "user"})
247
+ linux["uidMappings"] = [{"containerID": UID, "hostID": 0, "size": 1}]
248
+ linux["gidMappings"] = [{"containerID": UID, "hostID": 0, "size": 1}]
249
+ process["user"] = {"uid": UID, "gid": UID}
250
+ linux["namespaces"] = namespaces
251
+ linux.pop("cgroupsPath", None)
252
+ linux.pop("resources", None)
253
+
254
+
255
+ def oci_config(definition, rootfs, image_config):
256
+ """Writes the OCI spec validated on FEVM for one service."""
257
+ directory = service_dir(definition["name"])
258
+ subprocess.run(["crun", "spec"], cwd=directory, env=runtime_env(), check=False,
259
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
260
+ with open(f"{directory}/config.json", encoding="utf-8") as handle:
261
+ config = json.load(handle)
262
+ host = definition["kind"] != "image"
263
+ config.pop("hostname", None)
264
+ config["root"] = {"path": rootfs, "readonly": host}
265
+ config["process"].update(
266
+ args=service_command(definition, image_config),
267
+ terminal=False,
268
+ env=service_environment(definition, image_config, host),
269
+ cwd=definition.get("workingDirectory") or (HOME if host else image_config.get("WorkingDir") or "/"),
270
+ )
271
+ apply_namespaces(config["linux"], config["process"], host)
272
+ config["mounts"] = service_mounts(config["mounts"], definition, host)
273
+ write_json(f"{directory}/config.json", config)
274
+
275
+
276
+ def wait_ready(definition, log, deadline):
277
+ port = definition["port"]
278
+ name = definition["name"]
279
+ while time.time() < deadline:
280
+ if crun_state(name) not in ("running", "created"):
281
+ raise RuntimeError("The service process exited; see the service log")
282
+ if definition.get("readiness", "tcp") == "none":
283
+ return
284
+ owner = listener_owner(port)
285
+ if owner is not None and owner != UID:
286
+ raise RuntimeError(f"Port {port} is held by another account")
287
+ if owner == UID and port_open(port):
288
+ return
289
+ time.sleep(0.5)
290
+ raise RuntimeError(f"The service did not listen on 127.0.0.1:{port} in time")
291
+
292
+
293
+ def stop_container(name, grace=10):
294
+ cid = container_id(name)
295
+ if crun_state(name) == "running":
296
+ namespaced(["crun", "--root", CRUN_ROOT, "kill", cid, "TERM"], check=False)
297
+ deadline = time.time() + grace
298
+ while time.time() < deadline and crun_state(name) == "running":
299
+ time.sleep(0.25)
300
+ namespaced(["crun", "--root", CRUN_ROOT, "kill", cid, "KILL"], check=False)
301
+ namespaced(["crun", "--root", CRUN_ROOT, "delete", "--force", cid], check=False)
302
+
303
+
304
+ def write_status(name, state, error=None):
305
+ status = {"state": state, "updatedAt": time.time()}
306
+ if error:
307
+ status["error"] = error
308
+ write_json(f"{service_dir(name)}/status.json", status)
309
+
310
+
311
+ def already_ready(definition):
312
+ if crun_state(definition["name"]) != "running":
313
+ return False
314
+ return definition.get("readiness", "tcp") == "none" or listener_owner(definition["port"]) == UID
315
+
316
+
317
+ def launch(definition, log):
318
+ """Recreates the container from its definition and waits for readiness."""
319
+ name = definition["name"]
320
+ stop_container(name)
321
+ if listener_owner(definition["port"]) is not None:
322
+ raise RuntimeError(f"Port {definition['port']} is already in use")
323
+ if definition["kind"] == "image":
324
+ rootfs, image_config = prepare_image(definition, log)
325
+ else:
326
+ rootfs, image_config = prepare_host_rootfs(name), {}
327
+ oci_config(definition, rootfs, image_config)
328
+ namespaced(["crun", "--root", CRUN_ROOT, "create", "--bundle", service_dir(name), container_id(name)], log)
329
+ namespaced(["crun", "--root", CRUN_ROOT, "start", container_id(name)], log)
330
+ wait_ready(definition, log, time.time() + definition.get("readySeconds", 180))
331
+
332
+
333
+ def start(name):
334
+ with locked(name):
335
+ definition = read_definition(name)
336
+ if already_ready(definition):
337
+ write_status(name, "ready")
338
+ return describe(name)
339
+ write_status(name, "starting")
340
+ with open_log(name) as log:
341
+ log.write(f"--- start {time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
342
+ log.flush()
343
+ try:
344
+ launch(definition, log)
345
+ except (OSError, RuntimeError, ValueError, KeyError) as error:
346
+ log.write(f"--- error: {error}\n")
347
+ write_status(name, "error", str(error))
348
+ return describe(name)
349
+ write_status(name, "ready")
350
+ log.write("--- ready\n")
351
+ return describe(name)
352
+
353
+
354
+ def stop(name):
355
+ with locked(name):
356
+ stop_container(name)
357
+ write_status(name, "stopped")
358
+ return describe(name)
359
+
360
+
361
+ def remove(name):
362
+ if not os.path.isdir(service_dir(name)):
363
+ return {"removed": False}
364
+ with locked(name):
365
+ stop_container(name)
366
+ namespaced(["buildah", "rm", f"lakebox-{name}"], check=False)
367
+ shutil.rmtree(service_dir(name), ignore_errors=True)
368
+ return {"removed": True}
369
+
370
+
371
+ def describe(name, include_log=False):
372
+ definition = read_definition(name)
373
+ status = {}
374
+ with contextlib.suppress(FileNotFoundError, json.JSONDecodeError):
375
+ with open(f"{service_dir(name)}/status.json", encoding="utf-8") as handle:
376
+ status = json.load(handle)
377
+ state = status.get("state", "stopped")
378
+ running = crun_state(name) == "running"
379
+ if state == "ready" and not running:
380
+ state = "stopped"
381
+ if state == "starting" and not running and not lock_held(name):
382
+ state = "error" if status.get("error") else "stopped"
383
+ result = {"name": name, "state": state, "definition": definition}
384
+ if status.get("error") and state == "error":
385
+ result["error"] = status["error"]
386
+ if include_log:
387
+ result["log"] = tail(name, 200, 16_000)
388
+ return result
389
+
390
+
391
+ def lock_held(name):
392
+ with contextlib.suppress(FileNotFoundError):
393
+ with open(f"{service_dir(name)}/.lock", "w", encoding="utf-8") as handle:
394
+ try:
395
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
396
+ except BlockingIOError:
397
+ return True
398
+ return False
399
+
400
+
401
+ def names():
402
+ if not os.path.isdir(SERVICES):
403
+ return []
404
+ return sorted(
405
+ entry for entry in os.listdir(SERVICES) if os.path.exists(f"{service_dir(entry)}/service.json")
406
+ )
407
+
408
+
409
+ def tail(name, max_lines, max_chars):
410
+ path = f"{service_dir(name)}/service.log"
411
+ if not os.path.exists(path):
412
+ return ""
413
+ with open(path, "rb") as handle:
414
+ handle.seek(0, os.SEEK_END)
415
+ size = handle.tell()
416
+ handle.seek(max(0, size - max_chars * 4))
417
+ text = handle.read().decode("utf-8", errors="replace")
418
+ return "\n".join(text.splitlines()[-max_lines:])[-max_chars:]
419
+
420
+
421
+ def main():
422
+ command = sys.argv[1] if len(sys.argv) > 1 else ""
423
+ if command == "put":
424
+ definition = json.loads(base64.b64decode(sys.argv[2]))
425
+ name = definition["name"]
426
+ with locked(name):
427
+ write_json(f"{service_dir(name)}/service.json", definition)
428
+ output = describe(name)
429
+ elif command == "start":
430
+ output = start(sys.argv[2])
431
+ elif command == "stop":
432
+ output = stop(sys.argv[2])
433
+ elif command == "remove":
434
+ output = remove(sys.argv[2])
435
+ elif command == "list":
436
+ output = {"services": [describe(name, include_log=True) for name in names()]}
437
+ elif command == "logs":
438
+ output = {"log": tail(sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))}
439
+ elif command == "start-all":
440
+ output = {"services": [start(name) for name in names() if read_definition(name).get("autostart")]}
441
+ else:
442
+ print("usage: lakebox-service put|start|stop|remove|list|logs|start-all", file=sys.stderr)
443
+ sys.exit(2)
444
+ json.dump(output, sys.stdout)
445
+
446
+
447
+ if __name__ == "__main__":
448
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
449
+ main()
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: html-page
3
+ description: Deploy or host a web app, website, HTML page, Three.js scene, 3D game, visualization, or browser demo as a Lakebox service. Use when asked to deploy an app, service, site, or page and return an open URL.
4
+ ---
5
+
6
+ # HTML Page
7
+
8
+ Generate one complete, self-contained page; keep CSS, JavaScript, workers,
9
+ assets, and data inline. Do not use external CDNs.
10
+
11
+ When the user names an existing application, prefer its maintained Docker/OCI
12
+ image. Use `web_search` when needed to find upstream image documentation, then
13
+ verify unprivileged execution, `127.0.0.1:$SERVICE_PORT`, one HTTP port, and
14
+ `$SERVICE_PATH` support. Deploy images only through
15
+ `create_lakebox_service`; never run a container CLI directly. Use the
16
+ self-contained page below when no compatible image exists or the user wants a
17
+ custom browser experience.
18
+
19
+ Route requests such as "deploy this app", "serve this page", "host this web
20
+ demo", or "deploy it as a service" here when the result can run entirely in a
21
+ browser. This includes interactive Three.js scenes and small games.
22
+
23
+ Call `create_lakebox_service` once. Use a short lowercase `name` for each
24
+ distinct page:
25
+
26
+ ```json
27
+ {
28
+ "name": "html-page",
29
+ "image": "docker.io/library/python:3.12-alpine",
30
+ "env": { "HTML_CONTENT": "<generated page>" },
31
+ "command": [
32
+ "sh",
33
+ "-c",
34
+ "mkdir -p /site && printf %s \"$HTML_CONTENT\" > /site/index.html && exec python -m http.server \"$SERVICE_PORT\" --bind 127.0.0.1 --directory /site"
35
+ ]
36
+ }
37
+ ```
38
+
39
+ The call returns after the server is listening. Call it again with the same
40
+ name to replace the page. Return the service `openUrl` as a Markdown link.
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: jupyter-lab
3
+ description: Deploy or run JupyterLab as a Lakebox service behind the user's authenticated route, without a separate Jupyter password or token. Use for notebook app or notebook service requests.
4
+ ---
5
+
6
+ # JupyterLab
7
+
8
+ Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
9
+ container CLI directly.
10
+
11
+ Call `create_lakebox_service` once:
12
+
13
+ ```json
14
+ {
15
+ "name": "jupyter-lab",
16
+ "image": "docker.io/library/python:3.12-slim",
17
+ "mounts": [{ "source": "notebooks", "target": "/work" }],
18
+ "stripPrefix": false,
19
+ "openPath": "lab",
20
+ "command": [
21
+ "sh",
22
+ "-c",
23
+ "[ -x /work/.venv/bin/jupyter ] || { python -m venv /work/.venv && /work/.venv/bin/pip install --quiet jupyterlab==4.4.10; } && exec /work/.venv/bin/jupyter lab --allow-root --no-browser --ip 127.0.0.1 --port \"$SERVICE_PORT\" --ServerApp.root_dir=/work --ServerApp.base_url=\"$SERVICE_PATH\" --IdentityProvider.token= --PasswordIdentityProvider.hashed_password="
24
+ ]
25
+ }
26
+ ```
27
+
28
+ The AppKit route supplies authentication, so do not add another token or
29
+ password. Jupyter serves its full route path, so `stripPrefix` is false.
30
+ Notebooks and the virtual environment persist in `~/notebooks`, so later starts
31
+ skip installation. The first start installs JupyterLab and can take a minute.
32
+ Return the service `openUrl` as a Markdown link.
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: lakebox-showcase
3
+ description: Recommend and deploy high-value single-user Lakebox browser demos. Use when a user asks what they can do with Lakebox, wants fun Lakebox or Docker demo ideas, or needs a one-port unprivileged showcase.
4
+ ---
5
+
6
+ # Lakebox Showcase
7
+
8
+ For a broad question such as "what can I do with Lakebox?", recommend a short
9
+ list before deploying anything:
10
+
11
+ 1. A retro desktop, paint app, or pixel-art editor built as a self-contained
12
+ HTML page. Use JS Paint and Piskel as design inspiration.
13
+ 2. A browser music player or sequencer inspired by Webamp or Efflux.
14
+ 3. A data-transformation workbench inspired by CyberChef or IT Tools.
15
+ 4. A diagram, drawing, or whiteboard app inspired by Excalidraw or diagrams.net.
16
+ 5. A self-contained 3D scene, puzzle, or arcade game.
17
+ 6. JupyterLab for a real stateful development environment.
18
+ 7. A network speed test for demonstrating the AppKit-to-Lakebox route.
19
+
20
+ Prefer a maintained Docker/OCI image when the user names an existing
21
+ application. If the image is not already known, use `web_search` to find the
22
+ upstream repository or image documentation. Verify that it can run
23
+ unprivileged, bind `127.0.0.1:$SERVICE_PORT`, expose one HTTP port, and work
24
+ beneath `$SERVICE_PATH`. Deploy it only through `create_lakebox_service`; never
25
+ run `docker`, `podman`, or `buildah` directly.
26
+
27
+ For a custom experience, prefer the first five because browser-only code
28
+ packaged in a small image is reliable behind the user-scoped Lakebox route. Use
29
+ the packaged `jupyter-lab` or `network-speed-test` skill when the user selects
30
+ one of those exact demos.
31
+
32
+ Do not recommend these as primary Lakebox showcases:
33
+
34
+ - PairDrop, LAN Games, or multiplayer collaboration. Lakebox routes and homes
35
+ are user-scoped, so cross-user interaction has little value.
36
+ - Webtop. Lakebox already provides a built-in desktop and terminal.
37
+ - Quake, console emulators, or ROM-based apps. Assets, licensing, device access,
38
+ or elevated runtime requirements make them unreliable defaults.
39
+ - Large local ML applications such as offline translation. Model downloads and
40
+ memory use make the first run slow and fragile.
41
+ - An upstream image that assumes port 80, root, a dedicated hostname, or `/`
42
+ asset paths. Use it only after proving that it listens on
43
+ `127.0.0.1:$SERVICE_PORT` and works beneath `$SERVICE_PATH`.
44
+
45
+ When the user asks to deploy a showcase but does not select one, generate a
46
+ polished self-contained retro desktop with two or three interactive mini apps.
47
+ Replace the placeholder HTML below with the complete page, then call
48
+ `create_lakebox_service` once:
49
+
50
+ ```json
51
+ {
52
+ "name": "lakebox-showcase",
53
+ "image": "docker.io/library/python:3.12-alpine",
54
+ "env": {
55
+ "HTML_CONTENT": "<generated self-contained retro desktop page>"
56
+ },
57
+ "command": [
58
+ "sh",
59
+ "-c",
60
+ "mkdir -p /site && printf %s \"$HTML_CONTENT\" > /site/index.html && exec python -m http.server \"$SERVICE_PORT\" --bind 127.0.0.1 --directory /site"
61
+ ]
62
+ }
63
+ ```
64
+
65
+ Keep all CSS, JavaScript, assets, workers, and data inline. Do not use external
66
+ CDNs. Return the service `openUrl` as a Markdown link.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: network-speed-test
3
+ description: Deploy or run a network speed-test app as a Lakebox service using LibreSpeed to measure browser-to-AppKit-to-Lakebox HTTP throughput.
4
+ ---
5
+
6
+ # Network Speed Test
7
+
8
+ Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
9
+ container CLI directly.
10
+
11
+ Call `create_lakebox_service` once:
12
+
13
+ ```json
14
+ {
15
+ "name": "speed-test",
16
+ "image": "docker.io/library/alpine:3.22",
17
+ "mounts": [{ "source": "speed-test", "target": "/app" }],
18
+ "command": [
19
+ "sh",
20
+ "-c",
21
+ "set -eu; cd /app; arch=$(uname -m); case \"$arch\" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac; [ -x speedtest-backend ] || wget -qO- \"https://github.com/librespeed/speedtest-go/releases/download/v1.1.6/speedtest-go_1.1.6_linux_${arch}.tar.gz\" | tar -xz; printf 'bind_address = \"127.0.0.1\"\\nlisten_port = %s\\nserver_lat = 1\\nserver_lng = 1\\ndatabase_type = \"none\"\\n' \"$SERVICE_PORT\" > settings.toml; exec ./speedtest-backend"
22
+ ]
23
+ }
24
+ ```
25
+
26
+ Do not call public speed-test services or external CDNs. The result measures
27
+ browser-to-AppKit-to-Lakebox traffic, including proxy and SSH forwarding
28
+ overhead; it is not an ISP or raw workspace network test. Return the service
29
+ `openUrl` as a Markdown link.
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: realtime-whiteboard
3
+ description: Deploy or run a realtime collaborative whiteboard app as a Lakebox service using WBO and user-scoped WebSocket routing.
4
+ ---
5
+
6
+ # Realtime Whiteboard
7
+
8
+ Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
9
+ container CLI directly.
10
+
11
+ Call `create_lakebox_service` once:
12
+
13
+ ```json
14
+ {
15
+ "name": "realtime-whiteboard",
16
+ "image": "docker.io/library/node:24-alpine",
17
+ "mounts": [{ "source": "whiteboard", "target": "/data" }],
18
+ "command": [
19
+ "sh",
20
+ "-c",
21
+ "set -eu; mkdir -p /data/app /data/boards; cd /data/app; [ -f server/server.mjs ] || wget -qO- https://github.com/lovasoa/whitebophir/archive/refs/tags/v2.17.1.tar.gz | tar -xz --strip-components=1; [ -d node_modules ] || npm install --omit=dev --ignore-scripts --no-audit --no-fund; WBO_HISTORY_DIR=/data/boards HOST=127.0.0.1 PORT=\"$SERVICE_PORT\" exec node server/server.mjs"
22
+ ]
23
+ }
24
+ ```
25
+
26
+ Boards persist in `~/whiteboard`. The first start downloads and installs WBO.
27
+ WebSocket clients use relative URLs, so the route prefix stays intact. Return
28
+ the service `openUrl` as a Markdown link.
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /** Read one source or packaged resource from the appkit-lakebox package root. */
6
+ export function readPackageResource(...segments: string[]): string {
7
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const parent = dirname(moduleDirectory);
9
+ const packageRoot = basename(parent) === "lib" ? dirname(parent) : parent;
10
+ return readFileSync(join(packageRoot, ...segments), "utf8");
11
+ }