@frockbot/computer-host-runtime 0.3.11 → 0.3.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/computer-host-runtime",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,9 +24,25 @@ import {
24
24
  BOTS_ROOT,
25
25
  boxDoctorScript,
26
26
  browserHelper,
27
+ BROWSER_ENSURE_ACTION,
28
+ BROWSER_FOCUS_ACTION,
29
+ BROWSER_SURVEY_ACTION,
27
30
  CHROME_LAUNCHER,
31
+ CHROME_PROFILE,
28
32
  chromeLauncherScript,
29
33
  CHROMIUM_PATH,
34
+ COMPUTER_CDP_PORT,
35
+ COMPUTER_DISPLAY,
36
+ DESKTOP_SLOTS,
37
+ ENSURE_WINDOW_SCRIPT,
38
+ FLUXBOX_ROOT,
39
+ fluxboxInit,
40
+ fluxboxOverlay,
41
+ FOCUS_WINDOW_SCRIPT,
42
+ SCREEN_WIDTH,
43
+ SLOT_HEIGHT,
44
+ SLOT_WIDTH,
45
+ TARGET_ID_FILE,
30
46
  COMPUTER_GUI_SHELL_COMMANDS,
31
47
  COMPUTER_RUNTIME_FILES,
32
48
  computerGuiRefusalV1,
@@ -608,22 +624,108 @@ describe("installed shell scripts", () => {
608
624
  // because the provisioning document is what creates it.
609
625
  expect(provisionScript).toContain(`${HOME_ROOT}/chrome-profile `);
610
626
  expect(provisionScript).not.toContain("chrome-profiles");
611
- // The flag set moved into the launcher (parity row 33); the desktop
612
- // starter calls it and holds no flags of its own.
627
+ // The flag set moved into the launcher (parity row 33); the browser
628
+ // service calls it and holds no flags of its own.
613
629
  expect(installedScript(provisionScript, CHROME_LAUNCHER)).toContain(
614
630
  `--user-data-dir=${HOME_ROOT}/chrome-profile`,
615
631
  );
616
632
  expect(
617
- installedScript(provisionScript, `${RUNTIME_ROOT}/start-desktop.sh`),
618
- ).toContain(`${CHROME_LAUNCHER} "$KEY"`);
633
+ installedScript(provisionScript, `${RUNTIME_ROOT}/start-browser.sh`),
634
+ ).toContain(`exec ${CHROME_LAUNCHER} about:blank`);
635
+ });
636
+
637
+ test("one browser holds the shared profile, and the launcher takes no Bot key", () => {
638
+ // ADR 0031. Chromium's singleton lock is per `--user-data-dir`, so a
639
+ // per-slot launch could only ever produce one browser: the first Bot to
640
+ // ask got a screen and the rest got "Opening in existing browser session"
641
+ // and a dead CDP port. One browser, one display, one port.
642
+ const launcher = installedScript(provisionScript, CHROME_LAUNCHER);
643
+ expect(launcher).toContain(`--remote-debugging-port=${COMPUTER_CDP_PORT}`);
644
+ expect(launcher).toContain(`export DISPLAY=${COMPUTER_DISPLAY}`);
645
+ expect(launcher).not.toContain("9222 + SLOT");
646
+ expect(launcher).not.toContain("100 + SLOT");
647
+ // The one thing the launcher may remove is a stale singleton file, and
648
+ // only when no browser is running. Never the profile: it is the User's
649
+ // login state, and every Bot's.
650
+ expect(launcher).toContain(`rm -f ${CHROME_PROFILE}/SingletonLock`);
651
+ expect(launcher).not.toContain(`rm -rf ${CHROME_PROFILE}`);
652
+ });
653
+
654
+ test("one screen carries every slot, and each viewer is clipped to one", () => {
655
+ const screen = installedScript(
656
+ provisionScript,
657
+ `${RUNTIME_ROOT}/start-screen.sh`,
658
+ );
659
+ expect(screen).toContain(
660
+ `Xvfb ${COMPUTER_DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SLOT_HEIGHT}x24`,
661
+ );
662
+ expect(SCREEN_WIDTH).toBe(SLOT_WIDTH * DESKTOP_SLOTS);
663
+ const view = installedScript(
664
+ provisionScript,
665
+ `${RUNTIME_ROOT}/start-view.sh`,
666
+ );
667
+ // `-clip`, never `-id`: a window id changes every time a Bot's window is
668
+ // re-created, and a VNC server bound to a dead window shows nothing.
669
+ expect(view).toContain(
670
+ `CLIP=${SLOT_WIDTH}x${SLOT_HEIGHT}+$((SLOT * ${SLOT_WIDTH}))+0`,
671
+ );
672
+ expect(view).toContain(
673
+ `exec x11vnc -display ${COMPUTER_DISPLAY} -clip "$CLIP"`,
674
+ );
675
+ expect(view).not.toContain("-id ");
676
+ });
677
+
678
+ test("fluxbox never reaches for a wallpaper setter, and hides its toolbar", () => {
679
+ // Every desktop carried an xmessage dialog reading "fbsetbg: I can't find
680
+ // an app to set the wallpaper with", because with no `~/.fluxbox` at all
681
+ // fluxbox writes its own defaults and applies the style's background.
682
+ expect(fluxboxOverlay).toContain("background: none");
683
+ expect(fluxboxInit).toContain("session.screen0.toolbar.visible: false");
684
+ expect(fluxboxInit).toContain(
685
+ `session.styleOverlay: ${FLUXBOX_ROOT}/overlay`,
686
+ );
687
+ expect(COMPUTER_RUNTIME_FILES.map((file) => file.path)).toEqual(
688
+ expect.arrayContaining([
689
+ `${FLUXBOX_ROOT}/init`,
690
+ `${FLUXBOX_ROOT}/overlay`,
691
+ ]),
692
+ );
693
+ expect(
694
+ installedScript(provisionScript, `${RUNTIME_ROOT}/start-screen.sh`),
695
+ ).toContain(`fluxbox -rc ${FLUXBOX_ROOT}/init`);
696
+ });
697
+
698
+ test("browser.mjs drives the Bot's own window and never another Bot's", () => {
699
+ expect(browserHelper).toContain("newWindow: true");
700
+ expect(browserHelper).toContain("Browser.setWindowBounds");
701
+ expect(browserHelper).toContain(
702
+ `const TARGET_ID_FILE = "${TARGET_ID_FILE}"`,
703
+ );
704
+ // The window helpers ask for exactly the actions this module declares.
705
+ for (const [encoded, action] of [
706
+ [BROWSER_ENSURE_ACTION, "ensure"],
707
+ [BROWSER_FOCUS_ACTION, "focus"],
708
+ [BROWSER_SURVEY_ACTION, "survey"],
709
+ ] as const) {
710
+ expect(
711
+ JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")),
712
+ ).toEqual({ action });
713
+ expect(browserHelper).toContain(`action.action === "${action}"`);
714
+ }
619
715
  });
620
716
 
