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