@frockbot/computer-host-runtime 0.3.11 → 0.3.13

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.13",
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,
@@ -443,10 +459,26 @@ state running`);
443
459
  const updateDocument = UPDATE_PHASES.map((phase) => phase.body).join("\n");
444
460
  expect(updateDocument).not.toContain("apt-get");
445
461
  expect(updateDocument).not.toContain("playwright-core/cli.js install");
446
- // An in-place update swaps names over files it owns and reaches no
447
- // network: the `applets` phase installs the shim and leaves the SDK's
462
+ // An in-place update swaps names over files it owns and leaves the SDK's
448
463
  // dependency tree — which an `applet dev` may be running out of — alone.
449
- expect(updateDocument).not.toContain("npm install");
464
+ // The one install it carries is the SDK itself, and only while the SDK
465
+ // is absent, which is exactly when nothing can be running out of it.
466
+ expect(updateDocument).not.toContain("miniflare@");
467
+ const installs = updateDocument
468
+ .split("\n")
469
+ .filter((line) => line.includes("npm install"));
470
+ expect(installs).toEqual([
471
+ ` if npm install --prefix ${APPLETS_ROOT} --no-audit --no-fund @frockbot/applet-sdk@${APPLET_SDK_VERSION}; then`,
472
+ ]);
473
+ expect(updateDocument).toContain(
474
+ `if [ ! -d ${APPLETS_ROOT}/node_modules/@frockbot/applet-sdk ]; then`,
475
+ );
476
+ });
477
+
478
+ test("the SDK install follows the published dist-tag, not a number", () => {
479
+ // v0.3.12 published `@frockbot/applet-sdk` as 0.3.12; a pinned "0.1.0"
480
+ // never existed and left every Computer without an SDK.
481
+ expect(APPLET_SDK_VERSION).toBe("latest");
450
482
  });
451
483
  });
452
484
 
@@ -608,22 +640,108 @@ describe("installed shell scripts", () => {
608
640
  // because the provisioning document is what creates it.
609
641
  expect(provisionScript).toContain(`${HOME_ROOT}/chrome-profile `);
610
642
  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.
643
+ // The flag set moved into the launcher (parity row 33); the browser
644
+ // service calls it and holds no flags of its own.
613
645
  expect(installedScript(provisionScript, CHROME_LAUNCHER)).toContain(
614
646
  `--user-data-dir=${HOME_ROOT}/chrome-profile`,
615
647
  );
616
648
  expect(
617
- installedScript(provisionScript, `${RUNTIME_ROOT}/start-desktop.sh`),
618
- ).toContain(`${CHROME_LAUNCHER} "$KEY"`);
649
+ installedScript(provisionScript, `${RUNTIME_ROOT}/start-browser.sh`),
650
+ ).toContain(`exec ${CHROME_LAUNCHER} about:blank`);
651
+ });
652
+
653
+ test("one browser holds the shared profile, and the launcher takes no Bot key", () => {
654
+ // ADR 0031. Chromium's singleton lock is per `--user-data-dir`, so a
655
+ // per-slot launch could only ever produce one browser: the first Bot to
656
+ // ask got a screen and the rest got "Opening in existing browser session"
657
+ // and a dead CDP port. One browser, one display, one port.
658
+ const launcher = installedScript(provisionScript, CHROME_LAUNCHER);
659
+ expect(launcher).toContain(`--remote-debugging-port=${COMPUTER_CDP_PORT}`);
660
+ expect(launcher).toContain(`export DISPLAY=${COMPUTER_DISPLAY}`);
661
+ expect(launcher).not.toContain("9222 + SLOT");
662
+ expect(launcher).not.toContain("100 + SLOT");
663
+ // The one thing the launcher may remove is a stale singleton file, and
664
+ // only when no browser is running. Never the profile: it is the User's
665
+ // login state, and every Bot's.
666
+ expect(launcher).toContain(`rm -f ${CHROME_PROFILE}/SingletonLock`);
667
+ expect(launcher).not.toContain(`rm -rf ${CHROME_PROFILE}`);
668
+ });
669
+
670
+ test("one screen carries every slot, and each viewer is clipped to one", () => {
671
+ const screen = installedScript(
672
+ provisionScript,
673
+ `${RUNTIME_ROOT}/start-screen.sh`,
674
+ );
675
+ expect(screen).toContain(
676
+ `Xvfb ${COMPUTER_DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SLOT_HEIGHT}x24`,
677
+ );
678
+ expect(SCREEN_WIDTH).toBe(SLOT_WIDTH * DESKTOP_SLOTS);
679
+ const view = installedScript(
680
+ provisionScript,
681
+ `${RUNTIME_ROOT}/start-view.sh`,
682
+ );
683
+ // `-clip`, never `-id`: a window id changes every time a Bot's window is
684
+ // re-created, and a VNC server bound to a dead window shows nothing.
685
+ expect(view).toContain(
686
+ `CLIP=${SLOT_WIDTH}x${SLOT_HEIGHT}+$((SLOT * ${SLOT_WIDTH}))+0`,
687
+ );
688
+ expect(view).toContain(
689
+ `exec x11vnc -display ${COMPUTER_DISPLAY} -clip "$CLIP"`,
690
+ );
691
+ expect(view).not.toContain("-id ");
692
+ });
693
+
694
+ test("fluxbox never reaches for a wallpaper setter, and hides its toolbar", () => {
695
+ // Every desktop carried an xmessage dialog reading "fbsetbg: I can't find
696
+ // an app to set the wallpaper with", because with no `~/.fluxbox` at all
697
+ // fluxbox writes its own defaults and applies the style's background.
698
+ expect(fluxboxOverlay).toContain("background: none");
699
+ expect(fluxboxInit).toContain("session.screen0.toolbar.visible: false");
700
+ expect(fluxboxInit).toContain(
701
+ `session.styleOverlay: ${FLUXBOX_ROOT}/overlay`,
702
+ );
703
+ expect(COMPUTER_RUNTIME_FILES.map((file) => file.path)).toEqual(
704
+ expect.arrayContaining([
705
+ `${FLUXBOX_ROOT}/init`,
706
+ `${FLUXBOX_ROOT}/overlay`,
707
+ ]),
708
+ );
709
+ expect(
710
+ installedScript(provisionScript, `${RUNTIME_ROOT}/start-screen.sh`),
711
+ ).toContain(`fluxbox -rc ${FLUXBOX_ROOT}/init`);
712
+ });
713
+
714
+ test("browser.mjs drives the Bot's own window and never another Bot's", () => {
715
+ expect(browserHelper).toContain("newWindow: true");
716
+ expect(browserHelper).toContain("Browser.setWindowBounds");
717
+ expect(browserHelper).toContain(
718
+ `const TARGET_ID_FILE = "${TARGET_ID_FILE}"`,
719
+ );
720
+ // The window helpers ask for exactly the actions this module declares.
721
+ for (const [encoded, action] of [
722
+ [BROWSER_ENSURE_ACTION, "ensure"],
723
+ [BROWSER_FOCUS_ACTION, "focus"],
724
+ [BROWSER_SURVEY_ACTION, "survey"],
725
+ ] as const) {
726
+ expect(
727
+ JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")),
728
+ ).toEqual({ action });
729
+ expect(browserHelper).toContain(`action.action === "${action}"`);
730
+ }
619
731
  });
620
732
 
621
733
  test("every script the provisioning document installs is valid bash", async () => {
622
734
  for (const path of [
623
- `${RUNTIME_ROOT}/start-desktop.sh`,
735
+ `${RUNTIME_ROOT}/start-screen.sh`,
736
+ `${RUNTIME_ROOT}/start-browser.sh`,
737
+ `${RUNTIME_ROOT}/start-view.sh`,
738
+ ENSURE_WINDOW_SCRIPT,
739
+ FOCUS_WINDOW_SCRIPT,
624
740
  ENSURE_AGENT_SCRIPT,
625
741
  CONTROL_SCRIPT,
626
742
  BOUNDED_LOG_SCRIPT,
743
+ CHROME_LAUNCHER,
744
+ DOCTOR_SCRIPT,
627
745
  `${RUNTIME_ROOT}/start-gateway.sh`,
628
746
  ]) {
629
747
  await expectValidShell(installedScript(provisionScript, path));
@@ -828,13 +946,13 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
828
946
  test("reclaims an idle tenant's display and never a live one", async () => {
829
947
  const { directory, runtimeRoot, run } = await installEnsureScript();
830
948
  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
949
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
950
+ // Slot 2's tenant went quiet long ago; every other tenant is one this
833
951
  // provider ran something for moments ago.
834
952
  await seedTenant(
835
953
  runtimeRoot,
836
954
  slot,
837
- slot === 7 ? SLOT_IDLE_SECONDS + 600 : 5,
955
+ slot === 2 ? SLOT_IDLE_SECONDS + 600 : 5,
838
956
  );
839
957
  }
840
958
 
@@ -845,10 +963,10 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
845
963
  (
846
964
  await readFile(join(runtimeRoot, "bots/newcomer/slot"), "utf8")
847
965
  ).trim(),
848
- ).toBe("7");
966
+ ).toBe("2");
849
967
  // 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);
968
+ expect(existsSync(join(runtimeRoot, "bots/tenant-002/slot"))).toBe(false);
969
+ expect(existsSync(join(runtimeRoot, "bots/tenant-003/slot"))).toBe(true);
852
970
  } finally {
853
971
  await rm(directory, { recursive: true, force: true });
854
972
  }
@@ -857,7 +975,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
857
975
  test("refuses the new tenant when every display is live, rather than sharing one", async () => {
858
976
  const { directory, runtimeRoot, run } = await installEnsureScript();
859
977
  try {
860
- for (let slot = 0; slot < 100; slot += 1) {
978
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
861
979
  await seedTenant(runtimeRoot, slot, 5);
862
980
  }
863
981
 
@@ -874,7 +992,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
874
992
  test("an idle tenant under human control keeps its display", async () => {
875
993
  const { directory, runtimeRoot, run } = await installEnsureScript();
876
994
  try {
877
- for (let slot = 0; slot < 100; slot += 1) {
995
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
878
996
  // The only idle tenant is the one a human is watching right now.
879
997
  await seedTenant(
880
998
  runtimeRoot,
@@ -896,7 +1014,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
896
1014
  test("a fresh User-wide desktop lease keeps every idle display", async () => {
897
1015
  const { directory, runtimeRoot, run } = await installEnsureScript();
898
1016
  try {
899
- for (let slot = 0; slot < 100; slot += 1) {
1017
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
900
1018
  await seedTenant(runtimeRoot, slot, SLOT_IDLE_SECONDS + 600);
901
1019
  }
902
1020
  const leaseRoot = join(runtimeRoot, "bots", DESKTOP_GUI_LEASE_KEY);
@@ -915,7 +1033,7 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
915
1033
  test("skips a tenant whose viewer just renewed last-seen", async () => {
916
1034
  const { directory, runtimeRoot, run } = await installEnsureScript();
917
1035
  try {
918
- for (let slot = 0; slot < 100; slot += 1) {
1036
+ for (let slot = 0; slot < DESKTOP_SLOTS; slot += 1) {
919
1037
  await seedTenant(runtimeRoot, slot, SLOT_IDLE_SECONDS + 600);
920
1038
  }
921
1039
  // Viewer open/renew touches this existing registry fact. The reclaim
@@ -932,6 +1050,40 @@ describe("desktop slots are reclaimed from idle tenants only", () => {
932
1050
  await rm(directory, { recursive: true, force: true });
933
1051
  }
934
1052
  }, 30_000);
1053
+ test("prunes a slot from the superseded hundred-display layout", async () => {
1054
+ // The migration's registry half (ADR 0031). A Computer that allocated
1055
+ // displays 0-99 carries slots the one screen has no rectangle for; a window
1056
+ // pinned past its last slot is a window nobody can see. They are pruned
1057
+ // under the same lock that allocates, so the tenant re-allocates in range
1058
+ // on its next open — and nothing durable, and no profile, is touched.
1059
+ const { directory, runtimeRoot, run } = await installEnsureScript();
1060
+ try {
1061
+ const stale = await seedTenant(runtimeRoot, 7, 5);
1062
+ await writeFile(join(runtimeRoot, "bots", stale, "target-id"), "old\n");
1063
+
1064
+ const ensured = await run("newcomer");
1065
+
1066
+ expect(ensured.exitCode).toBe(0);
1067
+ expect(existsSync(join(runtimeRoot, "bots", stale, "slot"))).toBe(false);
1068
+ expect(existsSync(join(runtimeRoot, "bots", stale, "target-id"))).toBe(
1069
+ false,
1070
+ );
1071
+ expect(
1072
+ (
1073
+ await readFile(join(runtimeRoot, "bots/newcomer/slot"), "utf8")
1074
+ ).trim(),
1075
+ ).toBe("0");
1076
+ // One browser, one port: the file stays, and every tenant reads the same
1077
+ // number out of it.
1078
+ expect(
1079
+ (
1080
+ await readFile(join(runtimeRoot, "bots/newcomer/cdp-port"), "utf8")
1081
+ ).trim(),
1082
+ ).toBe(String(COMPUTER_CDP_PORT));
1083
+ } finally {
1084
+ await rm(directory, { recursive: true, force: true });
1085
+ }
1086
+ }, 30_000);
935
1087
  });
936
1088
 
937
1089
  describe("the background-process logger", () => {
@@ -1128,6 +1280,9 @@ describe("box-doctor", () => {
1128
1280
  "scratch",
1129
1281
  "desktop-gateway",
1130
1282
  "sync-watcher",
1283
+ "browser-process",
1284
+ "browser-cdp",
1285
+ "screen",
1131
1286
  "tenant-display",
1132
1287
  "browser",
1133
1288
  "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. */
@@ -142,8 +146,18 @@ export const APPLET_SHIM_PATH = `${BIN_ROOT}/applet`;
142
146
  * reported fact, not a Computer nobody can open.
143
147
  */
144
148
  export const APPLETS_SDK_FAILURE_PATH = `${APPLETS_ROOT}/.sdk-unavailable`;
145
- /** The Applets SDK the Computer authors and previews an Applet with. */
146
- export const APPLET_SDK_VERSION = "0.1.0";
149
+ /**
150
+ * The Applets SDK the Computer authors and previews an Applet with.
151
+ *
152
+ * A dist-tag, not a number: the release workflow stamps every published
153
+ * Package with the tag's version (v0.3.12 published `@frockbot/applet-sdk`
154
+ * as 0.3.12), and this document has no way to learn that version at
155
+ * provisioning time. A pinned "0.1.0" here never existed on npm, so every
156
+ * Computer provisioned before this line was written reported the SDK as
157
+ * unavailable. `latest` follows each release; a Computer installs it once
158
+ * and keeps that tree until it is provisioned again.
159
+ */
160
+ export const APPLET_SDK_VERSION = "latest";
147
161
  /** Pinned with the SDK: `applet dev` embeds this Miniflare, never wrangler. */
148
162
  export const MINIFLARE_VERSION = "5.20260828.0-alpha";
149
163
 
@@ -197,14 +211,15 @@ export const LEASE_MAX_AGE_SECONDS = 90;
197
211
  * How long a tenant's slot is held after the provider last opened or ran
198
212
  * anything for it.
199
213
  *
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.
214
+ * A slot is a *region of the one screen* (ADR 0031): an x offset on the
215
+ * Computer's single Xvfb, one browser window pinned over it, and one VNC port
216
+ * clipped to it. There are `DESKTOP_SLOTS` of them, so they are allocated on
217
+ * demand and reclaimed rather than owned for ever. What makes a tenant live is
218
+ * this provider having opened or executed for it recently, or a human holding
219
+ * its takeover lease; nothing on the Computer is evidence, because an
220
+ * exec-only tenant never opens a window at all. The threshold is declared here
221
+ * so a reclaim is a stated policy rather than a guess about who is still using
222
+ * a screen.
208
223
  */
209
224
  export const SLOT_IDLE_SECONDS = 900;
210
225
  /** Exit code the ensure script uses when every slot belongs to a live tenant. */
@@ -216,34 +231,91 @@ export const NO_SLOTS_MARKER = "__FROCKBOT_NO_SLOTS__";
216
231
  export const VNC_PORT_BASE = 5900;
217
232
 
218
233
  /**
219
- * The prefix of a tenant's own desktop service.
234
+ * How many Bots of one User can hold a screen region at once (ADR 0031).
235
+ *
236
+ * The Computer runs **one** Xvfb whose width is this many slots, so the number
237
+ * is a real resource bound rather than a policy: a 1280×720 slot costs about
238
+ * 3.5 MiB of framebuffer, and a hundred of them would be a 128 000-pixel-wide
239
+ * root window nobody asked for. Four is the agreed figure; a Computer whose
240
+ * every slot belongs to a live tenant refuses the next one rather than putting
241
+ * two Bots on one screen.
242
+ */
243
+ export const DESKTOP_SLOTS = 4;
244
+ /** One slot's width in pixels; a slot's x offset is this times its number. */
245
+ export const SLOT_WIDTH = 1280;
246
+ /** One slot's height. The screen is exactly this tall. */
247
+ export const SLOT_HEIGHT = 720;
248
+ /** The width of the Computer's single root window. */
249
+ export const SCREEN_WIDTH = SLOT_WIDTH * DESKTOP_SLOTS;
250
+ /** The one X display on a Computer. Every Bot's window lives on it. */
251
+ export const COMPUTER_DISPLAY_NUMBER = 100;
252
+ export const COMPUTER_DISPLAY = `:${COMPUTER_DISPLAY_NUMBER}`;
253
+ /**
254
+ * The one CDP port on a Computer.
255
+ *
256
+ * There is one browser process, because there is one profile: Chromium's
257
+ * singleton lock is per `--user-data-dir`, so a second launch against
258
+ * `${HOME_ROOT}/chrome-profile` never becomes a second browser — it prints
259
+ * "Opening in existing browser session" and exits, leaving its CDP port dead
260
+ * and its Bot's screen black. That is the defect ADR 0031 records; the model
261
+ * that replaces it is one browser, one port, one window per Bot.
262
+ */
263
+ export const COMPUTER_CDP_PORT = 9222;
264
+
265
+ /** The Computer's single Xvfb and window manager. */
266
+ export const SCREEN_SERVICE = "frockbot-screen";
267
+ /** The Computer's single Chromium, supervised so a crash comes back. */
268
+ export const BROWSER_SERVICE = "frockbot-browser";
269
+
270
+ /**
271
+ * The prefix of a tenant's own **viewer** service: one `x11vnc`, clipped to
272
+ * that tenant's slot of the shared screen.
220
273
  *
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
274
+ * One service per tenant rather than one for the Computer, because a viewer
275
+ * session is per Bot: the token the gateway resolves addresses this port, and
276
+ * this port shows this slot and nothing else. The prefix is declared because
277
+ * two call sites need the same answer — the host starts these, and the
225
278
  * `service` op reattaches them after a cold pause, which it may only do for a
226
279
  * Computer-provider-declared name.
227
280
  */
281
+ export const VIEW_TENANT_SERVICE_PREFIX = "frockbot-view-";
282
+
283
+ /** The tenant viewer service that `start-view.sh` runs under. */
284
+ export function viewServiceNameV1(botKey: string): string {
285
+ return `${VIEW_TENANT_SERVICE_PREFIX}${botKey}`;
286
+ }
287
+
288
+ /**
289
+ * The prefix of the **superseded** per-slot desktop service (ADR 0031).
290
+ *
291
+ * Each of these was an Xvfb, a window manager, a browser launch, and an
292
+ * `x11vnc` for one tenant. Only the first ever got a browser — the rest lost
293
+ * the profile's singleton lock — so the layout is gone. The name stays
294
+ * declared because an existing Computer still has these services registered
295
+ * and the migration has to stop and delete them by name.
296
+ */
228
297
  export const DESKTOP_TENANT_SERVICE_PREFIX = "frockbot-desktop-";
229
298
 
230
- /** The tenant desktop service that `start-desktop.sh` runs under. */
299
+ /** The superseded per-tenant desktop service name. Migration only. */
231
300
  export function desktopServiceNameV1(botKey: string): string {
232
301
  return `${DESKTOP_TENANT_SERVICE_PREFIX}${botKey}`;
233
302
  }
234
303
 
235
304
  /**
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.
305
+ * Printed by the attach probe when the tenant's own VNC port already answers.
306
+ *
307
+ * The probe is the whole reason attaching a tenant does not restart anything
308
+ * on every Turn: `createService` is a create-*or-update*, so calling it
309
+ * unconditionally would tear down a running `x11vnc` each time the Computer
310
+ * was opened. A listening VNC port is the one piece of evidence that the
311
+ * viewer behind it is real, which is more than the slot file can say: the slot
312
+ * is an allocation, not a running process.
245
313
  */
246
314
  export const DESKTOP_LIVE_MARKER = "__FROCKBOT_DESKTOP_LIVE__";
315
+ /** Printed by the same probe when the Computer's one CDP port answers. */
316
+ export const BROWSER_LIVE_MARKER = "__FROCKBOT_BROWSER_LIVE__";
317
+ /** Printed by the same probe when this tenant already has a window recorded. */
318
+ export const WINDOW_LIVE_MARKER = "__FROCKBOT_WINDOW_LIVE__";
247
319
  /** Prefix carrying the slot the attach exec already read back to the host. */
248
320
  export const DESKTOP_SLOT_PREFIX = "__FROCKBOT_DESKTOP_SLOT__";
249
321
 
@@ -311,14 +383,24 @@ export function shellGuiCommandV1(command: string): string | undefined {
311
383
  return match?.[1];
312
384
  }
313
385
 
386
+ /** The one browser profile every Bot of one User shares (ADR 0012). */
387
+ export const CHROME_PROFILE = `${HOME_ROOT}/chrome-profile`;
388
+
314
389
  /** The browser flags the Computer runs chromium under, in one place. */
315
390
  export const CHROMIUM_FLAGS: readonly string[] = [
316
391
  "--no-sandbox",
317
392
  "--disable-dev-shm-usage",
318
393
  "--disable-gpu",
319
- `--user-data-dir=${HOME_ROOT}/chrome-profile`,
394
+ `--user-data-dir=${CHROME_PROFILE}`,
320
395
  "--remote-debugging-address=127.0.0.1",
321
- "--start-maximized",
396
+ // Not `--start-maximized`: a window belongs to one Bot's slot, and the slot
397
+ // is a region of a screen `DESKTOP_SLOTS` windows wide. Every window is
398
+ // placed by `Browser.setWindowBounds` once it exists; this is only the size
399
+ // the first one opens at.
400
+ `--window-size=${SLOT_WIDTH},${SLOT_HEIGHT}`,
401
+ "--window-position=0,0",
402
+ "--no-first-run",
403
+ "--no-default-browser-check",
322
404
  ];
323
405
 
324
406
  export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
@@ -326,34 +408,33 @@ export const CHROME_LAUNCHER = `${BIN_ROOT}/frockbot-chrome`;
326
408
  /**
327
409
  * The single place the Computer's chromium flags live (parity row 33).
328
410
  *
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.
411
+ * It takes no Bot key any more (ADR 0031). There is one browser on a Computer
412
+ * because there is one profile, so there is one display and one CDP port to
413
+ * derive: the arithmetic that used to turn a slot into a port is gone, and a
414
+ * slot now only says *where on the screen* a Bot's window sits. `start-browser.sh`
415
+ * calls this, and so may a human debugging the box; nothing else needs to know
416
+ * the flag set exists.
333
417
  */
334
418
  export const chromeLauncherScript = `#!/usr/bin/env bash
