@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 +1 -1
- package/src/runtime.test.ts +173 -18
- package/src/runtime.ts +607 -130
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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
612
|
-
//
|
|
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-
|
|
618
|
-
).toContain(
|
|
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-
|
|
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 <
|
|
832
|
-
// Slot
|
|
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 ===
|
|
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("
|
|
966
|
+
).toBe("2");
|
|
849
967
|
// 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-
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
|
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
|
-
/**
|
|
146
|
-
|
|
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
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* guess about who is still using
|
|
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
|
-
*
|
|
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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
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
|
|
239
|
-
*
|
|
240
|
-
* unconditionally would tear down a running
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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=${
|
|
394
|
+
`--user-data-dir=${CHROME_PROFILE}`,
|
|
320
395
|
"--remote-debugging-address=127.0.0.1",
|
|
321
|
-
|
|
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
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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$
|
|
402
|
-
Xvfb
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
${
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
|
439
|
-
# A slot is a
|
|
440
|
-
#
|
|
441
|
-
# never comes back would otherwise hold one for ever,
|
|
442
|
-
#
|
|
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.
|
|
449
|
-
#
|
|
450
|
-
# reclaimed only when its tenant has been idle past the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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}
|
|
996
|
-
|
|
997
|
-
|
|
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,
|
|
1021
|
-
its
|
|
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
|
|
1041
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
|
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=${
|
|
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
|
|
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 $
|
|
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 $
|
|
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-
|
|
1477
|
-
content:
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
1674
|
-
//
|
|
1675
|
-
// `applet dev` may be running out of right now; an
|
|
1676
|
-
// replaces files atomically and must not do that.
|
|
1677
|
-
//
|
|
1678
|
-
|
|
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",
|