621
717
  test("every script the provisioning document installs is valid bash", async () => {
622
718
  for (const path of [
623
- `${RUNTIME_ROOT}/start-desktop.sh`,
719
+ `${RUNTIME_ROOT}/start-screen.sh`,
720
+ `${RUNTIME_ROOT}/start-browser.sh`,
721
+ `${RUNTIME_ROOT}/start-view.sh`,
722
+ ENSURE_WINDOW_SCRIPT,
723
+ FOCUS_WINDOW_SCRIPT,
624
724
  ENSURE_AGENT_SCRIPT,
625
725
  CONTROL_SCRIPT,
626
726
  BOUNDED_LOG_SCRIPT,
727
+ CHROME_LAUNCHER,
728
+ DOCTOR_SCRIPT,
627
729
  `${RUNTIME_ROOT}/start-gateway.sh`,
628
730
  ]) {
629
731
  await expectValidShell(installedScript(provisionScript, path));
@@ -828,13 +930,13 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
828
930
  test("reclaims an idle tenant's display and never a live one", async () => {
829
931
  const { directory, runtimeRoot, run } = await installEnsureScript();
830
932
  try {
831
- for (let slot = 0; slot < 100; slot += 1) {
832
- // Slot 7's tenant went quiet long ago; every other tenant is one this
933
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
934
+ // Slot 2's tenant went quiet long ago; every other tenant is one this
833
935
  // provider ran something for moments ago.
834
936
  await seedTenant(
835
937
  runtimeRoot,
836
938
  slot,
837
- slot === 7 ? SLOT_IDLE_SECONDS + 600 : 5,
939
+ slot === 2 ? SLOT_IDLE_SECONDS + 600 : 5,
838
940
  );
839
941
  }
840
942
 
@@ -845,10 +947,10 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
845
947
  (
846
948
  await readFile(join(runtimeRoot, "bots/newcomer/slot"), "utf8")
847
949
  ).trim(),
848
- ).toBe("7");
950
+ ).toBe("2");
849
951
  // The idle tenant lost its slot; the live ones kept theirs.
850
- expect(existsSync(join(runtimeRoot, "bots/tenant-007/slot"))).toBe(false);
851
- expect(existsSync(join(runtimeRoot, "bots/tenant-008/slot"))).toBe(true);
952
+ expect(existsSync(join(runtimeRoot, "bots/tenant-002/slot"))).toBe(false);
953
+ expect(existsSync(join(runtimeRoot, "bots/tenant-003/slot"))).toBe(true);
852
954
  } finally {
853
955
  await rm(directory, { recursive: true, force: true });
854
956
  }
@@ -857,7 +959,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
857
959
  test("refuses the new tenant when every display is live, rather than sharing one", async () => {
858
960
  const { directory, runtimeRoot, run } = await installEnsureScript();
859
961
  try {
860
- for (let slot = 0; slot < 100; slot += 1) {
962
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
861
963
  await seedTenant(runtimeRoot, slot, 5);
862
964
  }
863
965
 
@@ -874,7 +976,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
874
976
  test("an idle tenant under human control keeps its display", async () => {
875
977
  const { directory, runtimeRoot, run } = await installEnsureScript();
876
978
  try {
877
- for (let slot = 0; slot < 100; slot += 1) {
979
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
878
980
  // The only idle tenant is the one a human is watching right now.
879
981
  await seedTenant(
880
982
  runtimeRoot,
@@ -896,7 +998,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
896
998
  test("a fresh User-wide desktop lease keeps every idle display", async () => {
897
999
  const { directory, runtimeRoot, run } = await installEnsureScript();
898
1000
  try {
899
- for (let slot = 0; slot < 100; slot += 1) {
1001
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
900
1002
  await seedTenant(runtimeRoot, slot, SLOT_IDLE_SECONDS + 600);
901
1003
  }
902
1004
  const leaseRoot = join(runtimeRoot, "bots", DESKTOP_GUI_LEASE_KEY);
@@ -915,7 +1017,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
915
1017
  test("skips a tenant whose viewer just renewed last-seen", async () => {
916
1018
  const { directory, runtimeRoot, run } = await installEnsureScript();
917
1019
  try {
918
- for (let slot = 0; slot < 100; slot += 1) {
1020
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
919
1021
  await seedTenant(runtimeRoot, slot, SLOT_IDLE_SECONDS + 600);
920
1022
  }
921
1023
  // Viewer open/renew touches this existing registry fact. The reclaim
@@ -932,6 +1034,40 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
932
1034
  await rm(directory, { recursive: true, force: true });
933
1035
  }
934
1036
  }, 30_000);
1037
+ test("prunes a slot from the superseded hundred-display layout", async () => {
1038
+ // The migration's registry half (ADR 0031). A Computer that allocated
1039
+ // displays 0-99 carries slots the one screen has no rectangle for; a window
1040
+ // pinned past its last slot is a window nobody can see. They are pruned
1041
+ // under the same lock that allocates, so the tenant re-allocates in range
1042
+ // on its next open — and nothing durable, and no profile, is touched.
1043
+ const { directory, runtimeRoot, run } = await installEnsureScript();
1044
+ try {
1045
+ const stale = await seedTenant(runtimeRoot, 7, 5);
1046
+ await writeFile(join(runtimeRoot, "bots", stale, "target-id"), "old\n");
1047
+
1048
+ const ensured = await run("newcomer");
1049
+
1050
+ expect(ensured.exitCode).toBe(0);
1051
+ expect(existsSync(join(runtimeRoot, "bots", stale, "slot"))).toBe(false);
1052
+ expect(existsSync(join(runtimeRoot, "bots", stale, "target-id"))).toBe(
1053
+ false,
1054
+ );
1055
+ expect(
1056
+ (
1057
+ await readFile(join(runtimeRoot, "bots/newcomer/slot"), "utf8")
1058
+ ).trim(),
1059
+ ).toBe("0");
1060
+ // One browser, one port: the file stays, and every tenant reads the same
1061
+ // number out of it.
1062
+ expect(
1063
+ (
1064
+ await readFile(join(runtimeRoot, "bots/newcomer/cdp-port"), "utf8")
1065
+ ).trim(),
1066
+ ).toBe(String(COMPUTER_CDP_PORT));
1067
+ } finally {
1068
+ await rm(directory, { recursive: true, force: true });
1069
+ }
1070
+ }, 30_000);
935
1071
  });
936
1072
 
937
1073
  describe("the background-process logger", () => {
@@ -1128,6 +1264,9 @@ describe("box-doctor", () => {
1128
1264
  "scratch",
1129
1265
  "desktop-gateway",
1130
1266
  "sync-watcher",
1267
+ "browser-process",
1268
+ "browser-cdp",
1269
+ "screen",
1131
1270
  "tenant-display",
1132
1271
  "browser",
1133
1272
  "browser-profile",
package/src/runtime.ts CHANGED
@@ -85,6 +85,10 @@ export const BOUNDED_LOG_HEAD_BYTES = 131_072;
85
85
  /** Bytes kept from its tail. Together, GrokBot's 256 KiB cap. */
86
86
  export const BOUNDED_LOG_TAIL_BYTES = 131_072;
87
87
  export const ENSURE_AGENT_SCRIPT = `${RUNTIME_ROOT}/ensure-agent.sh`;
88
+ /** Gives one Bot its window on the Computer's one screen, and pins it there. */
89
+ export const ENSURE_WINDOW_SCRIPT = `${RUNTIME_ROOT}/ensure-window.sh`;
90
+ /** Raises one Bot's window, for a human taking the Computer over. */
91
+ export const FOCUS_WINDOW_SCRIPT = `${RUNTIME_ROOT}/focus-window.sh`;
88
92
  /** Where Playwright keeps the browser builds it downloads for this Computer. */
89
93
  export const BROWSERS_ROOT = `${RUNTIME_ROOT}/browsers`;
90
94
  /**
@@ -95,7 +99,7 @@ export const BROWSERS_ROOT = `${RUNTIME_ROOT}/browsers`;
95
99
  * `systemd` and never finished inside the ten-minute bound. The browser is
96
100
  * Playwright's own Chromium build instead — a self-contained tarball from
97
101
  * Playwright's CDN, no package manager involved — and this symlink is what
98
- * keeps `start-desktop.sh` free of the version in its directory name.
102
+ * keeps the browser launcher free of the version in its directory name.
99
103
  */
100
104
  export const CHROMIUM_PATH = `${HOME_ROOT}/bin/chromium`;
101
105
  /** Pinned with `playwright-core`, because the driver and the build must agree. */
@@ -197,14 +201,15 @@ export const LEASE_MAX_AGE_SECONDS = 90;
197
201
  * How long a tenant's slot is held after the provider last opened or ran
198
202
  * anything for it.
199
203
  *
200
- * A slot is a display number an Xvfb, VNC, and CDP port triple and there
201
- * are a hundred of them, so they are allocated on demand and reclaimed rather
202
- * than owned for ever. What makes a tenant live is this provider having opened
203
- * or executed for it recently, or a human holding its takeover lease; nothing
204
- * on the Computer is evidence, because the desktop script deletes its own X
205
- * lock when it restarts and an exec-only tenant never holds one at all. The
206
- * threshold is declared here so a reclaim is a stated policy rather than a
207
- * guess about who is still using a screen.
204
+ * A slot is a *region of the one screen* (ADR 0031): an x offset on the
205
+ * Computer's single Xvfb, one browser window pinned over it, and one VNC port
206
+ * clipped to it. There are `DESKTOP_SLOTS` of them, so they are allocated on
207
+ * demand and reclaimed rather than owned for ever. What makes a tenant live is
208
+ * this provider having opened or executed for it recently, or a human holding
209
+ * its takeover lease; nothing on the Computer is evidence, because an
210
+ * exec-only tenant never opens a window at all. The threshold is declared here
211
+ * so a reclaim is a stated policy rather than a guess about who is still using
212
+ * a screen.
208
213
  */
209
214
  export const SLOT_IDLE_SECONDS = 900;
210
215
  /** Exit code the ensure script uses when every slot belongs to a live tenant. */
@@ -216,34 +221,91 @@ export const NO_SLOTS_MARKER = "__FROCKBOT_NO_SLOTS__";
216
221
  export const VNC_PORT_BASE = 5900;
217
222
 
218
223
  /**
219
- * The prefix of a tenant's own desktop service.
224
+ * How many Bots of one User can hold a screen region at once (ADR 0031).
220
225
  *
221
- * One service per tenant rather than one for the Computer: a slot *is* an
222
- * Xvfb, VNC, and CDP triple, so the process group that owns a display belongs
223
- * to the tenant holding that slot and dies with it. The prefix is declared
224
- * because two call sites need the same answer — the host starts these, and the
226
+ * The Computer runs **one** Xvfb whose width is this many slots, so the number
227
+ * is a real resource bound rather than a policy: a 1280×720 slot costs about
228
+ * 3.5 MiB of framebuffer, and a hundred of them would be a 128 000-pixel-wide
229
+ * root window nobody asked for. Four is the agreed figure; a Computer whose
230
+ * every slot belongs to a live tenant refuses the next one rather than putting
231
+ * two Bots on one screen.
232
+ */
233
+ export const DESKTOP_SLOTS = 4;
234
+ /** One slot's width in pixels; a slot's x offset is this times its number. */
235
+ export const SLOT_WIDTH = 1280;
236
+ /** One slot's height. The screen is exactly this tall. */
237
+ export const SLOT_HEIGHT = 720;
238
+ /** The width of the Computer's single root window. */
239
+ export const SCREEN_WIDTH = SLOT_WIDTH * DESKTOP_SLOTS;
240
+ /** The one X display on a Computer. Every Bot's window lives on it. */
241
+ export const COMPUTER_DISPLAY_NUMBER = 100;
242
+ export const COMPUTER_DISPLAY = `:${COMPUTER_DISPLAY_NUMBER}`;
243
+ /**
244
+ * The one CDP port on a Computer.
245
+ *
246
+ * There is one browser process, because there is one profile: Chromium's
247
+ * singleton lock is per `--user-data-dir`, so a second launch against
248
+ * `${HOME_ROOT}/chrome-profile` never becomes a second browser — it prints
249
+ * "Opening in existing browser session" and exits, leaving its CDP port dead
250
+ * and its Bot's screen black. That is the defect ADR 0031 records; the model
251
+ * that replaces it is one browser, one port, one window per Bot.
252
+ */
253
+ export const COMPUTER_CDP_PORT = 9222;
254
+
255
+ /** The Computer's single Xvfb and window manager. */
256
+ export const SCREEN_SERVICE = "frockbot-screen";
257
+ /** The Computer's single Chromium, supervised so a crash comes back. */
258
+ export const BROWSER_SERVICE = "frockbot-browser";
259
+
260
+ /**
261
+ * The prefix of a tenant's own **viewer** service: one `x11vnc`, clipped to
262
+ * that tenant's slot of the shared screen.
263
+ *
264
+ * One service per tenant rather than one for the Computer, because a viewer
265
+ * session is per Bot: the token the gateway resolves addresses this port, and
266
+ * this port shows this slot and nothing else. The prefix is declared because
267
+ * two call sites need the same answer — the host starts these, and the
225
268
  * `service` op reattaches them after a cold pause, which it may only do for a
226
269
  * Computer-provider-declared name.
227
270
  */
271
+ export const VIEW_TENANT_SERVICE_PREFIX = "frockbot-view-";
272
+
273
+ /** The tenant viewer service that `start-view.sh` runs under. */
274
+ export function viewServiceNameV1(botKey: string): string {
275
+ return `${VIEW_TENANT_SERVICE_PREFIX}${botKey}`;
276
+ }
277
+
278
+ /**
279
+ * The prefix of the **superseded** per-slot desktop service (ADR 0031).
280
+ *
281
+ * Each of these was an Xvfb, a window manager, a browser launch, and an
282
+ * `x11vnc` for one tenant. Only the first ever got a browser — the rest lost
283
+ * the profile's singleton lock — so the layout is gone. The name stays
284
+ * declared because an existing Computer still has these services registered
285
+ * and the migration has to stop and delete them by name.
286
+ */
228
287
  export const DESKTOP_TENANT_SERVICE_PREFIX = "frockbot-desktop-";
229
288
 
230
- /** The tenant desktop service that `start-desktop.sh` runs under. */
289
+ /** The superseded per-tenant desktop service name. Migration only. */
231
290
  export function desktopServiceNameV1(botKey: string): string {
232
291
  return `${DESKTOP_TENANT_SERVICE_PREFIX}${botKey}`;
233
292
  }
234
293
 
235
294
  /**
236
- * Printed by the attach probe when the tenant's VNC port already answers.
237
- *
238
- * The probe is the whole reason attaching a tenant does not restart its
239
- * desktop on every Turn: `createService` is a create-*or-update*, so calling it
240
- * unconditionally would tear down a running Xvfb and with it the browser and
241
- * every page the Bot had open each time the Computer was opened. A listening
242
- * VNC port is the one piece of evidence that the display behind it is real,
243
- * which is more than the slot file can say: the slot is an allocation, not a
244
- * running process.
295
+ * Printed by the attach probe when the tenant's own VNC port already answers.
296
+ *
297
+ * The probe is the whole reason attaching a tenant does not restart anything
298
+ * on every Turn: `createService` is a create-*or-update*, so calling it
299
+ * unconditionally would tear down a running `x11vnc` each time the Computer
300
+ * was opened. A listening VNC port is the one piece of evidence that the
301
+ * viewer behind it is real, which is more than the slot file can say: the slot
302
+ * is an allocation, not a running process.
245
303
  */
246
304
  export const DESKTOP_LIVE_MARKER = "__FROCKBOT_DESKTOP_LIVE__";
305
+ /** Printed by the same probe when the Computer's one CDP port answers. */
306
+ export const BROWSER_LIVE_MARKER = "__FROCKBOT_BROWSER_LIVE__";
307
+ /** Printed by the same probe when this tenant already has a window recorded. */
308
+ export const WINDOW_LIVE_MARKER = "__FROCKBOT_WINDOW_LIVE__";
247
309
  /** Prefix carrying the slot the attach exec already read back to the host. */
248
310
  export const DESKTOP_SLOT_PREFIX = "__FROCKBOT_DESKTOP_SLOT__";
249
311
 
@@ -311,14 +373,24 @@ export function shellGuiCommandV1(command: string): string | undefined {
311
373
  return match?.[1];
312
374
  }
313
375
 
376
+ /** The one browser profile every Bot of one User shares (ADR 0012). */
377
+ export const CHROME_PROFILE = `${HOME_ROOT}/chrome-profile`;
378
+
314
379
  /** The browser flags the Computer runs chromium under, in one place. */
315
380
  export const CHROMIUM_FLAGS: readonly string[] = [
316
381
  "--no-sandbox",
317
382
  "--disable-dev-shm-usage",
318
383
  "--disable-gpu",
319
- `--user-data-dir=${HOME_ROOT}/chrome-profile`,
384
+ `--user-data-dir=${CHROME_PROFILE}`,
320
385
  "--remote-debugging-address=127.0.0.1",
321
- "--start-maximized",
386
+ // Not `--start-maximized`: a window belongs to one Bot's slot, and the slot
387
+ // is a region of a screen `DESKTOP_SLOTS` windows wide. Every window is
388
+ // placed by `Browser.setWindowBounds` once it exists; this is only the size
389
+ // the first one opens at.
390
+ `--window-size=${SLOT_WIDTH},${SLOT_HEIGHT}`,
391
+ "--window-position=0,0",
392
+ "--no-first-run",
393
+ "--no-default-browser-check",
322
394
  ];
323
395
 
324
396
  export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
@@ -326,34 +398,33 @@ export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
326
398
  /**
327
399
  * The single place the Computer's chromium flags live (parity row 33).
328
400
  *
329
- * It takes a Bot key, reads that tenant's slot, and derives the display and
330
- * the CDP port from it the same arithmetic the desktop starter does, done
331
- * once. `start-desktop.sh` calls it, and so may a human debugging the box;
332
- * nothing else needs to know the flag set exists.
401
+ * It takes no Bot key any more (ADR 0031). There is one browser on a Computer
402
+ * because there is one profile, so there is one display and one CDP port to
403
+ * derive: the arithmetic that used to turn a slot into a port is gone, and a
404
+ * slot now only says *where on the screen* a Bot's window sits. `start-browser.sh`
405
+ * calls this, and so may a human debugging the box; nothing else needs to know
406
+ * the flag set exists.
333
407
  */
334
408
  export const chromeLauncherScript = `#!/usr/bin/env bash
335
409
  set -eu
336
- KEY="\${1:-\${FROCKBOT_BOT_KEY:-}}"
337
- if [ -z "$KEY" ]; then
338
- echo "frockbot-chrome needs a Bot key: frockbot-chrome <botKey> [chromium args…]" >&2
339
- exit 64
340
- fi
341
- shift || true
342
- SLOT=$(cat ${BOTS_ROOT}/"$KEY"/slot 2>/dev/null || echo "")
343
- if [ -z "$SLOT" ]; then
344
- echo "Bot \\"$KEY\\" has no desktop slot on this Computer" >&2
345
- exit 69
346
- fi
347
- export DISPLAY=":$((100 + SLOT))"
410
+ export DISPLAY=${COMPUTER_DISPLAY}
348
411
  export ${SANCTIONED_SURFACE_ENV}=1
349
412
  if [ ! -x ${CHROMIUM_PATH} ]; then
350
413
  echo "no browser is installed at ${CHROMIUM_PATH}; the Computer installs one when it is provisioned" >&2
351
414
  exit 69
352
415
  fi
416
+ # The singleton files, and never the profile. Chromium's lock is per
417
+ # user-data-dir and is left behind by a browser the platform killed rather than
418
+ # stopped; a stale one makes the next launch print "Opening in existing browser
419
+ # session" and exit. Removed only when no browser is actually running, because
420
+ # two Chromiums on one profile is the one thing worse than none.
421
+ if ! pgrep -f -- "--remote-debugging-port=${COMPUTER_CDP_PORT}" >/dev/null 2>&1; then
422
+ rm -f ${CHROME_PROFILE}/SingletonLock ${CHROME_PROFILE}/SingletonSocket ${CHROME_PROFILE}/SingletonCookie
423
+ fi
353
424
  # By absolute path, not by name: the browser is Playwright's own build behind a
354
425
  # stable symlink, and reaching it through PATH would go past the shim that
355
426
  # covers the name chromium.
356
- exec ${CHROMIUM_PATH} ${CHROMIUM_FLAGS.join(" ")} --remote-debugging-port="$((9222 + SLOT))" "$@"
427
+ exec ${CHROMIUM_PATH} ${CHROMIUM_FLAGS.join(" ")} --remote-debugging-port=${COMPUTER_CDP_PORT} "$@"
357
428
  `;
358
429
 
359
430
  /**
@@ -382,30 +453,155 @@ exit 64
382
453
  `;
383
454
  }
384
455
 
385
- export const startDesktopScript = `#!/usr/bin/env bash
456
+ /** Where fluxbox is told what it may and may not do, on this Computer. */
457
+ export const FLUXBOX_ROOT = `${HOME_ROOT}/.fluxbox`;
458
+
459
+ /**
460
+ * fluxbox's configuration, declared rather than generated.
461
+ *
462
+ * With no `~/.fluxbox` at all, fluxbox writes its own defaults and then
463
+ * applies the default style's background by calling `fbsetbg` — which is not
464
+ * installed, and whose failure is an `xmessage` dialog reading "fbsetbg: I
465
+ * can't find an app to set the wallpaper with" sitting on top of every Bot's
466
+ * screen. `background: none` in the style overlay is the documented way to
467
+ * tell fluxbox not to set a background at all, which is what a screen made
468
+ * entirely of browser windows wants.
469
+ *
470
+ * The toolbar goes for the same reason: the viewer shows a Bot's 1280×720 slot
471
+ * and nothing else, and a window-list bar across the bottom of it is fluxbox's
472
+ * chrome in FrockBot's frame.
473
+ */
474
+ export const fluxboxInit = `session.screen0.toolbar.visible: false
475
+ session.screen0.slit.autoHide: true
476
+ session.screen0.workspaces: 1
477
+ session.screen0.workspacewarping: false
478
+ session.screen0.defaultDeco: NONE
479
+ session.screen0.focusModel: ClickToFocus
480
+ session.screen0.tabs.usePixmap: false
481
+ session.screen0.fullMaximization: false
482
+ session.styleOverlay: ${FLUXBOX_ROOT}/overlay
483
+ session.configVersion: 13
484
+ `;
485
+
486
+ /** The style overlay whose one job is to stop fluxbox reaching for fbsetbg. */
487
+ export const fluxboxOverlay = `background: none
488
+ `;
489
+
490
+ /**
491
+ * The Computer's one screen: a single Xvfb `DESKTOP_SLOTS` slots wide, and a
492
+ * window manager over it (ADR 0031).
493
+ *
494
+ * One Xvfb per Computer rather than one per slot, because there is one browser
495
+ * per Computer — Chromium's singleton lock is per profile and the profile is
496
+ * the User's — and a browser can only put its windows on the display it was
497
+ * launched under. Each Bot gets a *region* of this screen instead of a display
498
+ * of its own: window pinned by CDP, VNC clipped to the same rectangle.
499
+ */
500
+ export const startScreenScript = `#!/usr/bin/env bash
386
501
  set -eu
387
- KEY="$1"
388
- ROOT=${RUNTIME_ROOT}
389
- BOT="$ROOT/bots/$KEY"
390
- SLOT=$(cat "$BOT/slot")
391
- DISPLAY_NUMBER=$((100 + SLOT))
392
- VNC_PORT=$((${VNC_PORT_BASE} + SLOT))
393
- export DISPLAY=:$DISPLAY_NUMBER
394
502
  # The desktop stack *is* the sanctioned surface, so the shims step aside for
395
503
  # it. Everything a Bot's own shell runs arrives without this set.
396
504
  export ${SANCTIONED_SURFACE_ENV}=1
505
+ export DISPLAY=${COMPUTER_DISPLAY}
506
+ export HOME=${HOME_ROOT}
397
507
  cleanup() {
398
508
  jobs -pr | xargs -r kill >/dev/null 2>&1 || true
399
509
  }
400
510
  trap cleanup EXIT INT TERM
401
- rm -f "/tmp/.X$DISPLAY_NUMBER-lock" "/tmp/.X11-unix/X$DISPLAY_NUMBER"
402
- Xvfb "$DISPLAY" -screen 0 1280x720x24 -nolisten tcp &
403
- for _ in $(seq 1 100); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.1; done
404
- fluxbox >"$BOT/fluxbox.log" 2>&1 &
405
- ${CHROME_LAUNCHER} "$KEY" about:blank >"$BOT/chromium.log" 2>&1 &
406
- x11vnc -display "$DISPLAY" -forever -shared -rfbport "$VNC_PORT" -passwd "$(cat "$BOT/vnc-password")" >"$BOT/x11vnc.log" 2>&1 &
407
- VNC_PID=$!
408
- wait "$VNC_PID"
511
+ rm -f "/tmp/.X${COMPUTER_DISPLAY_NUMBER}-lock" "/tmp/.X11-unix/X${COMPUTER_DISPLAY_NUMBER}"
512
+ Xvfb ${COMPUTER_DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SLOT_HEIGHT}x24 -nolisten tcp &
513
+ XVFB_PID=$!
514
+ for _ in $(seq 1 100); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.1; done
515
+ mkdir -p ${FLUXBOX_ROOT}
516
+ fluxbox -rc ${FLUXBOX_ROOT}/init >${RUNTIME_ROOT}/fluxbox.log 2>&1 &
517
+ wait "$XVFB_PID"
518
+ `;
519
+
520
+ /**
521
+ * The Computer's one browser, supervised (ADR 0031).
522
+ *
523
+ * Its own service rather than a background job of the screen's, so a Chromium
524
+ * that crashes is restarted by the platform without taking the screen — and
525
+ * every Bot's window — down with it. Each Bot re-creates its window on its
526
+ * next action, which is what `ensure-window.sh` is for.
527
+ */
528
+ export const startBrowserScript = `#!/usr/bin/env bash
529
+ set -eu
530
+ export ${SANCTIONED_SURFACE_ENV}=1
531
+ export DISPLAY=${COMPUTER_DISPLAY}
532
+ # The screen is a separate service, so this one may start first. Wait for the
533
+ # display rather than failing: a service that exits is a service the platform
534
+ # restarts, and a browser started before its X server never draws anything.
535
+ for _ in $(seq 1 300); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.2; done
536
+ if ! xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
537
+ echo "no X server on ${COMPUTER_DISPLAY} after 60s; the ${SCREEN_SERVICE} service is what starts one" >&2
538
+ exit 69
539
+ fi
540
+ exec ${CHROME_LAUNCHER} about:blank
541
+ `;
542
+
543
+ /**
544
+ * One Bot's viewer: an `x11vnc` clipped to that Bot's slot of the one screen.
545
+ *
546
+ * `-clip`, not `-id`: a window id changes every time the Bot's window is
547
+ * re-created, and a VNC server bound to a dead window shows nothing. The
548
+ * rectangle is stable for as long as the Bot holds the slot.
549
+ */
550
+ export const startViewScript = `#!/usr/bin/env bash
551
+ set -eu
552
+ KEY="$1"
553
+ ROOT=${RUNTIME_ROOT}
554
+ BOT="$ROOT/bots/$KEY"
555
+ SLOT=$(cat "$BOT/slot")
556
+ VNC_PORT=$((${VNC_PORT_BASE} + SLOT))
557
+ CLIP=${SLOT_WIDTH}x${SLOT_HEIGHT}+$((SLOT * ${SLOT_WIDTH}))+0
558
+ export ${SANCTIONED_SURFACE_ENV}=1
559
+ export DISPLAY=${COMPUTER_DISPLAY}
560
+ for _ in $(seq 1 300); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.2; done
561
+ if ! xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
562
+ echo "no X server on ${COMPUTER_DISPLAY} after 60s; the ${SCREEN_SERVICE} service is what starts one" >&2
563
+ exit 69
564
+ fi
565
+ exec x11vnc -display ${COMPUTER_DISPLAY} -clip "$CLIP" -forever -shared -rfbport "$VNC_PORT" -passwd "$(cat "$BOT/vnc-password")"
566
+ `;
567
+
568
+ /** What `ensure-window.sh` asks `browser.mjs`, base64url as it takes it. */
569
+ export const BROWSER_ENSURE_ACTION = "eyJhY3Rpb24iOiJlbnN1cmUifQ";
570
+ /** What `focus-window.sh` asks it, when a human takes this Computer over. */
571
+ export const BROWSER_FOCUS_ACTION = "eyJhY3Rpb24iOiJmb2N1cyJ9";
572
+ /** What box-doctor asks it, to report every tenant's window at once. */
573
+ export const BROWSER_SURVEY_ACTION = "eyJhY3Rpb24iOiJzdXJ2ZXkifQ";
574
+
575
+ /** Where the Bot's own browser window is recorded, under its Bot directory. */
576
+ export const TARGET_ID_FILE = "target-id";
577
+
578
+ /**
579
+ * Gives one Bot its window on the shared screen, and pins it to its slot.
580
+ *
581
+ * Idempotent by construction: it re-uses the recorded target when that target
582
+ * is still a live page and creates a new window when it is not, so a browser
583
+ * that crashed and came back costs one window per Bot on their next action and
584
+ * nothing else.
585
+ */
586
+ export const ensureWindowScript = `#!/usr/bin/env bash
587
+ set -eu
588
+ KEY="$1"
589
+ export ${SANCTIONED_SURFACE_ENV}=1
590
+ exec timeout 30 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_ENSURE_ACTION} "$KEY"
591
+ `;
592
+
593
+ /**
594
+ * Brings one Bot's window to the front, for a human taking the Computer over.
595
+ *
596
+ * Best effort and non-fatal: a takeover whose window could not be raised is a
597
+ * takeover of a screen showing the wrong Bot, which is worth reporting and is
598
+ * not worth refusing the lease over.
599
+ */
600
+ export const focusWindowScript = `#!/usr/bin/env bash
601
+ set -eu
602
+ KEY="$1"
603
+ export ${SANCTIONED_SURFACE_ENV}=1
604
+ exec timeout 20 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_FOCUS_ACTION} "$KEY"
409
605
  `;
410
606
 
411
607
  export const ensureAgentScript = `#!/usr/bin/env bash
@@ -425,29 +621,45 @@ chmod 600 "$PROFILE_TMP"
425
621
  mv "$PROFILE_TMP" "$AGENT_DATA/profile.json"
426
622
  exec 9>"$ROOT/registry.lock"
427
623
  flock -x 9
624
+ # Slots allocated under the superseded hundred-display layout (ADR 0031) cannot
625
+ # be shown on the one screen: it has ${DESKTOP_SLOTS} rectangles on it, and a
626
+ # window pinned past the last of them is a window nobody can see behind a clip
627
+ # x11vnc refuses. Pruned under the same lock that allocates, so a migrated
628
+ # Computer re-allocates in range on the tenant's next open. Only the
629
+ # provider-owned registry files go; nothing durable, and never the profile.
630
+ for SLOT_FILE in "$ROOT"/bots/*/slot; do
631
+ [ -s "$SLOT_FILE" ] || continue
632
+ SLOT_VALUE=$(cat "$SLOT_FILE")
633
+ SLOT_BOT=$(dirname "$SLOT_FILE")
634
+ case "$SLOT_VALUE" in
635
+ (''|*[!0-9]*) rm -f "$SLOT_FILE" "$SLOT_BOT/${TARGET_ID_FILE}" "$SLOT_BOT/cdp-port"; continue;;
636
+ esac
637
+ if [ "$SLOT_VALUE" -ge ${DESKTOP_SLOTS} ]; then
638
+ rm -f "$SLOT_FILE" "$SLOT_BOT/${TARGET_ID_FILE}" "$SLOT_BOT/cdp-port"
639
+ fi
640
+ done
428
641
  if [ ! -s "$BOT/slot" ]; then
429
642
  # Every slot in use, read once. The registry lock is held, so the answer
430
643
  # cannot change under this scan, and one read beats one per slot per tenant
431
644
  # when a Computer is close to full.
432
645
  USED=" $(cat "$ROOT"/bots/*/slot 2>/dev/null | tr '\n' ' ') "
433
646
  SLOT=0
434
- while [ "$SLOT" -lt 100 ]; do
647
+ while [ "$SLOT" -lt ${DESKTOP_SLOTS} ]; do
435
648
  case "$USED" in (*" $SLOT "*) ;; (*) break ;; esac
436
649
  SLOT=$((SLOT + 1))
437
650
  done
438
- if [ "$SLOT" -ge 100 ]; then
439
- # A slot is a display number, not durable state: it is the Xvfb, VNC, and
440
- # CDP port triple a tenant's desktop uses while it has one. A tenant that
441
- # never comes back would otherwise hold one for ever, and the hundred and
442
- # first Bot of a User could never open a desktop, so the allocation is
443
- # bounded rather than permanent.
651
+ if [ "$SLOT" -ge ${DESKTOP_SLOTS} ]; then
652
+ # A slot is a region of the one screen, not durable state: it is the x
653
+ # offset a tenant's browser window is pinned at and the VNC port clipped to
654
+ # it. A tenant that never comes back would otherwise hold one for ever, so
655
+ # the allocation is bounded rather than permanent.
444
656
  #
445
657
  # Liveness is decided by the provider's own registry, never by the
446
658
  # Computer's state: "last-seen" is written by the backend every time it
447
659
  # opens or runs anything for a tenant, and "human-control" is the takeover
448
- # lease. An X lock proves nothing — the desktop script deletes its own on
449
- # restart, and a tenant that only ever execs never holds one — so a slot is
450
- # reclaimed only when its tenant has been idle past the declared threshold
660
+ # lease. A window proves nothing — the browser is restarted under every
661
+ # tenant at once, and a tenant that only ever execs never opens one — so a
662
+ # slot is reclaimed only when its tenant has been idle past the threshold
451
663
  # AND no viewer lease is fresh. Its viewer token goes with the slot, or
452
664
  # that token would address another Bot's screen. When every slot belongs to
453
665
  # a live tenant the new tenant is refused: sharing a display would put two
@@ -496,7 +708,9 @@ if [ ! -s "$BOT/slot" ]; then
496
708
  chmod 600 "$VTMP"
497
709
  mv "$VTMP" "$ROOT/tokens"
498
710
  fi
499
- rm -f "$VICTIM" "$VICTIM_BOT/cdp-port"
711
+ # The window goes with the slot: the next holder of this rectangle must
712
+ # not inherit a target id addressing the previous tenant's window.
713
+ rm -f "$VICTIM" "$VICTIM_BOT/cdp-port" "$VICTIM_BOT/${TARGET_ID_FILE}"
500
714
  fi
501
715
  printf '%s\n' "$SLOT" > "$BOT/slot"
502
716
  fi
@@ -505,7 +719,9 @@ fi
505
719
  # the registry entry the reclaim reads to decide whether a tenant is live.
506
720
  touch "$BOT/slot" "$BOT/last-seen"
507
721
  SLOT=$(cat "$BOT/slot")
508
- printf '%s\n' "$((9222 + SLOT))" > "$BOT/cdp-port"
722
+ # One browser on this Computer, so one port for every tenant. The file stays
723
+ # because callers read it rather than deriving a port of their own.
724
+ printf '%s\n' "${COMPUTER_CDP_PORT}" > "$BOT/cdp-port"
509
725
  if [ ! -s "$BOT/vnc-password" ]; then
510
726
  umask 077
511
727
  head -c 32 /dev/urandom | base64 | tr -d '\n=+/' > "$BOT/vnc-password"
@@ -641,13 +857,172 @@ case "$ACTION" in
641
857
  esac
642
858
  `;
643
859
 
644
- export const browserHelper = `import { chromium } from "playwright-core";
860
+ /**
861
+ * The one program that drives this Computer's browser (ADR 0031).
862
+ *
863
+ * There is one Chromium and one CDP port, and each Bot owns one *window* on
864
+ * it. The window is recorded at `<bot>/target-id` and re-created when it is
865
+ * gone, so a browser that crashed costs each Bot one new window and nothing
866
+ * else. A Bot may open as many tabs inside its own window as it likes; this
867
+ * program never touches a target belonging to another Bot's window.
868
+ *
869
+ * Isolation between two Bots of one User is therefore weaker than it looks:
870
+ * one profile, one CDP port, one process. That is the trade ADR 0031 records —
871
+ * the requirement is that a login one Bot makes is a login all of them have —
872
+ * and the sanctioned-surface shims remain the line of defence.
873
+ */
874
+ export const browserHelper = `import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
875
+ import { chromium } from "playwright-core";
876
+
877
+ const BOTS_ROOT = "${BOTS_ROOT}";
878
+ const TARGET_ID_FILE = "${TARGET_ID_FILE}";
879
+ const SLOT_WIDTH = ${SLOT_WIDTH};
880
+ const SLOT_HEIGHT = ${SLOT_HEIGHT};
881
+
645
882
  const port = Number(process.argv[2]);
646
883
  const action = JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8"));
884
+ const botKey = process.argv[4] ?? "";
885
+
886
+ const botDir = (key) => \`\${BOTS_ROOT}/\${key}\`;
887
+ const targetPath = (key) => \`\${botDir(key)}/\${TARGET_ID_FILE}\`;
888
+
889
+ function slotOf(key) {
890
+ const raw = readFileSync(\`\${botDir(key)}/slot\`, "utf8").trim();
891
+ if (!/^\\d+$/.test(raw)) throw new Error(\`Bot "\${key}" holds no desktop slot\`);
892
+ return Number(raw);
893
+ }
894
+
895
+ function recordedTarget(key) {
896
+ try {
897
+ return readFileSync(targetPath(key), "utf8").trim();
898
+ } catch {
899
+ return "";
900
+ }
901
+ }
902
+
903
+ function boundsFor(slot) {
904
+ return { left: slot * SLOT_WIDTH, top: 0, width: SLOT_WIDTH, height: SLOT_HEIGHT, windowState: "normal" };
905
+ }
906
+
907
+ function placed(bounds, slot) {
908
+ const want = boundsFor(slot);
909
+ return Boolean(bounds) && bounds.left === want.left && bounds.top === want.top && bounds.width === want.width && bounds.height === want.height;
910
+ }
911
+
647
912
  const browser = await chromium.connectOverCDP(\`http://127.0.0.1:\${port}\`);
648
- const context = browser.contexts()[0];
649
- const pages = context.pages();
650
- const page = pages.at(-1) ?? await context.newPage();
913
+ const cdp = await browser.newBrowserCDPSession();
914
+
915
+ async function pageTargets() {
916
+ const { targetInfos } = await cdp.send("Target.getTargets");
917
+ return targetInfos.filter((info) => info.type === "page");
918
+ }
919
+
920
+ /** The Bot's own window: the recorded one when it is alive, a new one when not. */
921
+ async function ensureWindow(key) {
922
+ const slot = slotOf(key);
923
+ let targetId = recordedTarget(key);
924
+ if (!targetId || !(await pageTargets()).some((info) => info.targetId === targetId)) {
925
+ ({ targetId } = await cdp.send("Target.createTarget", { url: "about:blank", newWindow: true }));
926
+ mkdirSync(botDir(key), { recursive: true });
927
+ writeFileSync(targetPath(key), \`\${targetId}\\n\`, { mode: 0o600 });
928
+ }
929
+ const { windowId } = await cdp.send("Browser.getWindowForTarget", { targetId });
930
+ await cdp.send("Browser.setWindowBounds", { windowId, bounds: boundsFor(slot) });
931
+ return { targetId, windowId, slot };
932
+ }
933
+
934
+ /** The Playwright page for one target id, waited for: CDP creates it, Playwright discovers it. */
935
+ async function pageFor(targetId) {
936
+ for (let attempt = 0; attempt < 50; attempt += 1) {
937
+ for (const context of browser.contexts()) {
938
+ for (const candidate of context.pages()) {
939
+ if ((await targetIdOf(context, candidate)) === targetId) return candidate;
940
+ }
941
+ }
942
+ await new Promise((resolve) => setTimeout(resolve, 100));
943
+ }
944
+ return undefined;
945
+ }
946
+
947
+ async function targetIdOf(context, page) {
948
+ const session = await context.newCDPSession(page);
949
+ const { targetInfo } = await session.send("Target.getTargetInfo");
950
+ await session.detach().catch(() => {});
951
+ return targetInfo.targetId;
952
+ }
953
+
954
+ /** Every page in the Bot's own window. Tabs it opened there are its own. */
955
+ async function pagesInWindow(windowId) {
956
+ const own = [];
957
+ for (const context of browser.contexts()) {
958
+ for (const candidate of context.pages()) {
959
+ const targetId = await targetIdOf(context, candidate);
960
+ const window = await cdp
961
+ .send("Browser.getWindowForTarget", { targetId })
962
+ .catch(() => undefined);
963
+ if (window && window.windowId === windowId) own.push(candidate);
964
+ }
965
+ }
966
+ return own;
967
+ }
968
+
969
+ async function done(value) {
970
+ if (value !== undefined) console.log(JSON.stringify(value));
971
+ await browser.close();
972
+ process.exit(0);
973
+ }
974
+
975
+ // box-doctor's whole-Computer view (ADR 0031): every tenant that holds a slot,
976
+ // whether its window exists, and whether it sits over its own slot. One CDP
977
+ // connection for the Computer rather than one probe per Bot.
978
+ if (action.action === "survey") {
979
+ const infos = await pageTargets();
980
+ const rows = [];
981
+ for (const entry of readdirSync(BOTS_ROOT, { withFileTypes: true })) {
982
+ if (!entry.isDirectory()) continue;
983
+ let slot;
984
+ try {
985
+ slot = slotOf(entry.name);
986
+ } catch {
987
+ continue;
988
+ }
989
+ const targetId = recordedTarget(entry.name);
990
+ const alive = Boolean(targetId) && infos.some((info) => info.targetId === targetId);
991
+ let bounds;
992
+ if (alive) {
993
+ const { windowId } = await cdp.send("Browser.getWindowForTarget", { targetId });
994
+ ({ bounds } = await cdp.send("Browser.getWindowBounds", { windowId }));
995
+ }
996
+ rows.push({ key: entry.name, slot, targetId, alive, placed: alive && placed(bounds, slot) });
997
+ }
998
+ await done({ tenants: rows.sort((left, right) => left.slot - right.slot) });
999
+ }
1000
+
1001
+ // The Bot's window, created and pinned, and nothing else. What an open runs.
1002
+ if (action.action === "ensure") {
1003
+ await done(await ensureWindow(botKey));
1004
+ }
1005
+
1006
+ const anchor = botKey ? await ensureWindow(botKey) : undefined;
1007
+
1008
+ // A human is taking this Computer over: raise the Bot's window so the screen
1009
+ // they are handed is the Bot's, not whichever window Chromium last focused.
1010
+ if (action.action === "focus") {
1011
+ const focusPage = anchor ? await pageFor(anchor.targetId) : undefined;
1012
+ if (focusPage) await focusPage.bringToFront();
1013
+ await done({ focused: Boolean(focusPage), ...(anchor ? { targetId: anchor.targetId } : {}) });
1014
+ }
1015
+
1016
+ const own = anchor ? await pagesInWindow(anchor.windowId) : [];
1017
+ const page =
1018
+ own.at(-1) ??
1019
+ (anchor ? await pageFor(anchor.targetId) : undefined) ??
1020
+ browser.contexts()[0]?.pages().at(-1);
1021
+ if (!page) {
1022
+ console.error("this Computer's browser has no page for this Bot");
1023
+ await browser.close();
1024
+ process.exit(69);
1025
+ }
651
1026
  // box-doctor's browser-identity measurement (parity row 34b). It answers
652
1027
  // before any navigation and before the snapshot, so the check reads what the
653
1028
  // browser presents without moving the page a human or a Bot left open.
@@ -657,9 +1032,7 @@ if (action.action === "identity") {
657
1032
  webdriver: navigator.webdriver === true,
658
1033
  brands: (navigator.userAgentData?.brands ?? []).map((brand) => \`\${brand.brand}/\${brand.version}\`),
659
1034
  }));
660
- console.log(JSON.stringify(identity));
661
- await browser.close();
662
- process.exit(0);
1035
+ await done(identity);
663
1036
  }
664
1037
  if (action.action === "navigate") await page.goto(action.url, { waitUntil: "domcontentloaded" });
665
1038
  if (action.action === "click") await page.getByRole(action.role, { name: action.name, exact: action.exact ?? false }).click();
@@ -667,8 +1040,7 @@ if (action.action === "fill") await page.getByLabel(action.label, { exact: actio
667
1040
  if (action.action === "press") await page.keyboard.press(action.key);
668
1041
  if (action.action === "wait") await page.waitForTimeout(action.milliseconds ?? 1000);
669
1042
  const snapshot = await page.locator("body").ariaSnapshot({ timeout: 10000 });
670
- console.log(JSON.stringify({ url: page.url(), title: await page.title(), snapshot }));
671
- await browser.close();
1043
+ await done({ url: page.url(), title: await page.title(), snapshot });
672
1044
  `;
673
1045
 
674
1046
  export const syncWatchScript = `#!/usr/bin/env bash
@@ -883,7 +1255,7 @@ export const CLOCK_FLOOR_EPOCH = 1_756_684_800;
883
1255
  * corrected. The version is compared on every adoption instead, and the whole
884
1256
  * set is rewritten when it moves. Bump it whenever a document below changes.
885
1257
  */
886
- export const REFERENCE_DOCS_VERSION = "2026-09-03.1";
1258
+ export const REFERENCE_DOCS_VERSION = "2026-09-04.1";
887
1259
 
888
1260
  /**
889
1261
  * What a Bot reads to debug its own Computer.
@@ -900,8 +1272,8 @@ export const REFERENCE_DOCS: readonly { name: string; content: string }[] = [
900
1272
  content: `# Your FrockBot Computer
901
1273
 
902
1274
  One Computer serves all of your User's Bots. You have your own directories and
903
- your own desktop on it; the browser profile is shared, so a login one Bot makes
904
- is a login all of them have.
1275
+ your own window on its one screen; the browser and its profile are shared, so a
1276
+ login one Bot makes is a login all of them have.
905
1277
 
906
1278
  - \`layout.md\` — what is durable, what is scratch, and what is lost when.
907
1279
  - \`browser.md\` — how the browser is launched and driven, and what never is.
@@ -990,11 +1362,27 @@ accessibility snapshot, which is what you should read a page from.
990
1362
  \`computer_screenshot\` captures your own desktop as an image and files it in
991
1363
  your durable screenshots root.
992
1364
 
1365
+ ## One browser, one window each
1366
+
1367
+ There is exactly one browser process on this Computer, because there is one
1368
+ profile: Chromium's lock is per profile, and a second launch against it is not
1369
+ a second browser. Each Bot gets one **window** on that browser, pinned over its
1370
+ own slot of the one screen.
1371
+
1372
+ - Tabs you open inside your own window are yours; use as many as you like.
1373
+ - A login one Bot makes is a login every Bot has, the instant it is made — the
1374
+ cookie jar is the profile, and the profile is shared.
1375
+ - Other Bots' windows are not yours to drive, read, or close. Nothing stops
1376
+ you at the CDP layer; this is a rule, not a wall.
1377
+ - If your window is gone — the browser crashed and came back — the next
1378
+ \`computer_browser\` action opens you a new one.
1379
+
993
1380
  ## Launching it
994
1381
 
995
- \`${CHROME_LAUNCHER} <botKey>\` is the only sanctioned launcher. It derives
996
- your display and your CDP port from your desktop slot and holds the flag set;
997
- the desktop starter calls it, and nothing else needs to know the flags exist.
1382
+ \`${CHROME_LAUNCHER}\` is the only sanctioned launcher. It takes no Bot key any
1383
+ more: it holds the flag set and starts the Computer's one browser on the one
1384
+ display and the one CDP port. The \`${BROWSER_SERVICE}\` service calls it, and
1385
+ nothing else needs to know the flags exist.
998
1386
 
999
1387
  ## What is never run from the shell
1000
1388
 
@@ -1017,8 +1405,10 @@ tools do.
1017
1405
 
1018
1406
  \`computer_doctor\` runs \`${DOCTOR_SCRIPT}\` and hands back a report: disk on
1019
1407
  \`/\` and \`${HOME_ROOT}\`, the size of \`${SCRATCH_ROOT}\`, the viewer
1020
- gateway, the durable-root watcher, your display and CDP port, the browser and
1021
- its profile, the sync signal and any conflicting generations, this reference
1408
+ gateway, the durable-root watcher, the shared screen, the one browser process
1409
+ and its CDP port, every Bot's window and whether it sits over that Bot's own
1410
+ slot, the browser build and its profile, the sync signal and any conflicting
1411
+ generations, this reference
1022
1412
  set's version, the launcher and its shims, the clock, DNS, and whether a
1023
1413
  provisioning hold is still keeping this Computer awake.
1024
1414
 
@@ -1037,8 +1427,13 @@ Computer rather than of one run.
1037
1427
  - **sync-signal with conflicts** — a write landed on a generation its writer
1038
1428
  had not seen. The conflicting generation is preserved, never merged; say so
1039
1429
  rather than resolving it silently.
1040
- - **tenant-display**your desktop is gone. Ask for it again; slots are
1041
- allocated on demand and a Computer with all hundred in use will say so.
1430
+ - **tenant-display-<botKey>**that Bot has no window on the screen, or its
1431
+ window is not over its own slot. A window comes back on that Bot's next
1432
+ browser action. Slots are allocated on demand, and a Computer with all of
1433
+ them in use will say so.
1434
+ - **browser-process** — none, or more than one. One is the whole design: the
1435
+ profile's lock admits exactly one browser, and a second one is a Bot with a
1436
+ black screen.
1042
1437
  - **reference-docs** — this set is stale and refreshes when the Computer is
1043
1438
  next opened. Nothing you can do on the box fixes it.
1044
1439
  - **browser** — the browser build is missing. It is installed by provisioning,
@@ -1338,26 +1733,66 @@ else
1338
1733
  fi
1339
1734
  SLOT=""
1340
1735
  if [ -n "$KEY" ] && [ -s ${BOTS_ROOT}/"$KEY"/slot ]; then SLOT=$(cat ${BOTS_ROOT}/"$KEY"/slot); fi
1341
- if [ -z "$KEY" ]; then
1342
- record tenant-display pass "no Bot key was named, so no desktop was checked"
1343
- elif [ -z "$SLOT" ]; then
1344
- record tenant-display pass "Bot \\"$KEY\\" holds no desktop slot; its exec and file surfaces need no screen"
1345
- elif (exec 3<>/dev/tcp/127.0.0.1/$((9222 + SLOT))) 2>/dev/null; then
1346
- record tenant-display pass "Bot \\"$KEY\\" is on display :$((100 + SLOT)) with CDP on $((9222 + SLOT))"
1347
- elif [ ! -e "/tmp/.X$((100 + SLOT))-lock" ]; then
1348
- # A slot is reserved at attach; the desktop starts when somebody asks to see
1349
- # it. An exec-only tenant never holds an X lock, so this is a healthy state
1350
- # and not a missing screen.
1351
- record tenant-display pass "Bot \\"$KEY\\" holds slot $SLOT with no desktop running, which its exec and file surfaces do not need"
1736
+ # One browser, one CDP port (ADR 0031). A second main process would mean a
1737
+ # second browser holding or failing to hold the one shared profile, which
1738
+ # is the defect this layout replaced: the loser prints "Opening in existing
1739
+ # browser session", exits, and leaves its Bot a black screen.
1740
+ BROWSERS=$(pgrep -f -- "--remote-debugging-port=${COMPUTER_CDP_PORT}" 2>/dev/null | wc -l | tr -d ' ')
1741
+ if [ "$BROWSERS" = 1 ]; then
1742
+ record browser-process pass "exactly one browser process holds ${CHROME_PROFILE}"
1743
+ elif [ "$BROWSERS" = 0 ]; then
1744
+ record browser-process fail "no browser process is running; the ${BROWSER_SERVICE} service is what starts one"
1745
+ else
1746
+ record browser-process fail "$BROWSERS browser processes hold ${CHROME_PROFILE}; only the first of them can own the profile"
1747
+ fi
1748
+ if (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1749
+ record browser-cdp pass "CDP answers on ${COMPUTER_CDP_PORT}"
1352
1750
  else
1353
- 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"
1751
+ record browser-cdp fail "nothing answers CDP on ${COMPUTER_CDP_PORT}; no Bot can be given a window"
1752
+ fi
1753
+ if xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
1754
+ record screen pass "the shared screen is up on ${COMPUTER_DISPLAY}, ${SCREEN_WIDTH}x${SLOT_HEIGHT} for ${DESKTOP_SLOTS} slots"
1755
+ else
1756
+ record screen fail "no X server on ${COMPUTER_DISPLAY}; the ${SCREEN_SERVICE} service is what starts one"
1757
+ fi
1758
+ # Every tenant, not just the one that asked (ADR 0031). One Bot's report used
1759
+ # to be the only evidence there was, which is exactly how three Bots sat on
1760
+ # black screens while the first one browsed.
1761
+ SURVEY=""
1762
+ if (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1763
+ SURVEY=$(timeout 20 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_SURVEY_ACTION} 2>/dev/null | tail -n 1)
1764
+ fi
1765
+ case "$SURVEY" in (*'"tenants"'*) ;; (*) SURVEY="";; esac
1766
+ TENANTS=0
1767
+ for SLOT_FILE in ${BOTS_ROOT}/*/slot; do
1768
+ [ -s "$SLOT_FILE" ] || continue
1769
+ TENANT=$(basename "$(dirname "$SLOT_FILE")")
1770
+ TENANT_SLOT=$(cat "$SLOT_FILE")
1771
+ TENANTS=$((TENANTS + 1))
1772
+ if [ -z "$SURVEY" ]; then
1773
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT and no browser could be asked about its window"
1774
+ continue
1775
+ fi
1776
+ ROW=$(printf '%s' "$SURVEY" | tr '{' '\\n' | grep "\\"key\\":\\"$TENANT\\"" | head -n 1)
1777
+ if [ -z "$ROW" ]; then
1778
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT but the browser reported no window for it"
1779
+ elif ! printf '%s' "$ROW" | grep -q '"alive":true'; then
1780
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT with no live window; it opens one on its next action"
1781
+ elif ! printf '%s' "$ROW" | grep -q '"placed":true'; then
1782
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" has a window that is not over slot $TENANT_SLOT; its viewer shows another Bot's rectangle"
1783
+ else
1784
+ record "tenant-display-$TENANT" pass "Bot \\"$TENANT\\" has a live window pinned over slot $TENANT_SLOT of ${COMPUTER_DISPLAY}"
1785
+ fi
1786
+ done
1787
+ if [ "$TENANTS" = 0 ]; then
1788
+ record tenant-display pass "no Bot holds a slot on this Computer; the exec and file surfaces need no screen"
1354
1789
  fi
1355
1790
  if [ -x ${CHROMIUM_PATH} ]; then
1356
1791
  record browser pass "the browser is installed at ${CHROMIUM_PATH} ($(readlink -f ${CHROMIUM_PATH} 2>/dev/null || echo unresolved))"
1357
1792
  else
1358
1793
  record browser fail "no browser at ${CHROMIUM_PATH}; provisioning installs one, and no desktop can start without it"
1359
1794
  fi
1360
- PROFILE=${HOME_ROOT}/chrome-profile
1795
+ PROFILE=${CHROME_PROFILE}
1361
1796
  if [ -d "$PROFILE" ] && [ -w "$PROFILE" ]; then
1362
1797
  record browser-profile pass "the shared browser profile at $PROFILE is writable"
1363
1798
  else
@@ -1378,13 +1813,13 @@ fi
1378
1813
  IDENTITY=null
1379
1814
  if [ ! -x ${CHROMIUM_PATH} ]; then
1380
1815
  record browser-identity fail "no browser at ${CHROMIUM_PATH}, so nothing could be asked what it announces itself as"
1381
- elif [ -z "$SLOT" ] || ! (exec 3<>/dev/tcp/127.0.0.1/$((9222 + SLOT))) 2>/dev/null; then
1816
+ elif ! (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1382
1817
  record browser-identity pass "no browser answers CDP for this report, so nothing was asked what it announces itself as"
1383
1818
  else
1384
- MEASURED=$(timeout 15 node ${RUNTIME_ROOT}/browser.mjs $((9222 + SLOT)) ${DOCTOR_BROWSER_IDENTITY_ACTION} 2>/dev/null | tail -n 1)
1819
+ MEASURED=$(timeout 15 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${DOCTOR_BROWSER_IDENTITY_ACTION} "$KEY" 2>/dev/null | tail -n 1)
1385
1820
  case "$MEASURED" in (*'"userAgent"'*) ;; (*) MEASURED="";; esac
1386
1821
  if [ -z "$MEASURED" ]; then
1387
- record browser-identity fail "a browser answers CDP on $((9222 + SLOT)) but did not say what it presents"
1822
+ record browser-identity fail "a browser answers CDP on ${COMPUTER_CDP_PORT} but did not say what it presents"
1388
1823
  else
1389
1824
  IDENTITY="$MEASURED"
1390
1825
  UA=$(printf '%s' "$MEASURED" | sed -n 's/.*"userAgent":"\\([^"]*\\)".*/\\1/p')
@@ -1473,10 +1908,24 @@ export const COMPUTER_RUNTIME_FILES: readonly {
1473
1908
  readonly mode: number;
1474
1909
  }[] = [
1475
1910
  {
1476
- path: `${RUNTIME_ROOT}/start-desktop.sh`,
1477
- content: startDesktopScript,
1911
+ path: `${RUNTIME_ROOT}/start-screen.sh`,
1912
+ content: startScreenScript,
1913
+ mode: 0o700,
1914
+ },
1915
+ {
1916
+ path: `${RUNTIME_ROOT}/start-browser.sh`,
1917
+ content: startBrowserScript,
1918
+ mode: 0o700,
1919
+ },
1920
+ {
1921
+ path: `${RUNTIME_ROOT}/start-view.sh`,
1922
+ content: startViewScript,
1478
1923
  mode: 0o700,
1479
1924
  },
1925
+ { path: ENSURE_WINDOW_SCRIPT, content: ensureWindowScript, mode: 0o700 },
1926
+ { path: FOCUS_WINDOW_SCRIPT, content: focusWindowScript, mode: 0o700 },
1927
+ { path: `${FLUXBOX_ROOT}/init`, content: fluxboxInit, mode: 0o644 },
1928
+ { path: `${FLUXBOX_ROOT}/overlay`, content: fluxboxOverlay, mode: 0o644 },
1480
1929
  { path: ENSURE_AGENT_SCRIPT, content: ensureAgentScript, mode: 0o700 },
1481
1930
  { path: CONTROL_SCRIPT, content: controlScript, mode: 0o700 },
1482
1931
  { path: BOUNDED_LOG_SCRIPT, content: boundedLogScript, mode: 0o700 },
@@ -1568,7 +2017,7 @@ fi`,
1568
2017
  {
1569
2018
  name: "runtime",
1570
2019
  label: "installing the Computer runtime",
1571
- body: `mkdir -p ${VIEWER_ROOT}
2020
+ body: `mkdir -p ${VIEWER_ROOT} ${FLUXBOX_ROOT}
1572
2021
  ${installDeclaredFiles(COMPUTER_RUNTIME_FILES)}
1573
2022
  # noVNC's ES modules in core/ import one another and ../vendor/pako. The links
1574
2023
  # keep that package-owned graph intact while FrockBot owns every rendered element.
@@ -1599,7 +2048,7 @@ if [ ! -x ${CHROMIUM_PATH} ]; then
1599
2048
  exit 1
1600
2049
  fi
1601
2050
  # A symlink, so the version in the build's directory name stays out of
1602
- # start-desktop.sh and an upgrade is one relink rather than a script change.
2051
+ # the launcher and an upgrade is one relink rather than a script change.
1603
2052
  ln -sfn "$CHROMIUM_BUILD" ${CHROMIUM_PATH}
1604
2053
  fi`,
1605
2054
  },