335
419
  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))"
420
+ export DISPLAY=${COMPUTER_DISPLAY}
348
421
  export ${SANCTIONED_SURFACE_ENV}=1
349
422
  if [ ! -x ${CHROMIUM_PATH} ]; then
350
423
  echo "no browser is installed at ${CHROMIUM_PATH}; the Computer installs one when it is provisioned" >&2
351
424
  exit 69
352
425
  fi
426
+ # The singleton files, and never the profile. Chromium's lock is per
427
+ # user-data-dir and is left behind by a browser the platform killed rather than
428
+ # stopped; a stale one makes the next launch print "Opening in existing browser
429
+ # session" and exit. Removed only when no browser is actually running, because
430
+ # two Chromiums on one profile is the one thing worse than none.
431
+ if ! pgrep -f -- "--remote-debugging-port=${COMPUTER_CDP_PORT}" >/dev/null 2>&1; then
432
+ rm -f ${CHROME_PROFILE}/SingletonLock ${CHROME_PROFILE}/SingletonSocket ${CHROME_PROFILE}/SingletonCookie
433
+ fi
353
434
  # By absolute path, not by name: the browser is Playwright's own build behind a
354
435
  # stable symlink, and reaching it through PATH would go past the shim that
355
436
  # covers the name chromium.
