@frockbot/computer-host-runtime 0.0.0 → 0.1.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.
package/src/runtime.ts ADDED
@@ -0,0 +1,1739 @@
1
+ /**
2
+ * The Computer runtime as it exists *on the Sprite*: the paths a User's
3
+ * Computer is laid out under, the shell scripts that provision it and run its
4
+ * declared services, and the browser helper the provider drives over CDP.
5
+ *
6
+ * These constants used to live inside `@frockbot/plugin-fly-sprite`, where the
7
+ * only way to reach the Sprite was `execFileHTTP` and every script travelled
8
+ * base64-encoded on the command's argv. Fly answers a ~2.5 KB argv with HTTP
9
+ * 431 (ADR 0004), so the provisioning script could never run from any runtime
10
+ * as written. The scripts move here so the shared Computer host of ADR 0004
11
+ * can deliver them the way they must be delivered — on a command's **stdin** —
12
+ * while the provider Package keeps generating exactly the same text.
13
+ *
14
+ * There is one copy. `plugin-fly-sprite` imports this module rather than
15
+ * holding its own, so a change to the Computer's layout cannot mean two
16
+ * Computers.
17
+ */
18
+ export const DESKTOP_SERVICE = "frockbot-viewer-gateway";
19
+ /**
20
+ * The durable-root sync's on-Sprite half (ADR 0013), declared as a service so
21
+ * the Sprite runtime brings it back after a cold pause: "Only
22
+ * Computer-provider-declared services may be reattached; other processes are
23
+ * assumed dead after a cold pause." It holds no credential and makes no
24
+ * network call — it watches the durable roots and bumps a change signal, and
25
+ * the sync agent that reads object storage runs in the backend.
26
+ */
27
+ export const WORKSPACE_SYNC_SERVICE = "frockbot-workspace-sync";
28
+ export const HOME_ROOT = "/home/box";
29
+ export const DATA_ROOT = `${HOME_ROOT}/agent-data`;
30
+ export const RUNTIME_ROOT = `${HOME_ROOT}/.frockbot`;
31
+ export const BOTS_ROOT = `${RUNTIME_ROOT}/bots`;
32
+
33
+ /**
34
+ * The lease key a User-wide `desktop-gui` lease is held under.
35
+ *
36
+ * It sits beside the tenant directories rather than inside one, because the
37
+ * desktop it serializes is the box's, not a tenant's: one Computer serves all
38
+ * of a User's Bots and there is one screen on it. The `.` makes it unreachable
39
+ * from `computerBotKeyV1`, whose keys are `[a-z0-9-]+` followed by a hex
40
+ * digest, so no tenant can ever be handed this directory by accident.
41
+ */
42
+ export const DESKTOP_GUI_LEASE_KEY = "desktop-gui.lease";
43
+ export const WORKSPACES_ROOT = "/workspaces";
44
+ /**
45
+ * The shared scratch every Bot of one User can reach, and the one directory on
46
+ * the Computer that is deliberately **not** durable.
47
+ *
48
+ * GrokBot's fourteen agents share `/workspace` and make it their default cwd.
49
+ * FrockBot keeps the per-Bot workspace private (`/workspaces/<botKey>`) and
50
+ * adds this beside it, because a hand-off between two of a User's Bots needs
51
+ * somewhere to put a file that is not either Bot's private directory.
52
+ *
53
+ * It is absent from the Computer provider's Workspace layout on purpose. "The
54
+ * Workspace is durable User and Bot state ... everything else on the Computer
55
+ * may be lost": nothing here reaches object storage, so it survives a
56
+ * hibernation and a cold start on the Sprite's own disk and is lost on an
57
+ * image rebuild, a Computer reset, or a host migration. Said once here, once
58
+ * in `layout.md`, once in the `computer_exec` description, and reported by
59
+ * box-doctor as the first thing to prune under disk pressure.
60
+ */
61
+ export const SCRATCH_ROOT = "/workspace";
62
+ /** The environment variable a tenant finds the shared scratch under. */
63
+ export const SCRATCH_ENV = "FROCKBOT_SCRATCH";
64
+ /** Where the launcher and the sanctioned-surface shims are installed. */
65
+ export const BIN_ROOT = `${HOME_ROOT}/bin`;
66
+ /**
67
+ * Where the sanctioned-surface shims live.
68
+ *
69
+ * A directory of their own rather than `bin`, because `bin` holds real
70
+ * binaries — the browser launcher, and the browser itself — and a refusal
71
+ * named `chromium` sitting where the browser is expected would be a Computer
72
+ * that cannot start its own desktop. This directory leads a tenant's `PATH`;
73
+ * `bin` follows it.
74
+ */
75
+ export const SHIMS_ROOT = `${RUNTIME_ROOT}/shims`;
76
+ /** The shipped reference documents a Bot reads to debug its own Computer. */
77
+ export const REFERENCE_ROOT = `${HOME_ROOT}/reference`;
78
+ export const CONTROL_SCRIPT = `${RUNTIME_ROOT}/control.sh`;
79
+ export const BOUNDED_LOG_SCRIPT = `${RUNTIME_ROOT}/bounded-log.sh`;
80
+ /** Bytes kept from the head of a background process's log. */
81
+ export const BOUNDED_LOG_HEAD_BYTES = 131_072;
82
+ /** Bytes kept from its tail. Together, GrokBot's 256 KiB cap. */
83
+ export const BOUNDED_LOG_TAIL_BYTES = 131_072;
84
+ export const ENSURE_AGENT_SCRIPT = `${RUNTIME_ROOT}/ensure-agent.sh`;
85
+ /** Where Playwright keeps the browser builds it downloads for this Computer. */
86
+ export const BROWSERS_ROOT = `${RUNTIME_ROOT}/browsers`;
87
+ /**
88
+ * The one path that runs the Computer's browser.
89
+ *
90
+ * It is a symlink rather than a package because Ubuntu's `chromium` is a snap
91
+ * transitional package (ADR 0004): installing it drags in `snapd` and
92
+ * `systemd` and never finished inside the ten-minute bound. The browser is
93
+ * Playwright's own Chromium build instead — a self-contained tarball from
94
+ * Playwright's CDN, no package manager involved — and this symlink is what
95
+ * keeps `start-desktop.sh` free of the version in its directory name.
96
+ */
97
+ export const CHROMIUM_PATH = `${HOME_ROOT}/bin/chromium`;
98
+ /** Pinned with `playwright-core`, because the driver and the build must agree. */
99
+ export const PLAYWRIGHT_VERSION = "1.55.0";
100
+ /**
101
+ * The Playwright build the Computer downloads, named for a release Playwright
102
+ * knows rather than the one the Sprite actually runs.
103
+ *
104
+ * Playwright resolves a browser build from the host distribution and refuses
105
+ * anything it has no build for: on the Sprite base image it answers "Playwright
106
+ * does not support chromium on ubuntu26.04-x64" and installs nothing. This is
107
+ * the newest release it does have a build for, and that build was verified
108
+ * running headful under Xvfb on a real Sprite with CDP answering.
109
+ */
110
+ export const PLAYWRIGHT_PLATFORM = "ubuntu24.04-x64";
111
+
112
+ /**
113
+ * Everything the Computer's desktop needs from the distribution, and nothing
114
+ * that merely recommends itself.
115
+ *
116
+ * `chromium` is deliberately absent. On the Sprite base image (Ubuntu 25.10)
117
+ * it is a snap transitional package: installing it pulls `snapd` and
118
+ * `systemd` and, measured on 2026-09-01, had not finished after 25 minutes.
119
+ * The browser arrives in the `browser` phase instead, as Playwright's own
120
+ * self-contained Chromium build. What is left here is the display (`xvfb`),
121
+ * the window manager (`fluxbox`), the VNC server (`x11vnc`), the viewer's
122
+ * static assets and proxy (`novnc`, `websockify`), `xdpyinfo` (`x11-utils`)
123
+ * for the desktop script's readiness wait, `scrot` for `computer_screenshot`,
124
+ * and the shared libraries that
125
+ * Chromium build links against — those are named explicitly because
126
+ * `--no-install-recommends` is what keeps `python3-numpy`, `liblapack3`,
127
+ * `poppler-data`, and a font collection out of a cold Computer.
128
+ */
129
+ export const DESKTOP_PACKAGES = [
130
+ "xvfb",
131
+ "fluxbox",
132
+ "x11vnc",
133
+ "novnc",
134
+ "websockify",
135
+ "x11-utils",
136
+ "xauth",
137
+ "scrot",
138
+ "ca-certificates",
139
+ "util-linux",
140
+ "libnss3",
141
+ "libnspr4",
142
+ "libatk1.0-0t64",
143
+ "libatk-bridge2.0-0t64",
144
+ "libcups2t64",
145
+ "libdrm2",
146
+ "libxkbcommon0",
147
+ "libxcomposite1",
148
+ "libxdamage1",
149
+ "libxfixes3",
150
+ "libxrandr2",
151
+ "libgbm1",
152
+ "libpango-1.0-0",
153
+ "libcairo2",
154
+ "libasound2t64",
155
+ "libatspi2.0-0",
156
+ ] as const;
157
+ export const LEASE_MAX_AGE_SECONDS = 90;
158
+ /**
159
+ * How long a tenant's slot is held after the provider last opened or ran
160
+ * anything for it.
161
+ *
162
+ * A slot is a display number — an Xvfb, VNC, and CDP port triple — and there
163
+ * are a hundred of them, so they are allocated on demand and reclaimed rather
164
+ * than owned for ever. What makes a tenant live is this provider having opened
165
+ * or executed for it recently, or a human holding its takeover lease; nothing
166
+ * on the Computer is evidence, because the desktop script deletes its own X
167
+ * lock when it restarts and an exec-only tenant never holds one at all. The
168
+ * threshold is declared here so a reclaim is a stated policy rather than a
169
+ * guess about who is still using a screen.
170
+ */
171
+ export const SLOT_IDLE_SECONDS = 900;
172
+ /** Exit code the ensure script uses when every slot belongs to a live tenant. */
173
+ export const NO_SLOTS_EXIT = 75;
174
+ /** The same refusal, on stdout, for a transport that swallows the exit code. */
175
+ export const NO_SLOTS_MARKER = "__FROCKBOT_NO_SLOTS__";
176
+
177
+ /**
178
+ * The one variable that separates a sanctioned GUI call from a shell-driven
179
+ * one.
180
+ *
181
+ * The shims below refuse unless it is set, and the Computer's own scripts —
182
+ * the desktop starter, the launcher, the screenshot exec — set it. It is
183
+ * emphatically not a security control: a Bot's shell can export it in one
184
+ * word, and the Computer is the User's trust boundary anyway. It exists so
185
+ * the sanctioned path stays open while the accidental one closes with an
186
+ * explanation.
187
+ */
188
+ export const SANCTIONED_SURFACE_ENV = "FROCKBOT_SANCTIONED_SURFACE";
189
+
190
+ /**
191
+ * The commands a Bot never runs itself, because a sanctioned tool does the
192
+ * same job better.
193
+ *
194
+ * GrokBot's box ships `box-chrome` and has no `xdotool` at all: "the GUI is
195
+ * never driven from the shell". FrockBot states the same policy in two
196
+ * layers — a refusal at the `computer_exec` seam and a PATH shim on the
197
+ * Computer — and calls it policy rather than a boundary in both places.
198
+ */
199
+ export const COMPUTER_GUI_SHELL_COMMANDS = [
200
+ "chromium",
201
+ "chromium-browser",
202
+ "chrome",
203
+ "google-chrome",
204
+ "xdotool",
205
+ "wmctrl",
206
+ "xdpyinfo",
207
+ "scrot",
208
+ "import",
209
+ "x11vnc",
210
+ "Xvfb",
211
+ ] as const;
212
+
213
+ /** The refusal both layers print, naming the surface that does the job. */
214
+ export function computerGuiRefusalV1(command: string): string {
215
+ return [
216
+ `"${command}" is not run directly on this Computer: its GUI is never driven from the shell.`,
217
+ "Use computer_browser to drive the browser, computer_screenshot to see the screen,",
218
+ `and ${CHROME_LAUNCHER} to launch a browser with the Computer's own flags.`,
219
+ ].join(" ");
220
+ }
221
+
222
+ /**
223
+ * The command a shell string invokes, when that command is one of the GUI
224
+ * commands above.
225
+ *
226
+ * A regex over a shell string, and honestly labelled as one: it reads command
227
+ * positions — the start of the string, and whatever follows `;`, `&&`, `||`,
228
+ * `|`, a newline, or a subshell — past any leading environment assignments,
229
+ * `sudo`, or `env`. It is defeatable by anyone who wants to defeat it, which
230
+ * is why the policy is stated in the refusal rather than relied upon.
231
+ */
232
+ export function shellGuiCommandV1(command: string): string | undefined {
233
+ const names = COMPUTER_GUI_SHELL_COMMANDS.join("|");
234
+ const pattern = new RegExp(
235
+ String.raw`(?:^|[;&|(\n]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;|&]*\s+)*(?:sudo\s+|env\s+)?(?:[^\s;|&'"]*/)?(${names})(?=$|[\s;|&)'"])`,
236
+ );
237
+ const match = pattern.exec(command);
238
+ return match?.[1];
239
+ }
240
+
241
+ /** The browser flags the Computer runs chromium under, in one place. */
242
+ export const CHROMIUM_FLAGS: readonly string[] = [
243
+ "--no-sandbox",
244
+ "--disable-dev-shm-usage",
245
+ "--disable-gpu",
246
+ `--user-data-dir=${HOME_ROOT}/chrome-profile`,
247
+ "--remote-debugging-address=127.0.0.1",
248
+ "--start-maximized",
249
+ ];
250
+
251
+ export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
252
+
253
+ /**
254
+ * The single place the Computer's chromium flags live (parity row 33).
255
+ *
256
+ * It takes a Bot key, reads that tenant's slot, and derives the display and
257
+ * the CDP port from it — the same arithmetic the desktop starter does, done
258
+ * once. `start-desktop.sh` calls it, and so may a human debugging the box;
259
+ * nothing else needs to know the flag set exists.
260
+ */
261
+ export const chromeLauncherScript = `#!/usr/bin/env bash
262
+ set -eu
263
+ KEY="\${1:-\${FROCKBOT_BOT_KEY:-}}"
264
+ if [ -z "$KEY" ]; then
265
+ echo "frockbot-chrome needs a Bot key: frockbot-chrome <botKey> [chromium args…]" >&2
266
+ exit 64
267
+ fi
268
+ shift || true
269
+ SLOT=$(cat ${BOTS_ROOT}/"$KEY"/slot 2>/dev/null || echo "")
270
+ if [ -z "$SLOT" ]; then
271
+ echo "Bot \\"$KEY\\" has no desktop slot on this Computer" >&2
272
+ exit 69
273
+ fi
274
+ export DISPLAY=":$((100 + SLOT))"
275
+ export ${SANCTIONED_SURFACE_ENV}=1
276
+ if [ ! -x ${CHROMIUM_PATH} ]; then
277
+ echo "no browser is installed at ${CHROMIUM_PATH}; the Computer installs one when it is provisioned" >&2
278
+ exit 69
279
+ fi
280
+ # By absolute path, not by name: the browser is Playwright's own build behind a
281
+ # stable symlink, and reaching it through PATH would go past the shim that
282
+ # covers the name chromium.
283
+ exec ${CHROMIUM_PATH} ${CHROMIUM_FLAGS.join(" ")} --remote-debugging-port="$((9222 + SLOT))" "$@"
284
+ `;
285
+
286
+ /**
287
+ * One PATH shim: the same refusal the tool seam gives, on the Computer.
288
+ *
289
+ * With the sanctioned variable set it steps out of the way — it drops its own
290
+ * directory from `PATH` and execs the real binary — so the Computer's own
291
+ * scripts keep working while a shell that reached for `xdotool` by hand is
292
+ * told what to use instead. Exit 64 is `EX_USAGE`: the command was wrong, not
293
+ * the Computer.
294
+ */
295
+ export function guiShimScript(command: string): string {
296
+ return `#!/usr/bin/env bash
297
+ if [ "\${${SANCTIONED_SURFACE_ENV}:-}" = 1 ]; then
298
+ NEXT=""
299
+ IFS=: read -ra PARTS <<< "$PATH"
300
+ for PART in "\${PARTS[@]}"; do
301
+ [ "$PART" = ${SHIMS_ROOT} ] && continue
302
+ NEXT="\${NEXT:+$NEXT:}$PART"
303
+ done
304
+ export PATH="$NEXT"
305
+ exec ${command} "$@"
306
+ fi
307
+ echo ${shellQuote(computerGuiRefusalV1(command))} >&2
308
+ exit 64
309
+ `;
310
+ }
311
+
312
+ export const startDesktopScript = `#!/usr/bin/env bash
313
+ set -eu
314
+ KEY="$1"
315
+ ROOT=${RUNTIME_ROOT}
316
+ BOT="$ROOT/bots/$KEY"
317
+ SLOT=$(cat "$BOT/slot")
318
+ DISPLAY_NUMBER=$((100 + SLOT))
319
+ VNC_PORT=$((5900 + SLOT))
320
+ export DISPLAY=:$DISPLAY_NUMBER
321
+ # The desktop stack *is* the sanctioned surface, so the shims step aside for
322
+ # it. Everything a Bot's own shell runs arrives without this set.
323
+ export ${SANCTIONED_SURFACE_ENV}=1
324
+ cleanup() {
325
+ jobs -pr | xargs -r kill >/dev/null 2>&1 || true
326
+ }
327
+ trap cleanup EXIT INT TERM
328
+ rm -f "/tmp/.X$DISPLAY_NUMBER-lock" "/tmp/.X11-unix/X$DISPLAY_NUMBER"
329
+ Xvfb "$DISPLAY" -screen 0 1280x720x24 -nolisten tcp &
330
+ for _ in $(seq 1 100); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.1; done
331
+ fluxbox >"$BOT/fluxbox.log" 2>&1 &
332
+ ${CHROME_LAUNCHER} "$KEY" about:blank >"$BOT/chromium.log" 2>&1 &
333
+ x11vnc -display "$DISPLAY" -forever -shared -rfbport "$VNC_PORT" -passwd "$(cat "$BOT/vnc-password")" >"$BOT/x11vnc.log" 2>&1 &
334
+ VNC_PID=$!
335
+ wait "$VNC_PID"
336
+ `;
337
+
338
+ export const ensureAgentScript = `#!/usr/bin/env bash
339
+ set -eu
340
+ KEY="$1"
341
+ PROFILE_BASE64="$2"
342
+ ROOT=${RUNTIME_ROOT}
343
+ BOT="$ROOT/bots/$KEY"
344
+ DATA=${DATA_ROOT}
345
+ AGENT_DATA="$DATA/agents/$KEY"
346
+ WORKSPACE=${WORKSPACES_ROOT}/$KEY
347
+ case "$KEY" in (*[!a-z0-9-]*|'') echo "invalid agent key" >&2; exit 64;; esac
348
+ mkdir -p "$BOT" "$AGENT_DATA" "$AGENT_DATA/memory" "$AGENT_DATA/skills" "$DATA/user-memory" "$DATA/user-packages" "$WORKSPACE" "${HOME_ROOT}/bin" "${HOME_ROOT}/reference" "${HOME_ROOT}/chrome-profile"
349
+ PROFILE_TMP=$(mktemp "$AGENT_DATA/profile.json.XXXXXX")
350
+ printf '%s' "$PROFILE_BASE64" | base64 -d > "$PROFILE_TMP"
351
+ chmod 600 "$PROFILE_TMP"
352
+ mv "$PROFILE_TMP" "$AGENT_DATA/profile.json"
353
+ exec 9>"$ROOT/registry.lock"
354
+ flock -x 9
355
+ if [ ! -s "$BOT/slot" ]; then
356
+ # Every slot in use, read once. The registry lock is held, so the answer
357
+ # cannot change under this scan, and one read beats one per slot per tenant
358
+ # when a Computer is close to full.
359
+ USED=" $(cat "$ROOT"/bots/*/slot 2>/dev/null | tr '\n' ' ') "
360
+ SLOT=0
361
+ while [ "$SLOT" -lt 100 ]; do
362
+ case "$USED" in (*" $SLOT "*) ;; (*) break ;; esac
363
+ SLOT=$((SLOT + 1))
364
+ done
365
+ if [ "$SLOT" -ge 100 ]; then
366
+ # A slot is a display number, not durable state: it is the Xvfb, VNC, and
367
+ # CDP port triple a tenant's desktop uses while it has one. A tenant that
368
+ # never comes back would otherwise hold one for ever, and the hundred and
369
+ # first Bot of a User could never open a desktop, so the allocation is
370
+ # bounded rather than permanent.
371
+ #
372
+ # Liveness is decided by the provider's own registry, never by the
373
+ # Computer's state: "last-seen" is written by the backend every time it
374
+ # opens or runs anything for a tenant, and "human-control" is the takeover
375
+ # lease. An X lock proves nothing — the desktop script deletes its own on
376
+ # restart, and a tenant that only ever execs never holds one — so a slot is
377
+ # reclaimed only when its tenant has been idle past the declared threshold
378
+ # AND no viewer lease is fresh. Its viewer token goes with the slot, or
379
+ # that token would address another Bot's screen. When every slot belongs to
380
+ # a live tenant the new tenant is refused: sharing a display would put two
381
+ # Bots on one screen, which is worse than an unavailable desktop.
382
+ NOW=$(date +%s)
383
+ DESKTOP_LEASE="$ROOT/bots/${DESKTOP_GUI_LEASE_KEY}/human-control"
384
+ DESKTOP_LEASE_FRESH=0
385
+ if [ -f "$DESKTOP_LEASE" ]; then
386
+ DESKTOP_LEASED=$(stat -c %Y "$DESKTOP_LEASE")
387
+ if [ $((NOW - DESKTOP_LEASED)) -le ${LEASE_MAX_AGE_SECONDS} ]; then
388
+ DESKTOP_LEASE_FRESH=1
389
+ fi
390
+ fi
391
+ VICTIM=""
392
+ # Human takeover and computerUse hold the User-wide screen. Reclaiming
393
+ # any tenant's display while that lease is fresh would replace the screen
394
+ # underneath its holder, even when another Bot owned the slot. Avoid the
395
+ # whole per-tenant scan in that case: no candidate can be eligible.
396
+ if [ "$DESKTOP_LEASE_FRESH" -ne 1 ]; then
397
+ for FILE in $(ls -1tr "$ROOT"/bots/*/slot 2>/dev/null); do
398
+ CANDIDATE_BOT=$(dirname "$FILE")
399
+ SEEN=0
400
+ if [ -f "$CANDIDATE_BOT/last-seen" ]; then SEEN=$(stat -c %Y "$CANDIDATE_BOT/last-seen"); fi
401
+ if [ $((NOW - SEEN)) -le ${SLOT_IDLE_SECONDS} ]; then continue; fi
402
+ if [ -f "$CANDIDATE_BOT/human-control" ]; then
403
+ LEASED=$(stat -c %Y "$CANDIDATE_BOT/human-control")
404
+ if [ $((NOW - LEASED)) -le ${LEASE_MAX_AGE_SECONDS} ]; then continue; fi
405
+ fi
406
+ VICTIM="$FILE"
407
+ SLOT=$(cat "$FILE")
408
+ break
409
+ done
410
+ fi
411
+ if [ -z "$VICTIM" ]; then
412
+ # Said both ways: the exit code is for a caller that gets one, and
413
+ # the marker is for a transport that hands back output instead.
414
+ echo ${NO_SLOTS_MARKER}
415
+ echo "no desktop slots available" >&2
416
+ exit ${NO_SLOTS_EXIT}
417
+ fi
418
+ VICTIM_BOT=$(dirname "$VICTIM")
419
+ if [ -s "$VICTIM_BOT/viewer-token" ]; then
420
+ VICTIM_TOKEN=$(cat "$VICTIM_BOT/viewer-token")
421
+ VTMP=$(mktemp "$ROOT/tokens.XXXXXX")
422
+ grep -v "^$VICTIM_TOKEN:" "$ROOT/tokens" > "$VTMP" || true
423
+ chmod 600 "$VTMP"
424
+ mv "$VTMP" "$ROOT/tokens"
425
+ fi
426
+ rm -f "$VICTIM" "$VICTIM_BOT/cdp-port"
427
+ fi
428
+ printf '%s\n' "$SLOT" > "$BOT/slot"
429
+ fi
430
+ # Marks this tenant as the most recent holder of its slot, which is the order
431
+ # the reclaim above walks, and records that the provider has just opened it —
432
+ # the registry entry the reclaim reads to decide whether a tenant is live.
433
+ touch "$BOT/slot" "$BOT/last-seen"
434
+ SLOT=$(cat "$BOT/slot")
435
+ printf '%s\n' "$((9222 + SLOT))" > "$BOT/cdp-port"
436
+ if [ ! -s "$BOT/vnc-password" ]; then
437
+ umask 077
438
+ head -c 32 /dev/urandom | base64 | tr -d '\n=+/' > "$BOT/vnc-password"
439
+ fi
440
+ if [ ! -s "$BOT/viewer-token" ]; then
441
+ umask 077
442
+ head -c 36 /dev/urandom | base64 | tr -d '\n=+/' > "$BOT/viewer-token"
443
+ fi
444
+ TOKEN=$(cat "$BOT/viewer-token")
445
+ TMP=$(mktemp "$ROOT/tokens.XXXXXX")
446
+ grep -v "^$TOKEN:" "$ROOT/tokens" > "$TMP" || true
447
+ printf '%s: 127.0.0.1:%s\n' "$TOKEN" "$((5900 + SLOT))" >> "$TMP"
448
+ chmod 600 "$TMP"
449
+ mv "$TMP" "$ROOT/tokens"
450
+ `;
451
+
452
+ /**
453
+ * A background process's log, capped at 256 KiB — the head and the tail, with
454
+ * the middle dropped.
455
+ *
456
+ * GrokBot's `box-bounded-log.mjs` keeps both ends for the same reason: a job
457
+ * that runs for an hour says what it set out to do at the start and what went
458
+ * wrong at the end, and the middle is the part nobody reads. A cap is not
459
+ * optional here — a process outlives its Turn, and an uncapped log on a
460
+ * Computer is an unbounded write to a disk the User pays for.
461
+ *
462
+ * The two halves are separate files. Composing them at read time is what makes
463
+ * the tail trimmable without ever rewriting the head, so a long-running
464
+ * process costs one bounded trim per 128 KiB rather than a rewrite per line.
465
+ */
466
+ export const boundedLogScript = `#!/usr/bin/env bash
467
+ set -eu
468
+ OUT="$1"
469
+ HEAD_BYTES=\${2:-${BOUNDED_LOG_HEAD_BYTES}}
470
+ TAIL_BYTES=\${3:-${BOUNDED_LOG_TAIL_BYTES}}
471
+ : > "$OUT.head"
472
+ : > "$OUT.tail"
473
+ HEAD_WRITTEN=0
474
+ TAIL_WRITTEN=0
475
+ while IFS= read -r LINE || [ -n "$LINE" ]; do
476
+ SIZE=$((\${#LINE} + 1))
477
+ if [ "$HEAD_WRITTEN" -lt "$HEAD_BYTES" ]; then
478
+ printf '%s\\n' "$LINE" >> "$OUT.head"
479
+ HEAD_WRITTEN=$((HEAD_WRITTEN + SIZE))
480
+ else
481
+ printf '%s\\n' "$LINE" >> "$OUT.tail"
482
+ TAIL_WRITTEN=$((TAIL_WRITTEN + SIZE))
483
+ # Counted in the shell rather than measured with stat: a subprocess per
484
+ # line would make the logger cost more than the job it is logging.
485
+ # Trimming at twice the cap keeps the work amortized — one trim per
486
+ # TAIL_BYTES written, never one per line.
487
+ if [ "$TAIL_WRITTEN" -gt $((TAIL_BYTES * 2)) ]; then
488
+ tail -c "$TAIL_BYTES" "$OUT.tail" > "$OUT.tail.tmp"
489
+ mv "$OUT.tail.tmp" "$OUT.tail"
490
+ TAIL_WRITTEN="$TAIL_BYTES"
491
+ fi
492
+ fi
493
+ done
494
+ `;
495
+
496
+ export const controlScript = `#!/usr/bin/env bash
497
+ set -eu
498
+ if [ "$1" != "--locked" ]; then
499
+ ACTION="$1"
500
+ KEY="$2"
501
+ BOT=${BOTS_ROOT}/$KEY
502
+ mkdir -p "$BOT"
503
+ if [ "$ACTION" = "assert-agent" ]; then
504
+ DESKTOP_KEY="$3"
505
+ DESKTOP=${BOTS_ROOT}/$DESKTOP_KEY
506
+ mkdir -p "$DESKTOP"
507
+ # Every guarded command reads two independently writable leases. Taking
508
+ # the shared lock first keeps their check in one fixed order and prevents
509
+ # an acquire from changing the desktop lease midway through the snapshot.
510
+ exec flock -x "$DESKTOP/control.lock" flock -x "$BOT/control.lock" "$0" --locked "$@"
511
+ fi
512
+ exec flock -x "$BOT/control.lock" "$0" --locked "$@"
513
+ fi
514
+ shift
515
+ ACTION="$1"
516
+ KEY="$2"
517
+ BOT=${BOTS_ROOT}/$KEY
518
+ LEASE="$BOT/human-control"
519
+ if [ "$ACTION" = "assert-agent" ]; then
520
+ DESKTOP_KEY="$3"
521
+ OWNER="$4"
522
+ MAX_AGE="$5"
523
+ else
524
+ OWNER="$3"
525
+ MAX_AGE="$4"
526
+ fi
527
+ current_owner() { sed -n '1p' "$1" 2>/dev/null || true; }
528
+ is_fresh() {
529
+ CANDIDATE="$1"
530
+ [ -e "$CANDIDATE" ] || return 1
531
+ NOW=$(date +%s)
532
+ CHANGED=$(stat -c %Y "$CANDIDATE")
533
+ [ $((NOW - CHANGED)) -le "$MAX_AGE" ]
534
+ }
535
+ assert_available() {
536
+ CANDIDATE="$1"
537
+ EXISTING=$(current_owner "$CANDIDATE")
538
+ if [ -n "$EXISTING" ] && [ "$EXISTING" != "$OWNER" ]; then
539
+ if is_fresh "$CANDIDATE"; then echo "This Computer's control lease is held by $EXISTING" >&2; exit 73; fi
540
+ rm -f "$CANDIDATE"
541
+ fi
542
+ }
543
+ case "$ACTION" in
544
+ assert-agent)
545
+ assert_available "$LEASE"
546
+ assert_available "${BOTS_ROOT}/$DESKTOP_KEY/human-control"
547
+ ;;
548
+ acquire)
549
+ EXISTING=$(current_owner "$LEASE")
550
+ if [ "$EXISTING" = "$OWNER" ]; then touch "$LEASE"; exit 0; fi
551
+ # The refusal names the holder: "busy" is not something a caller can act on
552
+ # and "held by <owner>" is — the owner is the task id a computerUse
553
+ # dispatch leased the desktop under.
554
+ if [ -n "$EXISTING" ] && is_fresh "$LEASE"; then echo "This Computer's control lease is held by $EXISTING" >&2; exit 73; fi
555
+ TMP=$(mktemp "$BOT/human-control.XXXXXX")
556
+ printf '%s\n' "$OWNER" > "$TMP"
557
+ chmod 600 "$TMP"
558
+ mv "$TMP" "$LEASE"
559
+ ;;
560
+ renew)
561
+ [ "$(current_owner "$LEASE")" = "$OWNER" ] || { echo "Human control lease owner changed" >&2; exit 73; }
562
+ touch "$LEASE"
563
+ ;;
564
+ release)
565
+ if [ "$(current_owner "$LEASE")" = "$OWNER" ]; then rm -f "$LEASE"; fi
566
+ ;;
567
+ *) echo "unknown control action" >&2; exit 64;;
568
+ esac
569
+ `;
570
+
571
+ export const browserHelper = `import { chromium } from "playwright-core";
572
+ const port = Number(process.argv[2]);
573
+ const action = JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8"));
574
+ const browser = await chromium.connectOverCDP(\`http://127.0.0.1:\${port}\`);
575
+ const context = browser.contexts()[0];
576
+ const pages = context.pages();
577
+ const page = pages.at(-1) ?? await context.newPage();
578
+ // box-doctor's browser-identity measurement (parity row 34b). It answers
579
+ // before any navigation and before the snapshot, so the check reads what the
580
+ // browser presents without moving the page a human or a Bot left open.
581
+ if (action.action === "identity") {
582
+ const identity = await page.evaluate(() => ({
583
+ userAgent: navigator.userAgent,
584
+ webdriver: navigator.webdriver === true,
585
+ brands: (navigator.userAgentData?.brands ?? []).map((brand) => \`\${brand.brand}/\${brand.version}\`),
586
+ }));
587
+ console.log(JSON.stringify(identity));
588
+ await browser.close();
589
+ process.exit(0);
590
+ }
591
+ if (action.action === "navigate") await page.goto(action.url, { waitUntil: "domcontentloaded" });
592
+ if (action.action === "click") await page.getByRole(action.role, { name: action.name, exact: action.exact ?? false }).click();
593
+ if (action.action === "fill") await page.getByLabel(action.label, { exact: action.exact ?? false }).fill(action.text);
594
+ if (action.action === "press") await page.keyboard.press(action.key);
595
+ if (action.action === "wait") await page.waitForTimeout(action.milliseconds ?? 1000);
596
+ const snapshot = await page.locator("body").ariaSnapshot({ timeout: 10000 });
597
+ console.log(JSON.stringify({ url: page.url(), title: await page.title(), snapshot }));
598
+ await browser.close();
599
+ `;
600
+
601
+ export const syncWatchScript = `#!/usr/bin/env bash
602
+ set -eu
603
+ DATA=${DATA_ROOT}
604
+ STATE=${RUNTIME_ROOT}/sync
605
+ mkdir -p "$STATE"
606
+ SIGNAL="$STATE/signal"
607
+ STAMP="$STATE/.stamp"
608
+ [ -f "$SIGNAL" ] || printf '0\n' > "$SIGNAL"
609
+ [ -f "$STAMP" ] || touch "$STAMP"
610
+ while true; do
611
+ CHANGED=$(find "$DATA" -type f -newer "$STAMP" ! -path "*/.frockbot-sync/*" ! -path "*/.frockbot-locks/*" -print -quit 2>/dev/null || true)
612
+ if [ -n "$CHANGED" ]; then
613
+ touch "$STAMP"
614
+ printf '%s\n' "$(( $(cat "$SIGNAL" 2>/dev/null || echo 0) + 1 ))" > "$SIGNAL"
615
+ fi
616
+ sleep 5
617
+ done
618
+ `;
619
+
620
+ /** The port the noVNC gateway serves on, and the port box-doctor probes. */
621
+ export const DESKTOP_GATEWAY_PORT = 6080;
622
+
623
+ export const gatewayScript = `#!/usr/bin/env bash
624
+ set -eu
625
+ exec websockify --web=/usr/share/novnc --token-plugin TokenFile --token-source=${RUNTIME_ROOT}/tokens ${DESKTOP_GATEWAY_PORT}
626
+ `;
627
+
628
+ /** Where box-doctor is installed, and the log a human reads it back from. */
629
+ export const DOCTOR_SCRIPT = `${RUNTIME_ROOT}/box-doctor.sh`;
630
+ /** GrokBot's path, kept: `/tmp/box-doctor.log` (`grokbot-computer.md:396`). */
631
+ export const DOCTOR_LOG = "/tmp/box-doctor.log";
632
+ /** Prefixes the one line of the run that is the machine-readable report. */
633
+ export const DOCTOR_MARKER = "__FROCKBOT_DOCTOR__";
634
+ /**
635
+ * The report schema box-doctor prints. Bumped, never migrated.
636
+ *
637
+ * 2 adds `browserIdentity` (parity row 34b). A Computer provisioned before
638
+ * this bump prints 1, which the decoder refuses — deliberately: the script is
639
+ * reinstalled on the next open, and a half-read report is worse than a
640
+ * Computer that says it has nothing to say yet.
641
+ */
642
+ export const DOCTOR_REPORT_SCHEMA_VERSION = 2;
643
+
644
+ /**
645
+ * What box-doctor asks the browser, base64url as `browser.mjs` takes it.
646
+ *
647
+ * A literal rather than an encode at module load: this module builds shell
648
+ * documents in a Worker, where `Buffer` is not a given, and the encoding is
649
+ * asserted against the decode in `runtime.test.ts`.
650
+ */
651
+ export const DOCTOR_BROWSER_IDENTITY_ACTION = "eyJhY3Rpb24iOiJpZGVudGl0eSJ9";
652
+ /** Log lines kept in `/tmp/box-doctor.log` before the oldest are dropped. */
653
+ export const DOCTOR_LOG_MAX_LINES = 500;
654
+ /**
655
+ * The earliest wall clock a healthy Computer can report: 2026-09-01.
656
+ *
657
+ * A container whose clock has reset reads as some point in 1970, and every
658
+ * lease, every generation timestamp, and every `capturedAt` on it is then
659
+ * wrong in a way nothing downstream can detect. There is nothing on the box to
660
+ * check a clock against, so the check is a floor rather than a comparison.
661
+ */
662
+ export const CLOCK_FLOOR_EPOCH = 1_756_684_800;
663
+
664
+ /**
665
+ * The version of the shipped reference set (parity row 27).
666
+ *
667
+ * It exists because provisioning short-circuits: a Computer that has been
668
+ * provisioned is adopted, and the provisioning document never runs on it
669
+ * again, so a here-doc README written at provisioning time could never be
670
+ * corrected. The version is compared on every adoption instead, and the whole
671
+ * set is rewritten when it moves. Bump it whenever a document below changes.
672
+ */
673
+ export const REFERENCE_DOCS_VERSION = "2026-09-01.1";
674
+
675
+ /**
676
+ * What a Bot reads to debug its own Computer.
677
+ *
678
+ * GrokBot ships `reference/{debugging-the-box.md, app-ui.md}` for exactly this
679
+ * (`grokbot-computer.md:65`): documents the harness wrote, not the agent, that
680
+ * answer "where does this live" and "what do I run" without a round trip
681
+ * through a human. These four cover the layout, the browser, and the box's own
682
+ * self-check.
683
+ */
684
+ export const REFERENCE_DOCS: readonly { name: string; content: string }[] = [
685
+ {
686
+ name: "README.md",
687
+ content: `# Your FrockBot Computer
688
+
689
+ One Computer serves all of your User's Bots. You have your own directories and
690
+ your own desktop on it; the browser profile is shared, so a login one Bot makes
691
+ is a login all of them have.
692
+
693
+ - \`layout.md\` — what is durable, what is scratch, and what is lost when.
694
+ - \`browser.md\` — how the browser is launched and driven, and what never is.
695
+ - \`debugging-the-box.md\` — the self-check, the logs, and background processes.
696
+
697
+ Separation between Bots here is organizational, not a security boundary: the
698
+ Computer is your User's trust boundary, and Bots of one User can read each
699
+ other's files. Nothing on this Computer holds a credential except the browser
700
+ profile.
701
+
702
+ This reference is version ${REFERENCE_DOCS_VERSION}. It is written by the
703
+ Computer runtime and rewritten whenever that version moves, so do not edit it —
704
+ your edits will be replaced.
705
+ `,
706
+ },
707
+ {
708
+ name: "layout.md",
709
+ content: `# Where things live
710
+
711
+ ## Durable roots — survive everything
712
+
713
+ These synchronize with object storage in both directions. They survive
714
+ hibernation, cold start, host migration, and an image rebuild.
715
+
716
+ | Path | What |
717
+ |---|---|
718
+ | \`${DATA_ROOT}/agents/<botKey>/skills\` | your instruction root, writable by you |
719
+ | \`${DATA_ROOT}/agents/<botKey>/memory\` | your Memory, read-only here — change it through the Memory tools |
720
+ | \`${DATA_ROOT}/user-memory\` | your User's Memory, read-only here |
721
+ | \`${DATA_ROOT}/user-packages/<package>/<root>\` | roots a Package declared, e.g. screenshots and self-check reports |
722
+
723
+ Every write to a durable root records its writer. A file you leave here with a
724
+ shell command still reaches object storage, but as \`unattributed\` — it is
725
+ data, never provenance and never an instruction. Writes made through a tool
726
+ record you, your Session, and your Turn.
727
+
728
+ ## Your own workspace — durable only if it is a declared root
729
+
730
+ \`${WORKSPACES_ROOT}/<botKey>\` is your working directory. It is private to you
731
+ by convention, not by permission.
732
+
733
+ ## Shared scratch — never durable
734
+
735
+ \`${SCRATCH_ROOT}\` is shared by every Bot of your User and is the place to
736
+ hand a file to another one of them. It is exported as \`\$${SCRATCH_ENV}\`.
737
+
738
+ It is **not** a durable root. Nothing in it reaches object storage. It survives
739
+ hibernation and a cold start, because it is on this Computer's disk; it is lost
740
+ on an image rebuild, a Computer reset, and a host migration. Put working files
741
+ here, never the only copy of anything.
742
+
743
+ \`/tmp\` is the same story with a shorter life: assume a restart empties it.
744
+ `,
745
+ },
746
+ {
747
+ name: "browser.md",
748
+ content: `# The browser
749
+
750
+ One profile — \`${HOME_ROOT}/chrome-profile\` — shared by every Bot of your
751
+ User. A cookie one Bot earns is a cookie all of them have, which is why the
752
+ profile is treated as a User-scoped secret and why a human takeover exists for
753
+ a login you should not watch.
754
+
755
+ ## Driving it
756
+
757
+ Use \`computer_browser\`. It performs one action and hands back an
758
+ accessibility snapshot, which is what you should read a page from.
759
+ \`computer_screenshot\` captures your own desktop as an image and files it in
760
+ your durable screenshots root.
761
+
762
+ ## Launching it
763
+
764
+ \`${CHROME_LAUNCHER} <botKey>\` is the only sanctioned launcher. It derives
765
+ your display and your CDP port from your desktop slot and holds the flag set;
766
+ the desktop starter calls it, and nothing else needs to know the flags exist.
767
+
768
+ ## What is never run from the shell
769
+
770
+ ${COMPUTER_GUI_SHELL_COMMANDS.map((name) => `\`${name}\``).join(", ")}.
771
+
772
+ A \`computer_exec\` naming one of them is refused, and each has a shim in
773
+ \`${SHIMS_ROOT}\` — which leads your \`PATH\` — that prints the same refusal
774
+ and exits 64. Neither is a
775
+ security boundary — a shell can defeat both in one line, and this Computer is
776
+ your User's trust boundary anyway. They exist so the sanctioned path is the
777
+ easy one: a GUI driven from a shell leaves no record of who did what, and the
778
+ tools do.
779
+ `,
780
+ },
781
+ {
782
+ name: "debugging-the-box.md",
783
+ content: `# Debugging this Computer
784
+
785
+ ## The self-check
786
+
787
+ \`computer_doctor\` runs \`${DOCTOR_SCRIPT}\` and hands back a report: disk on
788
+ \`/\` and \`${HOME_ROOT}\`, the size of \`${SCRATCH_ROOT}\`, the viewer
789
+ gateway, the durable-root watcher, your display and CDP port, the browser and
790
+ its profile, the sync signal and any conflicting generations, this reference
791
+ set's version, the launcher and its shims, the clock, DNS, and whether a
792
+ provisioning hold is still keeping this Computer awake.
793
+
794
+ Every run also appends to \`${DOCTOR_LOG}\`:
795
+
796
+ [box-doctor] PASS <name>: <detail>
797
+ [box-doctor] FAIL <name>: <detail>
798
+ [box-doctor] SUMMARY <n> checks, <n> passed, <n> failed
799
+
800
+ The log keeps the last ${DOCTOR_LOG_MAX_LINES} lines, so it is a history of the
801
+ Computer rather than of one run.
802
+
803
+ ## When a check fails
804
+
805
+ - **disk** — prune \`${SCRATCH_ROOT}\` first: nothing in it is durable.
806
+ - **sync-signal with conflicts** — a write landed on a generation its writer
807
+ had not seen. The conflicting generation is preserved, never merged; say so
808
+ rather than resolving it silently.
809
+ - **tenant-display** — your desktop is gone. Ask for it again; slots are
810
+ allocated on demand and a Computer with all hundred in use will say so.
811
+ - **reference-docs** — this set is stale and refreshes when the Computer is
812
+ next opened. Nothing you can do on the box fixes it.
813
+ - **browser** — the browser build is missing. It is installed by provisioning,
814
+ not by a package manager, so there is nothing to apt-get; say so.
815
+ - **sprite-hold** — a provisioning hold is still registered, so this Computer
816
+ cannot pause and is being paid for while idle. Worth reporting.
817
+
818
+ ## Background work
819
+
820
+ \`computer_exec{background:true}\` returns a \`processId\` and keeps running
821
+ after your Turn ends. Check it with \`computer_process_check\`, read it with
822
+ \`computer_process_logs\`, end it with \`computer_process_stop\`. Do not poll
823
+ it in a loop.
824
+
825
+ Nothing keeps this Computer awake for a background process. If it hibernates
826
+ first, the process is gone and its outcome is reported as \`unknown\` — never
827
+ as running. Its log keeps its first and last 128 KiB; the middle of a long run
828
+ is dropped.
829
+
830
+ ## Logs on the box
831
+
832
+ \`${DOCTOR_LOG}\`, and per-Bot under \`${BOTS_ROOT}/<botKey>\`:
833
+ \`chromium.log\`, \`fluxbox.log\`, \`x11vnc.log\`, and \`processes/<id>/log.*\`.
834
+ Provisioning's own log is \`${RUNTIME_ROOT}/provision/provision.log\`.
835
+ `,
836
+ },
837
+ ];
838
+
839
+ /** Files the reference phase owns, in the order it installs them. */
840
+ export const REFERENCE_RUNTIME_FILES: readonly {
841
+ readonly path: string;
842
+ readonly content: string;
843
+ readonly mode: number;
844
+ }[] = [
845
+ ...REFERENCE_DOCS.map((document) => ({
846
+ path: `${REFERENCE_ROOT}/${document.name}`,
847
+ content: document.content,
848
+ mode: 0o644,
849
+ })),
850
+ {
851
+ path: `${REFERENCE_ROOT}/.version`,
852
+ content: `${REFERENCE_DOCS_VERSION}\n`,
853
+ mode: 0o644,
854
+ },
855
+ ];
856
+
857
+ const referenceFilesInstallScript = `mkdir -p ${REFERENCE_ROOT}
858
+ ${installDeclaredFiles(REFERENCE_RUNTIME_FILES)}`;
859
+
860
+ /**
861
+ * Rewrites the shipped reference set when its version has moved.
862
+ *
863
+ * Guarded by the version file and by nothing else, so it is safe to run on
864
+ * every adoption: an up-to-date Computer costs one `cat`. The write is a
865
+ * rename, so a Bot reading a document never sees half of one.
866
+ */
867
+ export const referenceInstallScript = `mkdir -p ${REFERENCE_ROOT}
868
+ if [ "$(cat ${REFERENCE_ROOT}/.version 2>/dev/null || true)" != ${shellQuote(REFERENCE_DOCS_VERSION)} ]; then
869
+ ${referenceFilesInstallScript
870
+ .split("\n")
871
+ .slice(1)
872
+ .map((line) => ` ${line}`)
873
+ .join("\n")}
874
+ fi`;
875
+
876
+ export function shellQuote(value: string): string {
877
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
878
+ }
879
+
880
+ export function base64(value: string): string {
881
+ return Buffer.from(value).toString("base64");
882
+ }
883
+
884
+ export function installFile(path: string, content: string): string {
885
+ return `printf %s ${shellQuote(base64(content))} | base64 -d > ${path}`;
886
+ }
887
+
888
+ /**
889
+ * The same install, through a rename.
890
+ *
891
+ * `>` truncates in place, which is fine during provisioning — nothing is
892
+ * reading these files yet — and wrong on a live Computer, where a script being
893
+ * refreshed may be the script a running process is reading. A rename swaps the
894
+ * name and leaves the old inode to whoever holds it.
895
+ */
896
+ export function installFileAtomic(path: string, content: string): string {
897
+ return `${installFile(`${path}.tmp`, content)} && mv ${path}.tmp ${path}`;
898
+ }
899
+
900
+ /**
901
+ * Where the detached provisioner keeps everything about one provisioning run.
902
+ *
903
+ * A Computer is provisioned by a process that outlives the connection that
904
+ * started it (ADR 0004): `@fly/sprites@0.1.0` declares a WebSocket dead after
905
+ * `WS_PONG_WAIT` (45 s) without an inbound message and never sends a ping of
906
+ * its own, so no exec may be quiet for that long. `apt-get` is quiet for
907
+ * minutes. The provisioner therefore runs under `setsid nohup` behind a
908
+ * `flock`, and the host learns about it from these files through short exec
909
+ * calls that answer immediately.
910
+ */
911
+ export const PROVISION_ROOT = `${RUNTIME_ROOT}/provision`;
912
+ /** The provisioning document itself, installed by the launcher. */
913
+ export const PROVISION_SCRIPT = `${PROVISION_ROOT}/provision.sh`;
914
+ /** One JSON line: which phase the provisioner is on, and how it is going. */
915
+ export const PROVISION_STATE = `${PROVISION_ROOT}/state.json`;
916
+ /** The sha-256 of the runtime document this Computer last completed. */
917
+ export const PROVISION_DIGEST = `${PROVISION_ROOT}/digest`;
918
+ /** Everything the provisioner and its `apt-get` wrote, for a failure report. */
919
+ export const PROVISION_LOG = `${PROVISION_ROOT}/provision.log`;
920
+ /** Held for the life of a run, so "is it still going?" is not a pid guess. */
921
+ export const PROVISION_LOCK = `${PROVISION_ROOT}/provision.lock`;
922
+ /**
923
+ * One file per completed phase.
924
+ *
925
+ * This is the marker that makes a half-provisioned Computer resumable: a run
926
+ * that starts again skips every phase whose marker is already there, so a
927
+ * container restart or a dropped connection costs the remaining phases and
928
+ * never the whole install.
929
+ */
930
+ export const PROVISION_MARKERS = `${PROVISION_ROOT}/phases`;
931
+
932
+ /** Prefix the report tail uses to say whether a provisioner is still alive. */
933
+ export const PROVISION_RUNNER_PREFIX = "frockbot-provision-runner:";
934
+
935
+ /**
936
+ * The Sprite's own management socket, and the task that holds it awake.
937
+ *
938
+ * A detached provisioner does not keep its Sprite running. Sprites define
939
+ * activity as "a command running, a session producing output, an open TCP
940
+ * connection to its URL, a service handling traffic" — a `setsid nohup`
941
+ * background process is none of those, so the platform is free to pause the
942
+ * VM while `apt-get` is mid-download and resume it when the host's next poll
943
+ * arrives. Measured on 2026-09-01 against a disposable Sprite: with nothing
944
+ * holding it up, the Sprite's own clock advanced ~4 minutes while ~25 minutes
945
+ * of wall time passed, so provisioning ran at roughly a seventh of its speed
946
+ * and no package list could have fitted inside the ten-minute bound.
947
+ *
948
+ * The documented hold is the Tasks API on `/.sprite/api.sock`: "Register a
949
+ * task; the Sprite stays up. Delete it (or let it expire); the Sprite is free
950
+ * to pause again." The task is registered with a short expiry and refreshed
951
+ * from a child process, so a provisioner that dies without cleaning up stops
952
+ * paying for the Sprite within the expiry rather than pinning it awake.
953
+ *
954
+ * @see https://docs.sprites.dev/keeping-sprites-running/
955
+ */
956
+ export const SPRITE_API_SOCKET = "/.sprite/api.sock";
957
+ /** The name the provisioner's keepalive task holds. */
958
+ export const PROVISION_TASK = "frockbot-provision";
959
+ /** Short enough that a crashed provisioner releases the Sprite on its own. */
960
+ export const PROVISION_TASK_EXPIRY = "5m";
961
+ /** Four refreshes inside one expiry, the interval the Sprites docs recommend. */
962
+ export const PROVISION_TASK_REFRESH_SECONDS = 60;
963
+
964
+ /**
965
+ * PATH repair, run before anything in the provisioning document shells out.
966
+ *
967
+ * `/.sprite/bin/node` (and `npm`, and `npx`) is not the binary: it is a bash
968
+ * shim that sources `nvm.sh`, activates the default toolchain, and re-execs.
969
+ * Its last resort for locating the real binary is `command -v node`, which in
970
+ * a non-login shell resolves to the shim itself — so it re-execs itself for
971
+ * ever. Measured on a real Sprite: a detached `node --version` forked
972
+ * endlessly and never returned, which would have hung the browser phase the
973
+ * way `apt-get` hung the packages phase.
974
+ *
975
+ * The real toolchain directories are declared, one per line, in
976
+ * `/etc/profile.d/languages_paths`. Putting them on PATH first means every
977
+ * `node` and `npm` in this document is a binary rather than a shim, and the
978
+ * document keeps working unchanged if the file is ever absent.
979
+ */
980
+ export const provisionPathPreamble = `if [ -r /etc/profile.d/languages_paths ]; then
981
+ PATH="$(tr '\\n' ':' < /etc/profile.d/languages_paths)$PATH"
982
+ export PATH
983
+ fi`;
984
+
985
+ /**
986
+ * The Computer's self-check (parity row 27).
987
+ *
988
+ * GrokBot runs `box-doctor` at startup and on demand and leaves
989
+ * `[box-doctor] PASS|FAIL <name>: <detail>` lines plus a `SUMMARY` in
990
+ * `/tmp/box-doctor.log`; both are kept here, because the log is what a human
991
+ * reads over a Computer's life and the JSON is what a tool returns for one
992
+ * run. It is a provisioned script rather than a host `service`: a service
993
+ * answers `running|unavailable` and is reattached after a pause, and neither
994
+ * is what a report is.
995
+ *
996
+ * It is read-only by construction — every check reads, none repairs — which is
997
+ * why `computer_doctor` is exempt from recording durable intent.
998
+ *
999
+ * Arguments: the tenant's Bot key, and the Computer's provisioning generation
1000
+ * as the host last reported it. Both are optional; a report with generation 0
1001
+ * is a report nobody told which Computer it was on.
1002
+ */
1003
+ export const boxDoctorScript = `#!/usr/bin/env bash
1004
+ set -u
1005
+ KEY="\${1:-\${FROCKBOT_BOT_KEY:-}}"
1006
+ GENERATION="\${2:-0}"
1007
+ case "$GENERATION" in (''|*[!0-9]*) GENERATION=0;; esac
1008
+ LOG=${DOCTOR_LOG}
1009
+ NOW=$(date -u +%s)
1010
+ CAPTURED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1011
+ CHECKS=""
1012
+ PASSED=0
1013
+ FAILED=0
1014
+ touch "$LOG" 2>/dev/null || true
1015
+ # The log outlives every run on it, so it is trimmed at the start of one
1016
+ # rather than left to grow for the life of the Computer.
1017
+ if [ -s "$LOG" ]; then
1018
+ KEPT=$(tail -n ${DOCTOR_LOG_MAX_LINES} "$LOG" 2>/dev/null || true)
1019
+ printf '%s\\n' "$KEPT" > "$LOG" 2>/dev/null || true
1020
+ fi
1021
+ record() {
1022
+ NAME="$1"
1023
+ STATUS="$2"
1024
+ DETAIL=$(printf '%s' "$3" | tr -d '"\\\\' | tr '\\n\\t' ' ')
1025
+ if [ "$STATUS" = pass ]; then
1026
+ PASSED=$((PASSED + 1))
1027
+ LABEL=PASS
1028
+ else
1029
+ FAILED=$((FAILED + 1))
1030
+ LABEL=FAIL
1031
+ fi
1032
+ printf '[box-doctor] %s %s: %s\\n' "$LABEL" "$NAME" "$DETAIL" >> "$LOG" 2>/dev/null || true
1033
+ CHECKS="\${CHECKS:+$CHECKS,}$(printf '{"name":"%s","status":"%s","detail":"%s"}' "$NAME" "$STATUS" "$DETAIL")"
1034
+ }
1035
+ disk() {
1036
+ LINE=$(df -P "$2" 2>/dev/null | tail -n 1 | tr -s ' ')
1037
+ if [ -z "$LINE" ]; then
1038
+ record "$1" fail "no filesystem is mounted at $2"
1039
+ return
1040
+ fi
1041
+ USED=$(printf '%s' "$LINE" | cut -d' ' -f5 | tr -d '%')
1042
+ FREE=$(printf '%s' "$LINE" | cut -d' ' -f4)
1043
+ case "$USED" in (''|*[!0-9]*) record "$1" fail "df reported \\"$LINE\\" for $2"; return;; esac
1044
+ if [ "$USED" -ge 95 ]; then
1045
+ record "$1" fail "$2 is $USED% full, $FREE KiB free"
1046
+ else
1047
+ record "$1" pass "$2 is $USED% full, $FREE KiB free"
1048
+ fi
1049
+ }
1050
+ disk disk-root /
1051
+ disk disk-home ${HOME_ROOT}
1052
+ # The shared scratch, and the first thing to prune when a disk check fails:
1053
+ # nothing in it is durable, so nothing in it is lost that was not already
1054
+ # expendable.
1055
+ if [ ! -d ${SCRATCH_ROOT} ]; then
1056
+ record scratch fail "${SCRATCH_ROOT} is missing; the shared scratch is created at provisioning"
1057
+ elif [ ! -w ${SCRATCH_ROOT} ]; then
1058
+ record scratch fail "${SCRATCH_ROOT} is not writable by $(id -un)"
1059
+ else
1060
+ record scratch pass "${SCRATCH_ROOT} holds $(du -sxm ${SCRATCH_ROOT} 2>/dev/null | cut -f1) MiB of shared scratch, none of it durable"
1061
+ fi
1062
+ if (exec 3<>/dev/tcp/127.0.0.1/${DESKTOP_GATEWAY_PORT}) 2>/dev/null; then
1063
+ record desktop-gateway pass "the viewer gateway is listening on ${DESKTOP_GATEWAY_PORT}"
1064
+ else
1065
+ record desktop-gateway fail "nothing is listening on ${DESKTOP_GATEWAY_PORT}; no desktop can be viewed"
1066
+ fi
1067
+ STAMP=${RUNTIME_ROOT}/sync/.stamp
1068
+ if pgrep -f watch-workspace.sh >/dev/null 2>&1; then
1069
+ if [ -f "$STAMP" ]; then
1070
+ record sync-watcher pass "the durable-root watcher is running; its stamp is $((NOW - $(stat -c %Y "$STAMP"))) s old"
1071
+ else
1072
+ record sync-watcher fail "the durable-root watcher is running but has written no stamp at $STAMP"
1073
+ fi
1074
+ else
1075
+ record sync-watcher fail "no durable-root watcher is running; on-Computer writes will not signal a sync"
1076
+ fi
1077
+ SLOT=""
1078
+ if [ -n "$KEY" ] && [ -s ${BOTS_ROOT}/"$KEY"/slot ]; then SLOT=$(cat ${BOTS_ROOT}/"$KEY"/slot); fi
1079
+ if [ -z "$KEY" ]; then
1080
+ record tenant-display pass "no Bot key was named, so no desktop was checked"
1081
+ elif [ -z "$SLOT" ]; then
1082
+ record tenant-display pass "Bot \\"$KEY\\" holds no desktop slot; its exec and file surfaces need no screen"
1083
+ elif (exec 3<>/dev/tcp/127.0.0.1/$((9222 + SLOT))) 2>/dev/null; then
1084
+ record tenant-display pass "Bot \\"$KEY\\" is on display :$((100 + SLOT)) with CDP on $((9222 + SLOT))"
1085
+ elif [ ! -e "/tmp/.X$((100 + SLOT))-lock" ]; then
1086
+ # A slot is reserved at attach; the desktop starts when somebody asks to see
1087
+ # it. An exec-only tenant never holds an X lock, so this is a healthy state
1088
+ # and not a missing screen.
1089
+ record tenant-display pass "Bot \\"$KEY\\" holds slot $SLOT with no desktop running, which its exec and file surfaces do not need"
1090
+ else
1091
+ record tenant-display fail "Bot \\"$KEY\\" has an X server on display :$((100 + SLOT)) but nothing answers CDP on $((9222 + SLOT)); its desktop is only half up"
1092
+ fi
1093
+ if [ -x ${CHROMIUM_PATH} ]; then
1094
+ record browser pass "the browser is installed at ${CHROMIUM_PATH} ($(readlink -f ${CHROMIUM_PATH} 2>/dev/null || echo unresolved))"
1095
+ else
1096
+ record browser fail "no browser at ${CHROMIUM_PATH}; provisioning installs one, and no desktop can start without it"
1097
+ fi
1098
+ PROFILE=${HOME_ROOT}/chrome-profile
1099
+ if [ -d "$PROFILE" ] && [ -w "$PROFILE" ]; then
1100
+ record browser-profile pass "the shared browser profile at $PROFILE is writable"
1101
+ else
1102
+ record browser-profile fail "the shared browser profile at $PROFILE is missing or not writable"
1103
+ fi
1104
+ # What the browser announces itself as (parity row 34b).
1105
+ #
1106
+ # Measured, not governed: the register declines UA pinning and per-site
1107
+ # fingerprint profiles, and keeps this, because "does our browser announce
1108
+ # itself as a robot" was an assumption nobody had checked and it costs one
1109
+ # CDP round trip to make it a recorded fact. A FAIL means a tell was found —
1110
+ # a HeadlessChrome token or navigator.webdriver — and the fix is one entry in
1111
+ # the flag list the launcher already holds.
1112
+ #
1113
+ # A browser that is not running is not a failure. There is nothing to ask, so
1114
+ # the check passes and the report carries no measurement, which is a
1115
+ # different fact from a browser that presented no tells.
1116
+ IDENTITY=null
1117
+ if [ ! -x ${CHROMIUM_PATH} ]; then
1118
+ record browser-identity fail "no browser at ${CHROMIUM_PATH}, so nothing could be asked what it announces itself as"
1119
+ elif [ -z "$SLOT" ] || ! (exec 3<>/dev/tcp/127.0.0.1/$((9222 + SLOT))) 2>/dev/null; then
1120
+ record browser-identity pass "no browser answers CDP for this report, so nothing was asked what it announces itself as"
1121
+ else
1122
+ MEASURED=$(timeout 15 node ${RUNTIME_ROOT}/browser.mjs $((9222 + SLOT)) ${DOCTOR_BROWSER_IDENTITY_ACTION} 2>/dev/null | tail -n 1)
1123
+ case "$MEASURED" in (*'"userAgent"'*) ;; (*) MEASURED="";; esac
1124
+ if [ -z "$MEASURED" ]; then
1125
+ record browser-identity fail "a browser answers CDP on $((9222 + SLOT)) but did not say what it presents"
1126
+ else
1127
+ IDENTITY="$MEASURED"
1128
+ UA=$(printf '%s' "$MEASURED" | sed -n 's/.*"userAgent":"\\([^"]*\\)".*/\\1/p')
1129
+ BRANDS=$(printf '%s' "$MEASURED" | sed -n 's/.*"brands":\\[\\(.*\\)\\].*/\\1/p')
1130
+ TELLS=""
1131
+ case "$UA" in (*HeadlessChrome*) TELLS="a HeadlessChrome token in its user agent";; esac
1132
+ case "$MEASURED" in (*'"webdriver":true'*) TELLS="\${TELLS:+$TELLS and }navigator.webdriver true";; esac
1133
+ if [ -n "$TELLS" ]; then
1134
+ record browser-identity fail "the browser presents $TELLS; user agent $UA, brands [$BRANDS]"
1135
+ else
1136
+ record browser-identity pass "the browser presents no automation tell; user agent $UA, brands [$BRANDS]"
1137
+ fi
1138
+ fi
1139
+ fi
1140
+ SIGNAL=${RUNTIME_ROOT}/sync/signal
1141
+ CONFLICTS=$(find ${DATA_ROOT} -path '*/.frockbot-sync/conflicts/*' -type f 2>/dev/null | wc -l | tr -d ' ')
1142
+ if [ ! -f "$SIGNAL" ]; then
1143
+ record sync-signal fail "no change signal at $SIGNAL; $CONFLICTS conflicting generation(s) held"
1144
+ elif [ "$CONFLICTS" -gt 0 ]; then
1145
+ record sync-signal fail "$CONFLICTS conflicting generation(s) are held under .frockbot-sync/conflicts and need a human"
1146
+ else
1147
+ record sync-signal pass "signal $(cat "$SIGNAL" 2>/dev/null), last moved $((NOW - $(stat -c %Y "$SIGNAL"))) s ago, no conflicts"
1148
+ fi
1149
+ INSTALLED=$(cat ${REFERENCE_ROOT}/.version 2>/dev/null || echo none)
1150
+ if [ "$INSTALLED" = "${REFERENCE_DOCS_VERSION}" ]; then
1151
+ record reference-docs pass "${REFERENCE_ROOT} holds version ${REFERENCE_DOCS_VERSION}"
1152
+ else
1153
+ record reference-docs fail "${REFERENCE_ROOT} holds version $INSTALLED, not ${REFERENCE_DOCS_VERSION}; it refreshes when the Computer is next opened"
1154
+ fi
1155
+ MISSING=""
1156
+ for TOOL in ${CHROME_LAUNCHER} ${COMPUTER_GUI_SHELL_COMMANDS.map((name) => `${SHIMS_ROOT}/${name}`).join(" ")}; do
1157
+ [ -x "$TOOL" ] || MISSING="\${MISSING:+$MISSING }$TOOL"
1158
+ done
1159
+ if [ -z "$MISSING" ]; then
1160
+ record launcher pass "the launcher is installed in ${BIN_ROOT} and ${COMPUTER_GUI_SHELL_COMMANDS.length} shims in ${SHIMS_ROOT}"
1161
+ else
1162
+ record launcher fail "not executable: $MISSING"
1163
+ fi
1164
+ if [ "$NOW" -ge ${CLOCK_FLOOR_EPOCH} ]; then
1165
+ record clock pass "the clock reads $CAPTURED_AT"
1166
+ else
1167
+ record clock fail "the clock reads $CAPTURED_AT, before this Computer runtime was written"
1168
+ fi
1169
+ if getent hosts api.fly.io >/dev/null 2>&1; then
1170
+ record dns pass "api.fly.io resolves"
1171
+ else
1172
+ record dns fail "api.fly.io does not resolve; nothing on this Computer can reach the network by name"
1173
+ fi
1174
+ # The provisioner holds this Sprite awake with a Tasks-API task, because the
1175
+ # platform is otherwise free to pause a VM under a detached \`apt-get\`. The
1176
+ # hold is released on the provisioner's EXIT; one still registered afterwards
1177
+ # is a Sprite that cannot pause and is billed awake for nothing.
1178
+ if [ ! -S ${SPRITE_API_SOCKET} ]; then
1179
+ record sprite-hold pass "this Computer exposes no Sprite task API, so it holds nothing awake"
1180
+ elif curl -sS --max-time 5 --unix-socket ${SPRITE_API_SOCKET} http://sprite/v1/tasks 2>/dev/null | grep -q ${PROVISION_TASK}; then
1181
+ record sprite-hold fail "the ${PROVISION_TASK} hold is still registered; this Sprite cannot pause"
1182
+ else
1183
+ record sprite-hold pass "no provisioning hold is registered; this Computer is free to pause"
1184
+ fi
1185
+ SUMMARY="$((PASSED + FAILED)) checks, $PASSED passed, $FAILED failed"
1186
+ printf '[box-doctor] SUMMARY %s\\n' "$SUMMARY" >> "$LOG" 2>/dev/null || true
1187
+ printf '${DOCTOR_MARKER}{"schemaVersion":${DOCTOR_REPORT_SCHEMA_VERSION},"generation":%s,"capturedAt":"%s","checks":[%s],"browserIdentity":%s,"summary":"%s"}\\n' "$GENERATION" "$CAPTURED_AT" "$CHECKS" "$IDENTITY" "$SUMMARY"
1188
+ `;
1189
+
1190
+ /** Every declared file installed by the runtime phase, in install order. */
1191
+ export const COMPUTER_RUNTIME_FILES: readonly {
1192
+ readonly path: string;
1193
+ readonly content: string;
1194
+ readonly mode: number;
1195
+ }[] = [
1196
+ {
1197
+ path: `${RUNTIME_ROOT}/start-desktop.sh`,
1198
+ content: startDesktopScript,
1199
+ mode: 0o700,
1200
+ },
1201
+ { path: ENSURE_AGENT_SCRIPT, content: ensureAgentScript, mode: 0o700 },
1202
+ { path: CONTROL_SCRIPT, content: controlScript, mode: 0o700 },
1203
+ { path: BOUNDED_LOG_SCRIPT, content: boundedLogScript, mode: 0o700 },
1204
+ { path: `${RUNTIME_ROOT}/browser.mjs`, content: browserHelper, mode: 0o700 },
1205
+ {
1206
+ path: `${RUNTIME_ROOT}/start-gateway.sh`,
1207
+ content: gatewayScript,
1208
+ mode: 0o700,
1209
+ },
1210
+ {
1211
+ path: `${RUNTIME_ROOT}/watch-workspace.sh`,
1212
+ content: syncWatchScript,
1213
+ mode: 0o700,
1214
+ },
1215
+ { path: DOCTOR_SCRIPT, content: boxDoctorScript, mode: 0o755 },
1216
+ { path: CHROME_LAUNCHER, content: chromeLauncherScript, mode: 0o755 },
1217
+ ...COMPUTER_GUI_SHELL_COMMANDS.map((name) => ({
1218
+ path: `${SHIMS_ROOT}/${name}`,
1219
+ content: guiShimScript(name),
1220
+ mode: 0o755,
1221
+ })),
1222
+ ];
1223
+
1224
+ function installDeclaredFiles(
1225
+ files: readonly {
1226
+ readonly path: string;
1227
+ readonly content: string;
1228
+ readonly mode: number;
1229
+ }[],
1230
+ ): string {
1231
+ return files
1232
+ .map(
1233
+ (file) => `${installFile(`${file.path}.tmp`, file.content)}
1234
+ chmod ${file.mode.toString(8)} ${file.path}.tmp
1235
+ mv ${file.path}.tmp ${file.path}`,
1236
+ )
1237
+ .join("\n");
1238
+ }
1239
+
1240
+ /**
1241
+ * The phases of provisioning a Computer, in order.
1242
+ *
1243
+ * They are declared rather than inlined because they are three things at
1244
+ * once: the body of the provisioning script, the resume markers that let a
1245
+ * half-provisioned Computer be completed, and the progress a client reports
1246
+ * ("installing the desktop packages (2/5)") while a cold Computer opens.
1247
+ */
1248
+ export const PROVISION_PHASES: readonly {
1249
+ name: string;
1250
+ label: string;
1251
+ body: string;
1252
+ /**
1253
+ * Runs every time, marker or no marker.
1254
+ *
1255
+ * For a phase that is idempotent *and* carries its own reason to run again —
1256
+ * the versioned reference set. A phase without this is done once and skipped
1257
+ * for ever, which is what makes a half-provisioned Computer resumable.
1258
+ */
1259
+ always?: boolean;
1260
+ }[] = [
1261
+ {
1262
+ name: "layout",
1263
+ label: "preparing the Computer layout",
1264
+ body: `mkdir -p ${RUNTIME_ROOT} ${RUNTIME_ROOT}/sync ${BOTS_ROOT} ${DATA_ROOT}/agents ${DATA_ROOT}/user-memory ${DATA_ROOT}/user-packages ${BIN_ROOT} ${SHIMS_ROOT} ${REFERENCE_ROOT} ${HOME_ROOT}/chrome-profile ${WORKSPACES_ROOT} ${SCRATCH_ROOT}
1265
+ touch ${RUNTIME_ROOT}/tokens
1266
+ chmod 700 ${RUNTIME_ROOT}
1267
+ chmod 600 ${RUNTIME_ROOT}/tokens
1268
+ # The shared scratch: group-writable and owned by the Computer's user, because
1269
+ # every Bot of this User writes here and none of it is durable.
1270
+ chmod 0775 ${SCRATCH_ROOT}
1271
+ chown box:box ${SCRATCH_ROOT} 2>/dev/null || true`,
1272
+ },
1273
+ {
1274
+ name: "packages",
1275
+ label: "installing the desktop packages",
1276
+ body: `if ! command -v Xvfb >/dev/null || ! command -v x11vnc >/dev/null || ! command -v websockify >/dev/null || ! command -v scrot >/dev/null; then
1277
+ if [ "$(id -u)" = 0 ]; then SUDO=""; else SUDO="sudo"; fi
1278
+ # The base image ships a populated /var/lib/apt/lists, but a stale one: on
1279
+ # 2026-09-01 installing straight from it failed with 404s on superseded
1280
+ # libheif .debs that security.ubuntu.com no longer carries. The refresh is
1281
+ # not the expense it looked like — measured at 6 s once the Sprite is held
1282
+ # awake, against the 262 s recorded in ADR 0004 for the same command on a
1283
+ # Sprite the platform kept pausing underneath it.
1284
+ $SUDO apt-get update
1285
+ $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ${DESKTOP_PACKAGES.join(" ")}
1286
+ fi`,
1287
+ },
1288
+ {
1289
+ name: "runtime",
1290
+ label: "installing the Computer runtime",
1291
+ body: installDeclaredFiles(COMPUTER_RUNTIME_FILES),
1292
+ },
1293
+ {
1294
+ name: "browser",
1295
+ label: "installing the browser",
1296
+ body: `if [ ! -d ${RUNTIME_ROOT}/node_modules/playwright-core ]; then
1297
+ npm install --prefix ${RUNTIME_ROOT} --no-audit --no-fund playwright-core@${PLAYWRIGHT_VERSION}
1298
+ fi
1299
+ if [ ! -x ${CHROMIUM_PATH} ]; then
1300
+ # Playwright's own build, from Playwright's CDN, unpacked into the runtime
1301
+ # root: a real ELF binary with its libraries beside it, no package manager
1302
+ # and no snap involved. The driver that talks to it over CDP is the
1303
+ # \`playwright-core\` above, so the two are pinned to the same version.
1304
+ # PLAYWRIGHT_HOST_PLATFORM_OVERRIDE, because Playwright ${PLAYWRIGHT_VERSION} refuses
1305
+ # the Sprite base image outright: "Playwright does not support chromium on
1306
+ # ubuntu26.04-x64". It has no build named for that release and will not
1307
+ # guess. The build named for the newest release it does know runs on it —
1308
+ # proved on a real Sprite, headful under Xvfb with CDP answering — so this
1309
+ # names that build rather than leaving the phase to fail.
1310
+ PLAYWRIGHT_BROWSERS_PATH=${BROWSERS_ROOT} PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=${PLAYWRIGHT_PLATFORM} node ${RUNTIME_ROOT}/node_modules/playwright-core/cli.js install chromium
1311
+ CHROMIUM_BUILD=$(ls -d ${BROWSERS_ROOT}/chromium-*/chrome-linux*/chrome 2>/dev/null | head -1)
1312
+ if [ -z "$CHROMIUM_BUILD" ]; then
1313
+ echo "playwright installed no chromium build under ${BROWSERS_ROOT}" >&2
1314
+ exit 1
1315
+ fi
1316
+ # A symlink, so the version in the build's directory name stays out of
1317
+ # start-desktop.sh and an upgrade is one relink rather than a script change.
1318
+ ln -sfn "$CHROMIUM_BUILD" ${CHROMIUM_PATH}
1319
+ fi`,
1320
+ },
1321
+ {
1322
+ name: "reference",
1323
+ label: "writing the Computer reference",
1324
+ // Version-guarded rather than marker-guarded: a marker would make this
1325
+ // phase run exactly once in a Computer's life, and the whole point of a
1326
+ // versioned reference set is that a later build can correct it.
1327
+ always: true,
1328
+ body: referenceInstallScript,
1329
+ },
1330
+ ];
1331
+
1332
+ /**
1333
+ * The only phases an in-place runtime update may run.
1334
+ *
1335
+ * These atomically replace files owned by the provisioner. They never run
1336
+ * `apt`, install a browser, replace the instance, or touch `/home/box` User
1337
+ * content, the shared browser profile, or any durable root. A running Turn
1338
+ * keeps the old inode while each name is swapped, so it is not interrupted.
1339
+ * That is the Computer and Workspace rule made executable: an automatic
1340
+ * update loses nothing and cannot become an undeclared durability mechanism.
1341
+ */
1342
+ export const UPDATE_PHASES: readonly {
1343
+ readonly name: string;
1344
+ readonly label: string;
1345
+ readonly body: string;
1346
+ }[] = [
1347
+ {
1348
+ name: "runtime",
1349
+ label: "Updating the Computer runtime",
1350
+ body: PROVISION_PHASES.find((phase) => phase.name === "runtime")!.body,
1351
+ },
1352
+ {
1353
+ name: "reference",
1354
+ label: "Updating the Computer reference",
1355
+ // The document digest, not the hand-maintained reference version, is the
1356
+ // update trigger. Always rewrite these files so a one-byte source change
1357
+ // cannot be acknowledged without reaching an existing Computer.
1358
+ body: referenceFilesInstallScript,
1359
+ },
1360
+ ];
1361
+
1362
+ /** The first progress report for an in-place update. */
1363
+ export const UPDATE_STARTING_PHASE = {
1364
+ name: "starting",
1365
+ label: "Updating the Computer runtime document",
1366
+ } as const;
1367
+
1368
+ /** The phase a run reports before it has entered the first real one. */
1369
+ export const PROVISION_STARTING_PHASE = {
1370
+ name: "starting",
1371
+ label: "starting the Computer provisioner",
1372
+ } as const;
1373
+
1374
+ function provisionStateLine(
1375
+ kind: "provision" | "update",
1376
+ digest: string,
1377
+ index: number,
1378
+ total: number,
1379
+ name: string,
1380
+ label: string,
1381
+ status: string,
1382
+ ): string {
1383
+ return JSON.stringify({
1384
+ version: 1,
1385
+ kind,
1386
+ documentDigest: digest,
1387
+ index,
1388
+ total,
1389
+ phase: name,
1390
+ label,
1391
+ status,
1392
+ });
1393
+ }
1394
+
1395
+ /**
1396
+ * The provisioning document, run detached and resumable.
1397
+ *
1398
+ * Provisioning guards every phase with its marker, so running this again on a
1399
+ * half-provisioned Computer completes it rather than starting over. Update
1400
+ * mode runs only `UPDATE_PHASES`, with no markers: they are idempotent atomic
1401
+ * file installs and must run again whenever the document digest moves. Every
1402
+ * phase records where it has got to before it begins. `set -E` is what makes
1403
+ * the `ERR` trap fire from inside a function or a subshell, so a failure is
1404
+ * recorded rather than merely exiting.
1405
+ *
1406
+ * Before any of that it does the two things that make a detached run on a
1407
+ * Sprite possible at all: it holds the Sprite awake with a Tasks-API task
1408
+ * (see `SPRITE_API_SOCKET` — without it the platform pauses the VM under a
1409
+ * background `apt-get`), and it puts the real toolchain on PATH (see
1410
+ * `provisionPathPreamble` — without it `node` is a shim that re-execs itself
1411
+ * for ever). Both releases are on an `EXIT` trap, so a provisioner that fails
1412
+ * hands the Sprite back rather than pinning it awake.
1413
+ */
1414
+ export const provisionScript = `#!/usr/bin/env bash
1415
+ set -eEu
1416
+ ${provisionPathPreamble}
1417
+ KIND="\${1:-provision}"
1418
+ DIGEST="\${2:-}"
1419
+ case "$KIND:$DIGEST" in
1420
+ provision:[0-9a-f][0-9a-f]*|update:[0-9a-f][0-9a-f]*) ;;
1421
+ *) echo "provisioner needs provision|update and a runtime digest" >&2; exit 64;;
1422
+ esac
1423
+ sprite_task() {
1424
+ curl -sS --max-time 10 --unix-socket ${SPRITE_API_SOCKET} "$@" >/dev/null 2>&1 || true
1425
+ }
1426
+ sprite_task -H 'Content-Type: application/json' -X POST http://sprite/v1/tasks -d '{"name":"${PROVISION_TASK}","expire":"${PROVISION_TASK_EXPIRY}"}'
1427
+ while sleep ${PROVISION_TASK_REFRESH_SECONDS}; do
1428
+ curl -sS --max-time 10 --unix-socket ${SPRITE_API_SOCKET} -H 'Content-Type: application/json' -X PUT http://sprite/v1/tasks/${PROVISION_TASK} -d '{"expire":"${PROVISION_TASK_EXPIRY}"}' >/dev/null 2>&1 || exit 0
1429
+ done &
1430
+ KEEPALIVE=$!
1431
+ release() {
1432
+ kill "$KEEPALIVE" 2>/dev/null || true
1433
+ sprite_task -X DELETE http://sprite/v1/tasks/${PROVISION_TASK}
1434
+ }
1435
+ trap release EXIT
1436
+ MARKERS=${PROVISION_MARKERS}
1437
+ STATE=${PROVISION_STATE}
1438
+ mkdir -p "$MARKERS"
1439
+ INDEX=0
1440
+ if [ "$KIND" = update ]; then
1441
+ TOTAL=${UPDATE_PHASES.length}
1442
+ NAME=${UPDATE_STARTING_PHASE.name}
1443
+ LABEL=${shellQuote(UPDATE_STARTING_PHASE.label)}
1444
+ else
1445
+ TOTAL=${PROVISION_PHASES.length}
1446
+ NAME=${PROVISION_STARTING_PHASE.name}
1447
+ LABEL=${shellQuote(PROVISION_STARTING_PHASE.label)}
1448
+ fi
1449
+ state() {
1450
+ TMP=$(mktemp "$STATE.XXXXXX")
1451
+ printf '{"version":1,"kind":"%s","documentDigest":"%s","index":%s,"total":%s,"phase":"%s","label":"%s","status":"%s"}\\n' "$KIND" "$DIGEST" "$INDEX" "$TOTAL" "$NAME" "$LABEL" "$1" > "$TMP"
1452
+ mv "$TMP" "$STATE"
1453
+ }
1454
+ trap 'state failed' ERR
1455
+ if [ "$KIND" = update ]; then
1456
+ ${UPDATE_PHASES.map(
1457
+ (phase, position) => ` INDEX=${position + 1}
1458
+ NAME=${phase.name}
1459
+ LABEL=${shellQuote(phase.label)}
1460
+ state running
1461
+ ${phase.body}`,
1462
+ ).join("\n")}
1463
+ else
1464
+ ${PROVISION_PHASES.map(
1465
+ (phase, position) => `INDEX=${position + 1}
1466
+ NAME=${phase.name}
1467
+ LABEL=${shellQuote(phase.label)}
1468
+ state running
1469
+ ${
1470
+ phase.always
1471
+ ? phase.body
1472
+ : `if [ ! -f "$MARKERS/${phase.name}" ]; then
1473
+ ${phase.body}
1474
+ touch "$MARKERS/${phase.name}"
1475
+ fi`
1476
+ }`,
1477
+ ).join("\n")}
1478
+ fi
1479
+ INDEX=$TOTAL
1480
+ NAME=ready
1481
+ if [ "$KIND" = update ]; then
1482
+ LABEL='the Computer update is complete'
1483
+ else
1484
+ LABEL='the Computer is ready'
1485
+ fi
1486
+ state complete
1487
+ DIGEST_TMP=$(mktemp ${PROVISION_DIGEST}.XXXXXX)
1488
+ printf '%s\\n' "$DIGEST" > "$DIGEST_TMP"
1489
+ mv "$DIGEST_TMP" ${PROVISION_DIGEST}
1490
+ `;
1491
+
1492
+ /**
1493
+ * Every declared file in the runtime document, in digest order.
1494
+ *
1495
+ * The phase bodies install from `COMPUTER_RUNTIME_FILES` and
1496
+ * `REFERENCE_RUNTIME_FILES`, and the launcher installs `provisionScript`
1497
+ * itself. The digest consumes those same sources so adding a provisioned file
1498
+ * necessarily adds it here rather than creating a second hand-kept inventory.
1499
+ */
1500
+ export const RUNTIME_DOCUMENT_FILES: readonly {
1501
+ readonly path: string;
1502
+ readonly content: string;
1503
+ }[] = [
1504
+ { path: PROVISION_SCRIPT, content: provisionScript },
1505
+ ...COMPUTER_RUNTIME_FILES,
1506
+ ...REFERENCE_RUNTIME_FILES,
1507
+ ];
1508
+
1509
+ /** sha-256 in plain TypeScript, so the Worker and Node container agree. */
1510
+ function sha256HexV1(value: string): string {
1511
+ const source = new TextEncoder().encode(value);
1512
+ const paddedLength = Math.ceil((source.byteLength + 9) / 64) * 64;
1513
+ const bytes = new Uint8Array(paddedLength);
1514
+ bytes.set(source);
1515
+ bytes[source.byteLength] = 0x80;
1516
+ const bits = source.byteLength * 8;
1517
+ const view = new DataView(bytes.buffer);
1518
+ view.setUint32(paddedLength - 8, Math.floor(bits / 0x1_0000_0000));
1519
+ view.setUint32(paddedLength - 4, bits >>> 0);
1520
+
1521
+ const constants = [
1522
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
1523
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
1524
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
1525
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
1526
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
1527
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
1528
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1529
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
1530
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
1531
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
1532
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
1533
+ ] as const;
1534
+ const state = new Uint32Array([
1535
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
1536
+ 0x1f83d9ab, 0x5be0cd19,
1537
+ ]);
1538
+ const words = new Uint32Array(64);
1539
+ const rotate = (word: number, count: number) =>
1540
+ (word >>> count) | (word << (32 - count));
1541
+
1542
+ for (let offset = 0; offset < bytes.byteLength; offset += 64) {
1543
+ for (let index = 0; index < 16; index += 1) {
1544
+ words[index] = view.getUint32(offset + index * 4);
1545
+ }
1546
+ for (let index = 16; index < 64; index += 1) {
1547
+ const before = words[index - 15]!;
1548
+ const recent = words[index - 2]!;
1549
+ const s0 = rotate(before, 7) ^ rotate(before, 18) ^ (before >>> 3);
1550
+ const s1 = rotate(recent, 17) ^ rotate(recent, 19) ^ (recent >>> 10);
1551
+ words[index] = (words[index - 16]! + s0 + words[index - 7]! + s1) >>> 0;
1552
+ }
1553
+ let [a, b, c, d, e, f, g, h] = state;
1554
+ for (let index = 0; index < 64; index += 1) {
1555
+ const sum1 = rotate(e!, 6) ^ rotate(e!, 11) ^ rotate(e!, 25);
1556
+ const choose = (e! & f!) ^ (~e! & g!);
1557
+ const first =
1558
+ (h! + sum1 + choose + constants[index]! + words[index]!) >>> 0;
1559
+ const sum0 = rotate(a!, 2) ^ rotate(a!, 13) ^ rotate(a!, 22);
1560
+ const majority = (a! & b!) ^ (a! & c!) ^ (b! & c!);
1561
+ const second = (sum0 + majority) >>> 0;
1562
+ h = g;
1563
+ g = f;
1564
+ f = e;
1565
+ e = (d! + first) >>> 0;
1566
+ d = c;
1567
+ c = b;
1568
+ b = a;
1569
+ a = (first + second) >>> 0;
1570
+ }
1571
+ state[0] = (state[0]! + a!) >>> 0;
1572
+ state[1] = (state[1]! + b!) >>> 0;
1573
+ state[2] = (state[2]! + c!) >>> 0;
1574
+ state[3] = (state[3]! + d!) >>> 0;
1575
+ state[4] = (state[4]! + e!) >>> 0;
1576
+ state[5] = (state[5]! + f!) >>> 0;
1577
+ state[6] = (state[6]! + g!) >>> 0;
1578
+ state[7] = (state[7]! + h!) >>> 0;
1579
+ }
1580
+ return [...state].map((word) => word.toString(16).padStart(8, "0")).join("");
1581
+ }
1582
+
1583
+ /**
1584
+ * sha-256 over every installed runtime-document file's content, framed in a
1585
+ * fixed order so a boundary move cannot preserve the answer accidentally.
1586
+ */
1587
+ export function runtimeDocumentDigestV1(): string {
1588
+ return sha256HexV1(
1589
+ RUNTIME_DOCUMENT_FILES.map((file) => {
1590
+ const length = new TextEncoder().encode(file.content).byteLength;
1591
+ return `${length}\0${file.content}`;
1592
+ }).join(""),
1593
+ );
1594
+ }
1595
+
1596
+ /**
1597
+ * How long a provisioner waits for the run lock before giving up.
1598
+ *
1599
+ * It waits rather than refusing because the lock is probed, and a probe holds
1600
+ * it for microseconds. A provisioner that used `flock -n` would lose that race
1601
+ * every so often and die without a word — measured, and the reason this is a
1602
+ * wait and not a `-n`.
1603
+ */
1604
+ const PROVISION_LOCK_WAIT_SECONDS = 30;
1605
+
1606
+ /**
1607
+ * Installs the provisioning document and starts it detached, then reports.
1608
+ *
1609
+ * The document travels on this command's **stdin** and is installed with a
1610
+ * rename, so a provisioner that is already running keeps reading the inode it
1611
+ * opened and a second launcher cannot corrupt it. The launch itself is
1612
+ * guarded twice: `setsid nohup` so the run survives this exec session ending,
1613
+ * and `flock -n` so two launchers cannot produce two `apt-get` runs on one
1614
+ * Computer.
1615
+ */
1616
+ function launchScript(kind: "provision" | "update"): string {
1617
+ const digest = runtimeDocumentDigestV1();
1618
+ const starting =
1619
+ kind === "update" ? UPDATE_STARTING_PHASE : PROVISION_STARTING_PHASE;
1620
+ const total =
1621
+ kind === "update" ? UPDATE_PHASES.length : PROVISION_PHASES.length;
1622
+ const startingState = provisionStateLine(
1623
+ kind,
1624
+ digest,
1625
+ 0,
1626
+ total,
1627
+ starting.name,
1628
+ starting.label,
1629
+ "running",
1630
+ );
1631
+ const completeState = provisionStateLine(
1632
+ kind,
1633
+ digest,
1634
+ total,
1635
+ total,
1636
+ "ready",
1637
+ kind === "update"
1638
+ ? "the Computer update is complete"
1639
+ : "the Computer is ready",
1640
+ "complete",
1641
+ );
1642
+ const shouldRun =
1643
+ kind === "update"
1644
+ ? `[ "$(cat ${PROVISION_DIGEST} 2>/dev/null || true)" != ${digest} ]`
1645
+ : `! grep -q '"status":"complete"' ${PROVISION_STATE} 2>/dev/null`;
1646
+ const initializeState =
1647
+ kind === "update"
1648
+ ? `if ! grep -q '"kind":"update".*"status":"running"' ${PROVISION_STATE} 2>/dev/null; then
1649
+ printf '%s\\n' ${shellQuote(startingState)} > ${PROVISION_STATE}
1650
+ fi`
1651
+ : `[ -s ${PROVISION_STATE} ] || printf '%s\\n' ${shellQuote(startingState)} > ${PROVISION_STATE}`;
1652
+ return `set -eu
1653
+ mkdir -p ${PROVISION_ROOT} ${PROVISION_MARKERS}
1654
+ touch ${PROVISION_LOCK}
1655
+ # Probed once, before anything is started. A second probe after the launch
1656
+ # would contend with the provisioner it had just started.
1657
+ RUNNER=running
1658
+ if flock -n ${PROVISION_LOCK} true 2>/dev/null; then RUNNER=stopped; fi
1659
+ if [ "$RUNNER" = stopped ]; then
1660
+ ${installFile(`${PROVISION_SCRIPT}.tmp`, provisionScript)}
1661
+ chmod 700 ${PROVISION_SCRIPT}.tmp
1662
+ mv ${PROVISION_SCRIPT}.tmp ${PROVISION_SCRIPT}
1663
+ if ${shouldRun}; then
1664
+ # Only when there is nothing to keep. A relaunch resumes an install that
1665
+ # already reached a phase, and reporting it as "starting" again would make
1666
+ # a resume look like a restart to whoever is watching.
1667
+ ${initializeState}
1668
+ setsid nohup flock -w ${PROVISION_LOCK_WAIT_SECONDS} ${PROVISION_LOCK} bash ${PROVISION_SCRIPT} ${kind} ${digest} >>${PROVISION_LOG} 2>&1 </dev/null &
1669
+ RUNNER=running
1670
+ elif [ ${shellQuote(kind)} = update ]; then
1671
+ printf '%s\\n' ${shellQuote(completeState)} > ${PROVISION_STATE}
1672
+ fi
1673
+ fi
1674
+ printf '${PROVISION_RUNNER_PREFIX}%s\\n' "$RUNNER"
1675
+ cat ${PROVISION_STATE} 2>/dev/null || true
1676
+ `;
1677
+ }
1678
+
1679
+ export const provisionLaunchScript = launchScript("provision");
1680
+ export const updateLaunchScript = launchScript("update");
1681
+
1682
+ /**
1683
+ * One poll: it starts nothing and answers immediately.
1684
+ *
1685
+ * This is the command that replaces the minutes-long silent exec. It is what
1686
+ * keeps every connection to the Sprite far inside the SDK's 45-second window.
1687
+ */
1688
+ export const provisionPollScript = `set -eu
1689
+ touch ${PROVISION_LOCK} 2>/dev/null || true
1690
+ if flock -n ${PROVISION_LOCK} true 2>/dev/null; then
1691
+ printf '${PROVISION_RUNNER_PREFIX}stopped\\n'
1692
+ else
1693
+ printf '${PROVISION_RUNNER_PREFIX}running\\n'
1694
+ fi
1695
+ cat ${PROVISION_STATE} 2>/dev/null || true
1696
+ `;
1697
+
1698
+ /** The tail of the provisioner's own log, for a failure report. */
1699
+ export const provisionLogTailScript = `tail -c 2000 ${PROVISION_LOG} 2>/dev/null || true
1700
+ `;
1701
+
1702
+ /** The Sprite name pattern a Computer may take: 3-63 lowercase DNS characters. */
1703
+ export const COMPUTER_SPRITE_NAME = /^[a-z][a-z0-9-]{2,62}$/;
1704
+
1705
+ /**
1706
+ * The Sprite backing one User's Computer.
1707
+ *
1708
+ * "One Computer per User, shared by all Bots" (ADR 0012), so the name is
1709
+ * derived from the User and from nothing else. The digest is taken over a
1710
+ * JSON-encoded `["user", userId]` rather than the bare id, so a future
1711
+ * `["project", …]` key cannot collide with a User id that happens to spell the
1712
+ * same string.
1713
+ *
1714
+ * `digest` is supplied by the caller because the two runtimes that need this
1715
+ * name hash differently: Node has `node:crypto`, workerd has WebCrypto. The
1716
+ * derivation itself lives here once.
1717
+ */
1718
+ export function computerSpriteNameV1(
1719
+ userId: string,
1720
+ digestHex: string,
1721
+ baseName: string,
1722
+ ): string {
1723
+ const base = baseName.trim();
1724
+ if (!COMPUTER_SPRITE_NAME.test(base)) {
1725
+ throw new Error(
1726
+ "Computer Sprite base name must be 3-63 lowercase letters, numbers, or hyphens",
1727
+ );
1728
+ }
1729
+ if (!userId.trim()) {
1730
+ throw new Error("Computer Sprite name requires a non-empty userId");
1731
+ }
1732
+ const prefix = base.slice(0, 49).replace(/-+$/g, "");
1733
+ return `${prefix}-${digestHex.slice(0, 12)}`;
1734
+ }
1735
+
1736
+ /** What `computerSpriteNameV1` expects a digest of. */
1737
+ export function computerSpriteNameSourceV1(userId: string): string {
1738
+ return JSON.stringify(["user", userId]);
1739
+ }