@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 +1 -1
- package/src/runtime.test.ts +154 -15
- package/src/runtime.ts +559 -110
package/package.json
CHANGED
package/src/runtime.test.ts
CHANGED
|
@@ -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
|
|
612
|
-
//
|
|
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-
|
|
618
|
-
).toContain(
|
|
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-
|
|
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 <
|
|
832
|
-
// Slot
|
|
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 ===
|
|
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("
|
|
950
|
+
).toBe("2");
|
|
849
951
|
// The idle tenant lost its slot; the live ones kept theirs.
|
|
850
|
-
expect(existsSync(join(runtimeRoot, "bots/tenant-
|
|
851
|
-
expect(existsSync(join(runtimeRoot, "bots/tenant-
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
|
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
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* guess about who is still using
|
|
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
|
-
*
|
|
224
|
+
* How many Bots of one User can hold a screen region at once (ADR 0031).
|
|
220
225
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
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
|
|
239
|
-
*
|
|
240
|
-
* unconditionally would tear down a running
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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=${
|
|
384
|
+
`--user-data-dir=${CHROME_PROFILE}`,
|
|
320
385
|
"--remote-debugging-address=127.0.0.1",
|
|
321
|
-
|
|
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
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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$
|
|
402
|
-
Xvfb
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
${
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
|
439
|
-
# A slot is a
|
|
440
|
-
#
|
|
441
|
-
# never comes back would otherwise hold one for ever,
|
|
442
|
-
#
|
|
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.
|
|
449
|
-
#
|
|
450
|
-
# reclaimed only when its tenant has been idle past the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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}
|
|
996
|
-
|
|
997
|
-
|
|
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,
|
|
1021
|
-
its
|
|
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
|
|
1041
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
record
|
|
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
|
|
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=${
|
|
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
|
|
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 $
|
|
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 $
|
|
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-
|
|
1477
|
-
content:
|
|
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
|
-
#
|
|
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
|
},
|