356
- exec ${CHROMIUM_PATH} ${CHROMIUM_FLAGS.join(" ")} --remote-debugging-port="$((9222 + SLOT))" "$@"
437
+ exec ${CHROMIUM_PATH} ${CHROMIUM_FLAGS.join(" ")} --remote-debugging-port=${COMPUTER_CDP_PORT} "$@"
357
438
  `;
358
439
 
359
440
  /**
@@ -382,30 +463,155 @@ exit 64
382
463
  `;
383
464
  }
384
465
 
385
- export const startDesktopScript = `#!/usr/bin/env bash
466
+ /** Where fluxbox is told what it may and may not do, on this Computer. */
467
+ export const FLUXBOX_ROOT = `${HOME_ROOT}/.fluxbox`;
468
+
469
+ /**
470
+ * fluxbox's configuration, declared rather than generated.
471
+ *
472
+ * With no `~/.fluxbox` at all, fluxbox writes its own defaults and then
473
+ * applies the default style's background by calling `fbsetbg` — which is not
474
+ * installed, and whose failure is an `xmessage` dialog reading "fbsetbg: I
475
+ * can't find an app to set the wallpaper with" sitting on top of every Bot's
476
+ * screen. `background: none` in the style overlay is the documented way to
477
+ * tell fluxbox not to set a background at all, which is what a screen made
478
+ * entirely of browser windows wants.
479
+ *
480
+ * The toolbar goes for the same reason: the viewer shows a Bot's 1280×720 slot
481
+ * and nothing else, and a window-list bar across the bottom of it is fluxbox's
482
+ * chrome in FrockBot's frame.
483
+ */
484
+ export const fluxboxInit = `session.screen0.toolbar.visible: false
485
+ session.screen0.slit.autoHide: true
486
+ session.screen0.workspaces: 1
487
+ session.screen0.workspacewarping: false
488
+ session.screen0.defaultDeco: NONE
489
+ session.screen0.focusModel: ClickToFocus
490
+ session.screen0.tabs.usePixmap: false
491
+ session.screen0.fullMaximization: false
492
+ session.styleOverlay: ${FLUXBOX_ROOT}/overlay
493
+ session.configVersion: 13
494
+ `;
495
+
496
+ /** The style overlay whose one job is to stop fluxbox reaching for fbsetbg. */
497
+ export const fluxboxOverlay = `background: none
498
+ `;
499
+
500
+ /**
501
+ * The Computer's one screen: a single Xvfb `DESKTOP_SLOTS` slots wide, and a
502
+ * window manager over it (ADR 0031).
503
+ *
504
+ * One Xvfb per Computer rather than one per slot, because there is one browser
505
+ * per Computer — Chromium's singleton lock is per profile and the profile is
506
+ * the User's — and a browser can only put its windows on the display it was
507
+ * launched under. Each Bot gets a *region* of this screen instead of a display
508
+ * of its own: window pinned by CDP, VNC clipped to the same rectangle.
509
+ */
510
+ export const startScreenScript = `#!/usr/bin/env bash
386
511
  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
512
  # The desktop stack *is* the sanctioned surface, so the shims step aside for
395
513
  # it. Everything a Bot's own shell runs arrives without this set.
396
514
  export ${SANCTIONED_SURFACE_ENV}=1
515
+ export DISPLAY=${COMPUTER_DISPLAY}
516
+ export HOME=${HOME_ROOT}
397
517
  cleanup() {
398
518
  jobs -pr | xargs -r kill >/dev/null 2>&1 || true
399
519
  }
400
520
  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"
521
+ rm -f "/tmp/.X${COMPUTER_DISPLAY_NUMBER}-lock" "/tmp/.X11-unix/X${COMPUTER_DISPLAY_NUMBER}"
522
+ Xvfb ${COMPUTER_DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SLOT_HEIGHT}x24 -nolisten tcp &
523
+ XVFB_PID=$!
524
+ for _ in $(seq 1 100); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.1; done
525
+ mkdir -p ${FLUXBOX_ROOT}
526
+ fluxbox -rc ${FLUXBOX_ROOT}/init >${RUNTIME_ROOT}/fluxbox.log 2>&1 &
527
+ wait "$XVFB_PID"
528
+ `;
529
+
530
+ /**
531
+ * The Computer's one browser, supervised (ADR 0031).
532
+ *
533
+ * Its own service rather than a background job of the screen's, so a Chromium
534
+ * that crashes is restarted by the platform without taking the screen — and
535
+ * every Bot's window — down with it. Each Bot re-creates its window on its
536
+ * next action, which is what `ensure-window.sh` is for.
537
+ */
538
+ export const startBrowserScript = `#!/usr/bin/env bash
539
+ set -eu
540
+ export ${SANCTIONED_SURFACE_ENV}=1
541
+ export DISPLAY=${COMPUTER_DISPLAY}
542
+ # The screen is a separate service, so this one may start first. Wait for the
543
+ # display rather than failing: a service that exits is a service the platform
544
+ # restarts, and a browser started before its X server never draws anything.
545
+ for _ in $(seq 1 300); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.2; done
546
+ if ! xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
547
+ echo "no X server on ${COMPUTER_DISPLAY} after 60s; the ${SCREEN_SERVICE} service is what starts one" >&2
548
+ exit 69
549
+ fi
550
+ exec ${CHROME_LAUNCHER} about:blank
551
+ `;
552
+
553
+ /**
554
+ * One Bot's viewer: an `x11vnc` clipped to that Bot's slot of the one screen.
555
+ *
556
+ * `-clip`, not `-id`: a window id changes every time the Bot's window is
557
+ * re-created, and a VNC server bound to a dead window shows nothing. The
558
+ * rectangle is stable for as long as the Bot holds the slot.
559
+ */
560
+ export const startViewScript = `#!/usr/bin/env bash
561
+ set -eu
562
+ KEY="$1"
563
+ ROOT=${RUNTIME_ROOT}
564
+ BOT="$ROOT/bots/$KEY"
565
+ SLOT=$(cat "$BOT/slot")
566
+ VNC_PORT=$((${VNC_PORT_BASE} + SLOT))
567
+ CLIP=${SLOT_WIDTH}x${SLOT_HEIGHT}+$((SLOT * ${SLOT_WIDTH}))+0
568
+ export ${SANCTIONED_SURFACE_ENV}=1
569
+ export DISPLAY=${COMPUTER_DISPLAY}
570
+ for _ in $(seq 1 300); do xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1 && break; sleep 0.2; done
571
+ if ! xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
572
+ echo "no X server on ${COMPUTER_DISPLAY} after 60s; the ${SCREEN_SERVICE} service is what starts one" >&2
573
+ exit 69
574
+ fi
575
+ exec x11vnc -display ${COMPUTER_DISPLAY} -clip "$CLIP" -forever -shared -rfbport "$VNC_PORT" -passwd "$(cat "$BOT/vnc-password")"
576
+ `;
577
+
578
+ /** What `ensure-window.sh` asks `browser.mjs`, base64url as it takes it. */
579
+ export const BROWSER_ENSURE_ACTION = "eyJhY3Rpb24iOiJlbnN1cmUifQ";
580
+ /** What `focus-window.sh` asks it, when a human takes this Computer over. */
581
+ export const BROWSER_FOCUS_ACTION = "eyJhY3Rpb24iOiJmb2N1cyJ9";
582
+ /** What box-doctor asks it, to report every tenant's window at once. */
583
+ export const BROWSER_SURVEY_ACTION = "eyJhY3Rpb24iOiJzdXJ2ZXkifQ";
584
+
585
+ /** Where the Bot's own browser window is recorded, under its Bot directory. */
586
+ export const TARGET_ID_FILE = "target-id";
587
+
588
+ /**
589
+ * Gives one Bot its window on the shared screen, and pins it to its slot.
590
+ *
591
+ * Idempotent by construction: it re-uses the recorded target when that target
592
+ * is still a live page and creates a new window when it is not, so a browser
593
+ * that crashed and came back costs one window per Bot on their next action and
594
+ * nothing else.
595
+ */
596
+ export const ensureWindowScript = `#!/usr/bin/env bash
597
+ set -eu
598
+ KEY="$1"
599
+ export ${SANCTIONED_SURFACE_ENV}=1
600
+ exec timeout 30 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_ENSURE_ACTION} "$KEY"
601
+ `;
602
+
603
+ /**
604
+ * Brings one Bot's window to the front, for a human taking the Computer over.
605
+ *
606
+ * Best effort and non-fatal: a takeover whose window could not be raised is a
607
+ * takeover of a screen showing the wrong Bot, which is worth reporting and is
608
+ * not worth refusing the lease over.
609
+ */
610
+ export const focusWindowScript = `#!/usr/bin/env bash
611
+ set -eu
612
+ KEY="$1"
613
+ export ${SANCTIONED_SURFACE_ENV}=1
614
+ exec timeout 20 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_FOCUS_ACTION} "$KEY"
409
615
  `;
410
616
 
411
617
  export const ensureAgentScript = `#!/usr/bin/env bash
@@ -425,29 +631,45 @@ chmod 600 "$PROFILE_TMP"
425
631
  mv "$PROFILE_TMP" "$AGENT_DATA/profile.json"
426
632
  exec 9>"$ROOT/registry.lock"
427
633
  flock -x 9
634
+ # Slots allocated under the superseded hundred-display layout (ADR 0031) cannot
635
+ # be shown on the one screen: it has ${DESKTOP_SLOTS} rectangles on it, and a
636
+ # window pinned past the last of them is a window nobody can see behind a clip
637
+ # x11vnc refuses. Pruned under the same lock that allocates, so a migrated
638
+ # Computer re-allocates in range on the tenant's next open. Only the
639
+ # provider-owned registry files go; nothing durable, and never the profile.
640
+ for SLOT_FILE in "$ROOT"/bots/*/slot; do
641
+ [ -s "$SLOT_FILE" ] || continue
642
+ SLOT_VALUE=$(cat "$SLOT_FILE")
643
+ SLOT_BOT=$(dirname "$SLOT_FILE")
644
+ case "$SLOT_VALUE" in
645
+ (''|*[!0-9]*) rm -f "$SLOT_FILE" "$SLOT_BOT/${TARGET_ID_FILE}" "$SLOT_BOT/cdp-port"; continue;;
646
+ esac
647
+ if [ "$SLOT_VALUE" -ge ${DESKTOP_SLOTS} ]; then
648
+ rm -f "$SLOT_FILE" "$SLOT_BOT/${TARGET_ID_FILE}" "$SLOT_BOT/cdp-port"
649
+ fi
650
+ done
428
651
  if [ ! -s "$BOT/slot" ]; then
429
652
  # Every slot in use, read once. The registry lock is held, so the answer
430
653
  # cannot change under this scan, and one read beats one per slot per tenant
431
654
  # when a Computer is close to full.
432
655
  USED=" $(cat "$ROOT"/bots/*/slot 2>/dev/null | tr '\n' ' ') "
433
656
  SLOT=0
434
- while [ "$SLOT" -lt 100 ]; do
657
+ while [ "$SLOT" -lt ${DESKTOP_SLOTS} ]; do
435
658
  case "$USED" in (*" $SLOT "*) ;; (*) break ;; esac
436
659
  SLOT=$((SLOT + 1))
437
660
  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.
661
+ if [ "$SLOT" -ge ${DESKTOP_SLOTS} ]; then
662
+ # A slot is a region of the one screen, not durable state: it is the x
663
+ # offset a tenant's browser window is pinned at and the VNC port clipped to
664
+ # it. A tenant that never comes back would otherwise hold one for ever, so
665
+ # the allocation is bounded rather than permanent.
444
666
  #
445
667
  # Liveness is decided by the provider's own registry, never by the
446
668
  # Computer's state: "last-seen" is written by the backend every time it
447
669
  # 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
670
+ # lease. A window proves nothing — the browser is restarted under every
671
+ # tenant at once, and a tenant that only ever execs never opens one — so a
672
+ # slot is reclaimed only when its tenant has been idle past the threshold
451
673
  # AND no viewer lease is fresh. Its viewer token goes with the slot, or
452
674
  # that token would address another Bot's screen. When every slot belongs to
453
675
  # a live tenant the new tenant is refused: sharing a display would put two
@@ -496,7 +718,9 @@ if [ ! -s "$BOT/slot" ]; then
496
718
  chmod 600 "$VTMP"
497
719
  mv "$VTMP" "$ROOT/tokens"
498
720
  fi
499
- rm -f "$VICTIM" "$VICTIM_BOT/cdp-port"
721
+ # The window goes with the slot: the next holder of this rectangle must
722
+ # not inherit a target id addressing the previous tenant's window.
723
+ rm -f "$VICTIM" "$VICTIM_BOT/cdp-port" "$VICTIM_BOT/${TARGET_ID_FILE}"
500
724
  fi
501
725
  printf '%s\n' "$SLOT" > "$BOT/slot"
502
726
  fi
@@ -505,7 +729,9 @@ fi
505
729
  # the registry entry the reclaim reads to decide whether a tenant is live.
506
730
  touch "$BOT/slot" "$BOT/last-seen"
507
731
  SLOT=$(cat "$BOT/slot")
508
- printf '%s\n' "$((9222 + SLOT))" > "$BOT/cdp-port"
732
+ # One browser on this Computer, so one port for every tenant. The file stays
733
+ # because callers read it rather than deriving a port of their own.
734
+ printf '%s\n' "${COMPUTER_CDP_PORT}" > "$BOT/cdp-port"
509
735
  if [ ! -s "$BOT/vnc-password" ]; then
510
736
  umask 077
511
737
  head -c 32 /dev/urandom | base64 | tr -d '\n=+/' > "$BOT/vnc-password"
@@ -641,13 +867,172 @@ case "$ACTION" in
641
867
  esac
642
868
  `;
643
869
 
644
- export const browserHelper = `import { chromium } from "playwright-core";
870
+ /**
871
+ * The one program that drives this Computer's browser (ADR 0031).
872
+ *
873
+ * There is one Chromium and one CDP port, and each Bot owns one *window* on
874
+ * it. The window is recorded at `<bot>/target-id` and re-created when it is
875
+ * gone, so a browser that crashed costs each Bot one new window and nothing
876
+ * else. A Bot may open as many tabs inside its own window as it likes; this
877
+ * program never touches a target belonging to another Bot's window.
878
+ *
879
+ * Isolation between two Bots of one User is therefore weaker than it looks:
880
+ * one profile, one CDP port, one process. That is the trade ADR 0031 records —
881
+ * the requirement is that a login one Bot makes is a login all of them have —
882
+ * and the sanctioned-surface shims remain the line of defence.
883
+ */
884
+ export const browserHelper = `import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
885
+ import { chromium } from "playwright-core";
886
+
887
+ const BOTS_ROOT = "${BOTS_ROOT}";
888
+ const TARGET_ID_FILE = "${TARGET_ID_FILE}";
889
+ const SLOT_WIDTH = ${SLOT_WIDTH};
890
+ const SLOT_HEIGHT = ${SLOT_HEIGHT};
891
+
645
892
  const port = Number(process.argv[2]);
646
893
  const action = JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8"));
894
+ const botKey = process.argv[4] ?? "";
895
+
896
+ const botDir = (key) => \`\${BOTS_ROOT}/\${key}\`;
897
+ const targetPath = (key) => \`\${botDir(key)}/\${TARGET_ID_FILE}\`;
898
+
899
+ function slotOf(key) {
900
+ const raw = readFileSync(\`\${botDir(key)}/slot\`, "utf8").trim();
901
+ if (!/^\\d+$/.test(raw)) throw new Error(\`Bot "\${key}" holds no desktop slot\`);
902
+ return Number(raw);
903
+ }
904
+
905
+ function recordedTarget(key) {
906
+ try {
907
+ return readFileSync(targetPath(key), "utf8").trim();
908
+ } catch {
909
+ return "";
910
+ }
911
+ }
912
+
913
+ function boundsFor(slot) {
914
+ return { left: slot * SLOT_WIDTH, top: 0, width: SLOT_WIDTH, height: SLOT_HEIGHT, windowState: "normal" };
915
+ }
916
+
917
+ function placed(bounds, slot) {
918
+ const want = boundsFor(slot);
919
+ return Boolean(bounds) && bounds.left === want.left && bounds.top === want.top && bounds.width === want.width && bounds.height === want.height;
920
+ }
921
+
647
922
  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();
923
+ const cdp = await browser.newBrowserCDPSession();
924
+
925
+ async function pageTargets() {
926
+ const { targetInfos } = await cdp.send("Target.getTargets");
927
+ return targetInfos.filter((info) => info.type === "page");
928
+ }
929
+
930
+ /** The Bot's own window: the recorded one when it is alive, a new one when not. */
931
+ async function ensureWindow(key) {
932
+ const slot = slotOf(key);
933
+ let targetId = recordedTarget(key);
934
+ if (!targetId || !(await pageTargets()).some((info) => info.targetId === targetId)) {
935
+ ({ targetId } = await cdp.send("Target.createTarget", { url: "about:blank", newWindow: true }));
936
+ mkdirSync(botDir(key), { recursive: true });
937
+ writeFileSync(targetPath(key), \`\${targetId}\\n\`, { mode: 0o600 });
938
+ }
939
+ const { windowId } = await cdp.send("Browser.getWindowForTarget", { targetId });
940
+ await cdp.send("Browser.setWindowBounds", { windowId, bounds: boundsFor(slot) });
941
+ return { targetId, windowId, slot };
942
+ }
943
+
944
+ /** The Playwright page for one target id, waited for: CDP creates it, Playwright discovers it. */
945
+ async function pageFor(targetId) {
946
+ for (let attempt = 0; attempt < 50; attempt += 1) {
947
+ for (const context of browser.contexts()) {
948
+ for (const candidate of context.pages()) {
949
+ if ((await targetIdOf(context, candidate)) === targetId) return candidate;
950
+ }
951
+ }
952
+ await new Promise((resolve) => setTimeout(resolve, 100));
953
+ }
954
+ return undefined;
955
+ }
956
+
957
+ async function targetIdOf(context, page) {
958
+ const session = await context.newCDPSession(page);
959
+ const { targetInfo } = await session.send("Target.getTargetInfo");
960
+ await session.detach().catch(() => {});
961
+ return targetInfo.targetId;
962
+ }
963
+
964
+ /** Every page in the Bot's own window. Tabs it opened there are its own. */
965
+ async function pagesInWindow(windowId) {
966
+ const own = [];
967
+ for (const context of browser.contexts()) {
968
+ for (const candidate of context.pages()) {
969
+ const targetId = await targetIdOf(context, candidate);
970
+ const window = await cdp
971
+ .send("Browser.getWindowForTarget", { targetId })
972
+ .catch(() => undefined);
973
+ if (window && window.windowId === windowId) own.push(candidate);
974
+ }
975
+ }
976
+ return own;
977
+ }
978
+
979
+ async function done(value) {
980
+ if (value !== undefined) console.log(JSON.stringify(value));
981
+ await browser.close();
982
+ process.exit(0);
983
+ }
984
+
985
+ // box-doctor's whole-Computer view (ADR 0031): every tenant that holds a slot,
986
+ // whether its window exists, and whether it sits over its own slot. One CDP
987
+ // connection for the Computer rather than one probe per Bot.
988
+ if (action.action === "survey") {
989
+ const infos = await pageTargets();
990
+ const rows = [];
991
+ for (const entry of readdirSync(BOTS_ROOT, { withFileTypes: true })) {
992
+ if (!entry.isDirectory()) continue;
993
+ let slot;
994
+ try {
995
+ slot = slotOf(entry.name);
996
+ } catch {
997
+ continue;
998
+ }
999
+ const targetId = recordedTarget(entry.name);
1000
+ const alive = Boolean(targetId) && infos.some((info) => info.targetId === targetId);
1001
+ let bounds;
1002
+ if (alive) {
1003
+ const { windowId } = await cdp.send("Browser.getWindowForTarget", { targetId });
1004
+ ({ bounds } = await cdp.send("Browser.getWindowBounds", { windowId }));
1005
+ }
1006
+ rows.push({ key: entry.name, slot, targetId, alive, placed: alive && placed(bounds, slot) });
1007
+ }
1008
+ await done({ tenants: rows.sort((left, right) => left.slot - right.slot) });
1009
+ }
1010
+
1011
+ // The Bot's window, created and pinned, and nothing else. What an open runs.
1012
+ if (action.action === "ensure") {
1013
+ await done(await ensureWindow(botKey));
1014
+ }
1015
+
1016
+ const anchor = botKey ? await ensureWindow(botKey) : undefined;
1017
+
1018
+ // A human is taking this Computer over: raise the Bot's window so the screen
1019
+ // they are handed is the Bot's, not whichever window Chromium last focused.
1020
+ if (action.action === "focus") {
1021
+ const focusPage = anchor ? await pageFor(anchor.targetId) : undefined;
1022
+ if (focusPage) await focusPage.bringToFront();
1023
+ await done({ focused: Boolean(focusPage), ...(anchor ? { targetId: anchor.targetId } : {}) });
1024
+ }
1025
+
1026
+ const own = anchor ? await pagesInWindow(anchor.windowId) : [];
1027
+ const page =
1028
+ own.at(-1) ??
1029
+ (anchor ? await pageFor(anchor.targetId) : undefined) ??
1030
+ browser.contexts()[0]?.pages().at(-1);
1031
+ if (!page) {
1032
+ console.error("this Computer's browser has no page for this Bot");
1033
+ await browser.close();
1034
+ process.exit(69);
1035
+ }
651
1036
  // box-doctor's browser-identity measurement (parity row 34b). It answers
652
1037
  // before any navigation and before the snapshot, so the check reads what the
653
1038
  // browser presents without moving the page a human or a Bot left open.
@@ -657,9 +1042,7 @@ if (action.action === "identity") {
657
1042
  webdriver: navigator.webdriver === true,
658
1043
  brands: (navigator.userAgentData?.brands ?? []).map((brand) => \`\${brand.brand}/\${brand.version}\`),
659
1044
  }));
660
- console.log(JSON.stringify(identity));
661
- await browser.close();
662
- process.exit(0);
1045
+ await done(identity);
663
1046
  }
664
1047
  if (action.action === "navigate") await page.goto(action.url, { waitUntil: "domcontentloaded" });
665
1048
  if (action.action === "click") await page.getByRole(action.role, { name: action.name, exact: action.exact ?? false }).click();
@@ -667,8 +1050,7 @@ if (action.action === "fill") await page.getByLabel(action.label, { exact: actio
667
1050
  if (action.action === "press") await page.keyboard.press(action.key);
668
1051
  if (action.action === "wait") await page.waitForTimeout(action.milliseconds ?? 1000);
669
1052
  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();
1053
+ await done({ url: page.url(), title: await page.title(), snapshot });
672
1054
  `;
673
1055
 
674
1056
  export const syncWatchScript = `#!/usr/bin/env bash
@@ -883,7 +1265,7 @@ export const CLOCK_FLOOR_EPOCH = 1_756_684_800;
883
1265
  * corrected. The version is compared on every adoption instead, and the whole
884
1266
  * set is rewritten when it moves. Bump it whenever a document below changes.
885
1267
  */
886
- export const REFERENCE_DOCS_VERSION = "2026-09-03.1";
1268
+ export const REFERENCE_DOCS_VERSION = "2026-09-04.1";
887
1269
 
888
1270
  /**
889
1271
  * What a Bot reads to debug its own Computer.
@@ -900,8 +1282,8 @@ export const REFERENCE_DOCS: readonly { name: string; content: string }[] = [
900
1282
  content: `# Your FrockBot Computer
901
1283
 
902
1284
  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.
1285
+ your own window on its one screen; the browser and its profile are shared, so a
1286
+ login one Bot makes is a login all of them have.
905
1287
 
906
1288
  - \`layout.md\` — what is durable, what is scratch, and what is lost when.
907
1289
  - \`browser.md\` — how the browser is launched and driven, and what never is.
@@ -990,11 +1372,27 @@ accessibility snapshot, which is what you should read a page from.
990
1372
  \`computer_screenshot\` captures your own desktop as an image and files it in
991
1373
  your durable screenshots root.
992
1374
 
1375
+ ## One browser, one window each
1376
+
1377
+ There is exactly one browser process on this Computer, because there is one
1378
+ profile: Chromium's lock is per profile, and a second launch against it is not
1379
+ a second browser. Each Bot gets one **window** on that browser, pinned over its
1380
+ own slot of the one screen.
1381
+
1382
+ - Tabs you open inside your own window are yours; use as many as you like.
1383
+ - A login one Bot makes is a login every Bot has, the instant it is made — the
1384
+ cookie jar is the profile, and the profile is shared.
1385
+ - Other Bots' windows are not yours to drive, read, or close. Nothing stops
1386
+ you at the CDP layer; this is a rule, not a wall.
1387
+ - If your window is gone — the browser crashed and came back — the next
1388
+ \`computer_browser\` action opens you a new one.
1389
+
993
1390
  ## Launching it
994
1391
 
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.
1392
+ \`${CHROME_LAUNCHER}\` is the only sanctioned launcher. It takes no Bot key any
1393
+ more: it holds the flag set and starts the Computer's one browser on the one
1394
+ display and the one CDP port. The \`${BROWSER_SERVICE}\` service calls it, and
1395
+ nothing else needs to know the flags exist.
998
1396
 
999
1397
  ## What is never run from the shell
1000
1398
 
@@ -1017,8 +1415,10 @@ tools do.
1017
1415
 
1018
1416
  \`computer_doctor\` runs \`${DOCTOR_SCRIPT}\` and hands back a report: disk on
1019
1417
  \`/\` 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
1418
+ gateway, the durable-root watcher, the shared screen, the one browser process
1419
+ and its CDP port, every Bot's window and whether it sits over that Bot's own
1420
+ slot, the browser build and its profile, the sync signal and any conflicting
1421
+ generations, this reference
1022
1422
  set's version, the launcher and its shims, the clock, DNS, and whether a
1023
1423
  provisioning hold is still keeping this Computer awake.
1024
1424
 
@@ -1037,8 +1437,13 @@ Computer rather than of one run.
1037
1437
  - **sync-signal with conflicts** — a write landed on a generation its writer
1038
1438
  had not seen. The conflicting generation is preserved, never merged; say so
1039
1439
  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.
1440
+ - **tenant-display-<botKey>**that Bot has no window on the screen, or its
1441
+ window is not over its own slot. A window comes back on that Bot's next
1442
+ browser action. Slots are allocated on demand, and a Computer with all of
1443
+ them in use will say so.
1444
+ - **browser-process** — none, or more than one. One is the whole design: the
1445
+ profile's lock admits exactly one browser, and a second one is a Bot with a
1446
+ black screen.
1042
1447
  - **reference-docs** — this set is stale and refreshes when the Computer is
1043
1448
  next opened. Nothing you can do on the box fixes it.
1044
1449
  - **browser** — the browser build is missing. It is installed by provisioning,
@@ -1237,6 +1642,29 @@ fi
1237
1642
  exec "$APPLET" "$@"
1238
1643
  `;
1239
1644
 
1645
+ /**
1646
+ * Installs the Applets SDK when, and only when, it is absent.
1647
+ *
1648
+ * Guarded, and deliberately not fatal: a Computer that cannot fetch the SDK
1649
+ * is still a Computer — it browses, execs, and syncs — so the failure is
1650
+ * recorded as a file the doctor reports under `applets-sdk` rather than as a
1651
+ * phase that fails and leaves the whole run resumable-but-unfinished.
1652
+ *
1653
+ * The `[ ! -d ]` guard is also what makes this safe inside an in-place
1654
+ * update. The only thing that runs out of `${APPLETS_ROOT}` is `applet dev`,
1655
+ * and the shim refuses to start it while the SDK is missing, so an install
1656
+ * that runs only when the SDK is missing never touches a tree a process is
1657
+ * using. Once the SDK is present neither provisioning nor an update installs
1658
+ * again; a newer SDK arrives with the next provisioning adoption.
1659
+ */
1660
+ export const appletSdkInstallScript = `if [ ! -d ${APPLETS_ROOT}/node_modules/@frockbot/applet-sdk ]; then
1661
+ if npm install --prefix ${APPLETS_ROOT} --no-audit --no-fund @frockbot/applet-sdk@${APPLET_SDK_VERSION}; then
1662
+ rm -f ${APPLETS_SDK_FAILURE_PATH}
1663
+ else
1664
+ printf '%s\\n' "npm could not install @frockbot/applet-sdk@${APPLET_SDK_VERSION}" > ${APPLETS_SDK_FAILURE_PATH}
1665
+ fi
1666
+ fi`;
1667
+
1240
1668
  /** The files the `applets` phase installs, with their modes. */
1241
1669
  export const APPLETS_RUNTIME_FILES: readonly {
1242
1670
  readonly path: string;
@@ -1338,26 +1766,66 @@ else
1338
1766
  fi
1339
1767
  SLOT=""
1340
1768
  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"
1769
+ # One browser, one CDP port (ADR 0031). A second main process would mean a
1770
+ # second browser holding or failing to hold the one shared profile, which
1771
+ # is the defect this layout replaced: the loser prints "Opening in existing
1772
+ # browser session", exits, and leaves its Bot a black screen.
1773
+ BROWSERS=$(pgrep -f -- "--remote-debugging-port=${COMPUTER_CDP_PORT}" 2>/dev/null | wc -l | tr -d ' ')
1774
+ if [ "$BROWSERS" = 1 ]; then
1775
+ record browser-process pass "exactly one browser process holds ${CHROME_PROFILE}"
1776
+ elif [ "$BROWSERS" = 0 ]; then
1777
+ record browser-process fail "no browser process is running; the ${BROWSER_SERVICE} service is what starts one"
1352
1778
  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"
1779
+ record browser-process fail "$BROWSERS browser processes hold ${CHROME_PROFILE}; only the first of them can own the profile"
1780
+ fi
1781
+ if (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1782
+ record browser-cdp pass "CDP answers on ${COMPUTER_CDP_PORT}"
1783
+ else
1784
+ record browser-cdp fail "nothing answers CDP on ${COMPUTER_CDP_PORT}; no Bot can be given a window"
1785
+ fi
1786
+ if xdpyinfo -display ${COMPUTER_DISPLAY} >/dev/null 2>&1; then
1787
+ record screen pass "the shared screen is up on ${COMPUTER_DISPLAY}, ${SCREEN_WIDTH}x${SLOT_HEIGHT} for ${DESKTOP_SLOTS} slots"
1788
+ else
1789
+ record screen fail "no X server on ${COMPUTER_DISPLAY}; the ${SCREEN_SERVICE} service is what starts one"
1790
+ fi
1791
+ # Every tenant, not just the one that asked (ADR 0031). One Bot's report used
1792
+ # to be the only evidence there was, which is exactly how three Bots sat on
1793
+ # black screens while the first one browsed.
1794
+ SURVEY=""
1795
+ if (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1796
+ SURVEY=$(timeout 20 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${BROWSER_SURVEY_ACTION} 2>/dev/null | tail -n 1)
1797
+ fi
1798
+ case "$SURVEY" in (*'"tenants"'*) ;; (*) SURVEY="";; esac
1799
+ TENANTS=0
1800
+ for SLOT_FILE in ${BOTS_ROOT}/*/slot; do
1801
+ [ -s "$SLOT_FILE" ] || continue
1802
+ TENANT=$(basename "$(dirname "$SLOT_FILE")")
1803
+ TENANT_SLOT=$(cat "$SLOT_FILE")
1804
+ TENANTS=$((TENANTS + 1))
1805
+ if [ -z "$SURVEY" ]; then
1806
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT and no browser could be asked about its window"
1807
+ continue
1808
+ fi
1809
+ ROW=$(printf '%s' "$SURVEY" | tr '{' '\\n' | grep "\\"key\\":\\"$TENANT\\"" | head -n 1)
1810
+ if [ -z "$ROW" ]; then
1811
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT but the browser reported no window for it"
1812
+ elif ! printf '%s' "$ROW" | grep -q '"alive":true'; then
1813
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" holds slot $TENANT_SLOT with no live window; it opens one on its next action"
1814
+ elif ! printf '%s' "$ROW" | grep -q '"placed":true'; then
1815
+ record "tenant-display-$TENANT" fail "Bot \\"$TENANT\\" has a window that is not over slot $TENANT_SLOT; its viewer shows another Bot's rectangle"
1816
+ else
1817
+ record "tenant-display-$TENANT" pass "Bot \\"$TENANT\\" has a live window pinned over slot $TENANT_SLOT of ${COMPUTER_DISPLAY}"
1818
+ fi
1819
+ done
1820
+ if [ "$TENANTS" = 0 ]; then
1821
+ record tenant-display pass "no Bot holds a slot on this Computer; the exec and file surfaces need no screen"
1354
1822
  fi
1355
1823
  if [ -x ${CHROMIUM_PATH} ]; then
1356
1824
  record browser pass "the browser is installed at ${CHROMIUM_PATH} ($(readlink -f ${CHROMIUM_PATH} 2>/dev/null || echo unresolved))"
1357
1825
  else
1358
1826
  record browser fail "no browser at ${CHROMIUM_PATH}; provisioning installs one, and no desktop can start without it"
1359
1827
  fi
1360
- PROFILE=${HOME_ROOT}/chrome-profile
1828
+ PROFILE=${CHROME_PROFILE}
1361
1829
  if [ -d "$PROFILE" ] && [ -w "$PROFILE" ]; then
1362
1830
  record browser-profile pass "the shared browser profile at $PROFILE is writable"
1363
1831
  else
@@ -1378,13 +1846,13 @@ fi
1378
1846
  IDENTITY=null
1379
1847
  if [ ! -x ${CHROMIUM_PATH} ]; then
1380
1848
  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
1849
+ elif ! (exec 3<>/dev/tcp/127.0.0.1/${COMPUTER_CDP_PORT}) 2>/dev/null; then
1382
1850
  record browser-identity pass "no browser answers CDP for this report, so nothing was asked what it announces itself as"
1383
1851
  else
1384
- MEASURED=$(timeout 15 node ${RUNTIME_ROOT}/browser.mjs $((9222 + SLOT)) ${DOCTOR_BROWSER_IDENTITY_ACTION} 2>/dev/null | tail -n 1)
1852
+ MEASURED=$(timeout 15 node ${RUNTIME_ROOT}/browser.mjs ${COMPUTER_CDP_PORT} ${DOCTOR_BROWSER_IDENTITY_ACTION} "$KEY" 2>/dev/null | tail -n 1)
1385
1853
  case "$MEASURED" in (*'"userAgent"'*) ;; (*) MEASURED="";; esac
1386
1854
  if [ -z "$MEASURED" ]; then
1387
- record browser-identity fail "a browser answers CDP on $((9222 + SLOT)) but did not say what it presents"
1855
+ record browser-identity fail "a browser answers CDP on ${COMPUTER_CDP_PORT} but did not say what it presents"
1388
1856
  else
1389
1857
  IDENTITY="$MEASURED"
1390
1858
  UA=$(printf '%s' "$MEASURED" | sed -n 's/.*"userAgent":"\\([^"]*\\)".*/\\1/p')
@@ -1473,10 +1941,24 @@ export const COMPUTER_RUNTIME_FILES: readonly {
1473
1941
  readonly mode: number;
1474
1942
  }[] = [
1475
1943
  {
1476
- path: `${RUNTIME_ROOT}/start-desktop.sh`,
1477
- content: startDesktopScript,
1944
+ path: `${RUNTIME_ROOT}/start-screen.sh`,
1945
+ content: startScreenScript,
1478
1946
  mode: 0o700,
1479
1947
  },
1948
+ {
1949
+ path: `${RUNTIME_ROOT}/start-browser.sh`,
1950
+ content: startBrowserScript,
1951
+ mode: 0o700,
1952
+ },
1953
+ {
1954
+ path: `${RUNTIME_ROOT}/start-view.sh`,
1955
+ content: startViewScript,
1956
+ mode: 0o700,
1957
+ },
1958
+ { path: ENSURE_WINDOW_SCRIPT, content: ensureWindowScript, mode: 0o700 },
1959
+ { path: FOCUS_WINDOW_SCRIPT, content: focusWindowScript, mode: 0o700 },
1960
+ { path: `${FLUXBOX_ROOT}/init`, content: fluxboxInit, mode: 0o644 },
1961
+ { path: `${FLUXBOX_ROOT}/overlay`, content: fluxboxOverlay, mode: 0o644 },
1480
1962
  { path: ENSURE_AGENT_SCRIPT, content: ensureAgentScript, mode: 0o700 },
1481
1963
  { path: CONTROL_SCRIPT, content: controlScript, mode: 0o700 },
1482
1964
  { path: BOUNDED_LOG_SCRIPT, content: boundedLogScript, mode: 0o700 },
@@ -1568,7 +2050,7 @@ fi`,
1568
2050
  {
1569
2051
  name: "runtime",
1570
2052
  label: "installing the Computer runtime",
1571
- body: `mkdir -p ${VIEWER_ROOT}
2053
+ body: `mkdir -p ${VIEWER_ROOT} ${FLUXBOX_ROOT}
1572
2054
  ${installDeclaredFiles(COMPUTER_RUNTIME_FILES)}
1573
2055
  # noVNC's ES modules in core/ import one another and ../vendor/pako. The links
1574
2056
  # keep that package-owned graph intact while FrockBot owns every rendered element.
@@ -1599,7 +2081,7 @@ if [ ! -x ${CHROMIUM_PATH} ]; then
1599
2081
  exit 1
1600
2082
  fi
1601
2083
  # 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.
2084
+ # the launcher and an upgrade is one relink rather than a script change.
1603
2085
  ln -sfn "$CHROMIUM_BUILD" ${CHROMIUM_PATH}
1604
2086
  fi`,
1605
2087
  },
@@ -1622,18 +2104,7 @@ fi`,
1622
2104
  if [ ! -d ${APPLETS_ROOT}/node_modules/miniflare ]; then
1623
2105
  npm install --prefix ${APPLETS_ROOT} --no-audit --no-fund miniflare@${MINIFLARE_VERSION}
1624
2106
  fi
1625
- # Guarded, and deliberately not fatal. \`@frockbot/applet-sdk\` is not on npm
1626
- # yet, and a Computer that cannot fetch it is still a Computer: it browses,
1627
- # execs, and syncs. The failure is recorded as a file the doctor reports under
1628
- # \`applets-sdk\` rather than as a phase that fails and leaves the whole
1629
- # provisioning run resumable-but-unfinished.
1630
- if [ ! -d ${APPLETS_ROOT}/node_modules/@frockbot/applet-sdk ]; then
1631
- if npm install --prefix ${APPLETS_ROOT} --no-audit --no-fund @frockbot/applet-sdk@${APPLET_SDK_VERSION}; then
1632
- rm -f ${APPLETS_SDK_FAILURE_PATH}
1633
- else
1634
- printf '%s\\n' "npm could not install @frockbot/applet-sdk@${APPLET_SDK_VERSION}" > ${APPLETS_SDK_FAILURE_PATH}
1635
- fi
1636
- fi
2107
+ ${appletSdkInstallScript}
1637
2108
  ${installDeclaredFiles(APPLETS_RUNTIME_FILES)}`,
1638
2109
  },
1639
2110
  {
@@ -1670,12 +2141,18 @@ export const UPDATE_PHASES: readonly {
1670
2141
  {
1671
2142
  name: "applets",
1672
2143
  label: "Updating the Applets command",
1673
- // The shim and nothing else. The provisioning phase beside it also runs
1674
- // `npm install`, which is a network fetch into a dependency tree an
1675
- // `applet dev` may be running out of right now; an in-place update
1676
- // replaces files atomically and must not do that. A Computer picks up a
1677
- // newly published SDK on its next provisioning adoption, not here.
1678
- body: installDeclaredFiles(APPLETS_RUNTIME_FILES),
2144
+ // The shim, plus the SDK only if this Computer has none. The provisioning
2145
+ // phase beside it also installs Miniflare, a network fetch into a
2146
+ // dependency tree an `applet dev` may be running out of right now; an
2147
+ // in-place update replaces files atomically and must not do that. The SDK
2148
+ // install is different: it runs only while the SDK is absent, and nothing
2149
+ // runs out of that tree until the SDK is there (see
2150
+ // `appletSdkInstallScript`). This is how a Computer provisioned while the
2151
+ // SDK was unpublished gets it without being provisioned again. A newer
2152
+ // SDK still waits for the next provisioning adoption.
2153
+ body: `mkdir -p ${APPLETS_ROOT}
2154
+ ${appletSdkInstallScript}
2155
+ ${installDeclaredFiles(APPLETS_RUNTIME_FILES)}`,
1679
2156
  },
1680
2157
  {
1681
2158
  name: "reference",