@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.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/LICENSE +21 -0
- package/README.md +988 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/setup.ts +1151 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// The one browser.
|
|
2
|
+
//
|
|
3
|
+
// A single Google Chrome, on a virtual display, running for as long as the box
|
|
4
|
+
// does. It exists so a browser extension — Claude in Chrome is the one this was
|
|
5
|
+
// built for — has somewhere to live on a machine with no screen, and so you can
|
|
6
|
+
// take that browser over from anywhere rather than from one laptop.
|
|
7
|
+
//
|
|
8
|
+
// Three processes, in a chain, because each needs the one before it:
|
|
9
|
+
//
|
|
10
|
+
// Xtigervnc :99 an X server whose framebuffer is also a VNC server
|
|
11
|
+
// └── openbox a window manager
|
|
12
|
+
// └── chrome the browser itself
|
|
13
|
+
//
|
|
14
|
+
// ── Why an X server with VNC built in ────────────────────────────────────────
|
|
15
|
+
// The usual recipe is Xvfb plus x11vnc. Xtigervnc is both in one process, which
|
|
16
|
+
// removes a daemon and a failure mode, and it resizes the framebuffer on a
|
|
17
|
+
// client's request, which Xvfb cannot do without an xrandr dance.
|
|
18
|
+
//
|
|
19
|
+
// ── Why a window manager ─────────────────────────────────────────────────────
|
|
20
|
+
// Without one, nothing can move, resize or stack a window, and every dialog
|
|
21
|
+
// Chrome opens outside its own frame lands at 0,0 undecorated.
|
|
22
|
+
//
|
|
23
|
+
// ── Why not headless, or CDP screencasting ───────────────────────────────────
|
|
24
|
+
// Chrome's remote debugging protocol can stream the page and take clicks back,
|
|
25
|
+
// with no X server at all. It captures the *page* though, and an extension's
|
|
26
|
+
// side panel, toolbar and popups are browser UI, not page content. The whole
|
|
27
|
+
// point here is that browser UI, so pixels from a real display are the only
|
|
28
|
+
// thing that answers.
|
|
29
|
+
//
|
|
30
|
+
// The debug port is still opened, on loopback. Not for the view — for the
|
|
31
|
+
// watchdog, which is the only cheap way to tell a wedged browser from a
|
|
32
|
+
// healthy one.
|
|
33
|
+
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
|
|
36
|
+
/** Google publishes no arm64 Linux build of Chrome, and Chromium is not it. */
|
|
37
|
+
export const BROWSER_ARCH = "x64";
|
|
38
|
+
|
|
39
|
+
export interface BrowserOptions {
|
|
40
|
+
/** The unix user the browser runs as. Never root: Chrome refuses. */
|
|
41
|
+
user: string;
|
|
42
|
+
home: string;
|
|
43
|
+
/** X display number. 99 by convention, to stay clear of a real session. */
|
|
44
|
+
display: number;
|
|
45
|
+
geometry: string;
|
|
46
|
+
vncPort: number;
|
|
47
|
+
cdpPort: number;
|
|
48
|
+
/**
|
|
49
|
+
* A systemd OnCalendar expression, or null for no nightly restart.
|
|
50
|
+
*
|
|
51
|
+
* Include the timezone: systemd evaluates a bare time in UTC, and "2am" is
|
|
52
|
+
* being asked for in somebody's actual timezone.
|
|
53
|
+
*/
|
|
54
|
+
restartAt: string | null;
|
|
55
|
+
/** Where Chrome's profile lives. Losing it means signing in again. */
|
|
56
|
+
profileDir: string;
|
|
57
|
+
/**
|
|
58
|
+
* A TigerVNC password file, or null for no authentication at all.
|
|
59
|
+
*
|
|
60
|
+
* Null is the default and is not the hole it reads as: the display listens on
|
|
61
|
+
* loopback, so SSH is the authentication step, and anyone who can open that
|
|
62
|
+
* tunnel has a shell here anyway.
|
|
63
|
+
*
|
|
64
|
+
* It is set for one reason. macOS Screen Sharing refuses a server offering no
|
|
65
|
+
* authentication and asks for a password that does not exist, so a box driven
|
|
66
|
+
* from a Mac needs VncAuth to be reachable at all. VNC authentication is
|
|
67
|
+
* DES-based and truncates at 8 characters, which makes it a second layer
|
|
68
|
+
* behind SSH and never the thing keeping the box shut.
|
|
69
|
+
*/
|
|
70
|
+
vncPasswordFile: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_DISPLAY = 99;
|
|
74
|
+
export const DEFAULT_GEOMETRY = "1600x900";
|
|
75
|
+
export const DEFAULT_VNC_PORT = 5900;
|
|
76
|
+
export const DEFAULT_CDP_PORT = 9222;
|
|
77
|
+
/**
|
|
78
|
+
* 2am in the box's own timezone, written out by name. systemd would read a bare
|
|
79
|
+
* time the same way, but naming the zone puts it in the unit and in the install
|
|
80
|
+
* summary, where someone whose night is not the box's can see to change it.
|
|
81
|
+
*/
|
|
82
|
+
export const defaultRestartAt = () =>
|
|
83
|
+
`*-*-* 02:00:00 ${Intl.DateTimeFormat().resolvedOptions().timeZone}`;
|
|
84
|
+
|
|
85
|
+
/** VNC authentication truncates silently past this, so generate exactly this. */
|
|
86
|
+
export const VNC_PASSWORD_LENGTH = 8;
|
|
87
|
+
|
|
88
|
+
export const vncPasswordPath = (home: string) => `${home}/.vibe-os/vncpasswd`;
|
|
89
|
+
|
|
90
|
+
export function defaultBrowserOptions(
|
|
91
|
+
user: string,
|
|
92
|
+
home: string,
|
|
93
|
+
): BrowserOptions {
|
|
94
|
+
return {
|
|
95
|
+
user,
|
|
96
|
+
home,
|
|
97
|
+
display: DEFAULT_DISPLAY,
|
|
98
|
+
geometry: DEFAULT_GEOMETRY,
|
|
99
|
+
vncPort: DEFAULT_VNC_PORT,
|
|
100
|
+
cdpPort: DEFAULT_CDP_PORT,
|
|
101
|
+
restartAt: defaultRestartAt(),
|
|
102
|
+
profileDir: `${home}/.vibe-os/chrome`,
|
|
103
|
+
vncPasswordFile: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Size {
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Splits "1600x900".
|
|
114
|
+
*
|
|
115
|
+
* Chrome wants the two numbers separately for --window-size, and a geometry
|
|
116
|
+
* that reaches the unit file malformed is a service that fails to start on
|
|
117
|
+
* every boot rather than an error anyone sees.
|
|
118
|
+
*/
|
|
119
|
+
export function parseGeometry(geometry: string): Size | null {
|
|
120
|
+
const match = /^(\d{2,5})x(\d{2,5})$/.exec(geometry.trim());
|
|
121
|
+
if (!match) return null;
|
|
122
|
+
const width = Number(match[1]);
|
|
123
|
+
const height = Number(match[2]);
|
|
124
|
+
if (width < 320 || height < 240) return null;
|
|
125
|
+
return { width, height };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type VncExposure = "loopback" | "exposed" | "absent";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where the VNC port is listening, from `ss -ltnH`.
|
|
132
|
+
*
|
|
133
|
+
* This matters more than it looks. The display is served with no VNC password,
|
|
134
|
+
* on the argument that only loopback can reach it and anything on loopback
|
|
135
|
+
* already has a shell. Bound to a real interface, that same argument becomes an
|
|
136
|
+
* unauthenticated desktop, signed in to Claude, for anyone who can route to the
|
|
137
|
+
* box. So the check is not "is VNC running" but "is it only on loopback".
|
|
138
|
+
*/
|
|
139
|
+
export function vncExposure(ssOutput: string, port: number): VncExposure {
|
|
140
|
+
let found = false;
|
|
141
|
+
for (const line of ssOutput.split("\n")) {
|
|
142
|
+
const fields = line.trim().split(/\s+/);
|
|
143
|
+
// ss -ltnH columns: State Recv-Q Send-Q Local:Port Peer:Port
|
|
144
|
+
const local = fields[3];
|
|
145
|
+
if (!local) continue;
|
|
146
|
+
const colon = local.lastIndexOf(":");
|
|
147
|
+
if (colon === -1) continue;
|
|
148
|
+
if (Number(local.slice(colon + 1)) !== port) continue;
|
|
149
|
+
found = true;
|
|
150
|
+
// Strip the brackets of an IPv6 literal and the %iface of a scoped address.
|
|
151
|
+
const addr = local
|
|
152
|
+
.slice(0, colon)
|
|
153
|
+
.replace(/^\[/, "")
|
|
154
|
+
.replace(/\]$/, "")
|
|
155
|
+
.replace(/%.*$/, "");
|
|
156
|
+
const loopback = addr === "::1" || /^127\./.test(addr);
|
|
157
|
+
if (!loopback) return "exposed";
|
|
158
|
+
}
|
|
159
|
+
return found ? "loopback" : "absent";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface Unit {
|
|
163
|
+
name: string;
|
|
164
|
+
contents: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Where the units are written, named once.
|
|
169
|
+
*
|
|
170
|
+
* `install-browser` writes them and three readers go looking: doctor, the Hermes
|
|
171
|
+
* discovery in `harness.ts`, and `connect-hermes`. They had a path literal each
|
|
172
|
+
* until the third one wanted the same file, which is one copy past the point
|
|
173
|
+
* where they can be trusted to stay in step.
|
|
174
|
+
*/
|
|
175
|
+
export const UNIT_DIR = "/etc/systemd/system";
|
|
176
|
+
export const CHROME_UNIT_PATH = `${UNIT_DIR}/vibe-os-chrome.service`;
|
|
177
|
+
export const XVNC_UNIT_PATH = `${UNIT_DIR}/vibe-os-xvnc.service`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The units, in the order they must be written.
|
|
181
|
+
*
|
|
182
|
+
* Returned rather than written so the shape can be tested without a machine to
|
|
183
|
+
* install onto, and so `install-browser` has one place to look for what it is
|
|
184
|
+
* about to put in /etc.
|
|
185
|
+
*/
|
|
186
|
+
export function browserUnits(opts: BrowserOptions): Unit[] {
|
|
187
|
+
const size = parseGeometry(opts.geometry);
|
|
188
|
+
if (!size) throw new Error(`unusable geometry: ${opts.geometry}`);
|
|
189
|
+
|
|
190
|
+
const d = opts.display;
|
|
191
|
+
const security = opts.vncPasswordFile
|
|
192
|
+
? `-SecurityTypes VncAuth -PasswordFile ${opts.vncPasswordFile}`
|
|
193
|
+
: "-SecurityTypes None";
|
|
194
|
+
const units: Unit[] = [
|
|
195
|
+
{
|
|
196
|
+
name: "vibe-os-xvnc.service",
|
|
197
|
+
contents: `[Unit]
|
|
198
|
+
Description=vibe-os virtual display (TigerVNC on :${d})
|
|
199
|
+
After=network.target
|
|
200
|
+
|
|
201
|
+
[Service]
|
|
202
|
+
Type=simple
|
|
203
|
+
User=${opts.user}
|
|
204
|
+
# A killed X server leaves these behind, and the next start refuses the display
|
|
205
|
+
# number rather than reusing it. Leading '-' so a first run does not fail here.
|
|
206
|
+
ExecStartPre=-/bin/rm -f /tmp/.X${d}-lock /tmp/.X11-unix/X${d}
|
|
207
|
+
ExecStart=/usr/bin/Xtigervnc :${d} -geometry ${opts.geometry} -depth 24 \\
|
|
208
|
+
-localhost ${security} -rfbport ${opts.vncPort} -AlwaysShared
|
|
209
|
+
Restart=always
|
|
210
|
+
RestartSec=2
|
|
211
|
+
|
|
212
|
+
[Install]
|
|
213
|
+
WantedBy=multi-user.target
|
|
214
|
+
`,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "vibe-os-wm.service",
|
|
218
|
+
contents: `[Unit]
|
|
219
|
+
Description=vibe-os window manager (openbox)
|
|
220
|
+
Requires=vibe-os-xvnc.service
|
|
221
|
+
After=vibe-os-xvnc.service
|
|
222
|
+
|
|
223
|
+
[Service]
|
|
224
|
+
Type=simple
|
|
225
|
+
User=${opts.user}
|
|
226
|
+
Environment=DISPLAY=:${d}
|
|
227
|
+
# After= orders the start and nothing more: it does not wait for the X server to
|
|
228
|
+
# begin accepting clients, and openbox exits immediately if it cannot connect.
|
|
229
|
+
ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do /usr/bin/xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.5; done; exit 1'
|
|
230
|
+
ExecStart=/usr/bin/openbox
|
|
231
|
+
Restart=always
|
|
232
|
+
RestartSec=2
|
|
233
|
+
|
|
234
|
+
[Install]
|
|
235
|
+
WantedBy=multi-user.target
|
|
236
|
+
`,
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "vibe-os-chrome.service",
|
|
240
|
+
contents: `[Unit]
|
|
241
|
+
Description=vibe-os browser (Google Chrome)
|
|
242
|
+
Requires=vibe-os-wm.service
|
|
243
|
+
After=vibe-os-wm.service
|
|
244
|
+
|
|
245
|
+
[Service]
|
|
246
|
+
Type=simple
|
|
247
|
+
User=${opts.user}
|
|
248
|
+
Environment=DISPLAY=:${d}
|
|
249
|
+
Environment=HOME=${opts.home}
|
|
250
|
+
# systemd owns the singleton, so a lock left behind by a killed Chrome is always
|
|
251
|
+
# stale, and leaving it there costs a start.
|
|
252
|
+
ExecStartPre=-/bin/rm -f ${opts.profileDir}/SingletonLock ${opts.profileDir}/SingletonSocket ${opts.profileDir}/SingletonCookie
|
|
253
|
+
ExecStart=/usr/bin/google-chrome \\
|
|
254
|
+
--user-data-dir=${opts.profileDir} \\
|
|
255
|
+
--no-first-run --no-default-browser-check \\
|
|
256
|
+
--hide-crash-restore-bubble --disable-session-crashed-bubble \\
|
|
257
|
+
--password-store=basic \\
|
|
258
|
+
--remote-debugging-port=${opts.cdpPort} \\
|
|
259
|
+
--window-position=0,0 --window-size=${size.width},${size.height} \\
|
|
260
|
+
about:blank
|
|
261
|
+
Restart=always
|
|
262
|
+
RestartSec=5
|
|
263
|
+
# Chrome is the main process, deliberately. Wrapping it in dbus-run-session puts
|
|
264
|
+
# something else there instead, and that wrapper does not pass SIGTERM on, so
|
|
265
|
+
# every stop became a 20-second timeout and a SIGKILL.
|
|
266
|
+
#
|
|
267
|
+
# Chrome still records exit_type "Crashed" on a SIGTERM stop — it writes
|
|
268
|
+
# "Normal" only when quit from its own menu — which is why both crash-restore
|
|
269
|
+
# flags above are set rather than trusted to be unnecessary.
|
|
270
|
+
KillMode=mixed
|
|
271
|
+
TimeoutStopSec=20
|
|
272
|
+
|
|
273
|
+
[Install]
|
|
274
|
+
WantedBy=multi-user.target
|
|
275
|
+
`,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
// Restart=always covers a browser that died. It cannot see one that is
|
|
279
|
+
// running and wedged, which is the failure that actually strands you,
|
|
280
|
+
// because the window keeps rendering whatever it last painted.
|
|
281
|
+
name: "vibe-os-chrome-watchdog.service",
|
|
282
|
+
contents: `[Unit]
|
|
283
|
+
Description=vibe-os browser liveness probe
|
|
284
|
+
|
|
285
|
+
[Service]
|
|
286
|
+
Type=oneshot
|
|
287
|
+
ExecStart=/bin/sh -c 'curl -sf --max-time 5 http://127.0.0.1:${opts.cdpPort}/json/version >/dev/null || { echo "chrome not answering CDP, restarting"; systemctl restart vibe-os-chrome.service; }'
|
|
288
|
+
`,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: "vibe-os-chrome-watchdog.timer",
|
|
292
|
+
contents: `[Unit]
|
|
293
|
+
Description=probe the vibe-os browser every minute
|
|
294
|
+
|
|
295
|
+
[Timer]
|
|
296
|
+
OnBootSec=2min
|
|
297
|
+
OnUnitActiveSec=1min
|
|
298
|
+
|
|
299
|
+
[Install]
|
|
300
|
+
WantedBy=timers.target
|
|
301
|
+
`,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
if (opts.restartAt) {
|
|
306
|
+
units.push(
|
|
307
|
+
{
|
|
308
|
+
// A restart, not a tab sweep: it returns the memory a day of browsing
|
|
309
|
+
// took as well as the tabs, and the profile carries the sign-in across.
|
|
310
|
+
name: "vibe-os-chrome-restart.service",
|
|
311
|
+
contents: `[Unit]
|
|
312
|
+
Description=vibe-os nightly browser restart
|
|
313
|
+
|
|
314
|
+
[Service]
|
|
315
|
+
Type=oneshot
|
|
316
|
+
ExecStart=/usr/bin/systemctl restart vibe-os-chrome.service
|
|
317
|
+
`,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "vibe-os-chrome-restart.timer",
|
|
321
|
+
contents: `[Unit]
|
|
322
|
+
Description=bounce the vibe-os browser nightly
|
|
323
|
+
|
|
324
|
+
[Timer]
|
|
325
|
+
OnCalendar=${opts.restartAt}
|
|
326
|
+
# The point is a clean start each night, not catching up on a missed one.
|
|
327
|
+
Persistent=false
|
|
328
|
+
|
|
329
|
+
[Install]
|
|
330
|
+
WantedBy=timers.target
|
|
331
|
+
`,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return units;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The units that should be enabled, in dependency order. */
|
|
340
|
+
export function browserServices(opts: BrowserOptions): string[] {
|
|
341
|
+
return [
|
|
342
|
+
"vibe-os-xvnc.service",
|
|
343
|
+
"vibe-os-wm.service",
|
|
344
|
+
"vibe-os-chrome.service",
|
|
345
|
+
"vibe-os-chrome-watchdog.timer",
|
|
346
|
+
...(opts.restartAt ? ["vibe-os-chrome-restart.timer"] : []),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The commands this box is missing.
|
|
352
|
+
*
|
|
353
|
+
* Named separately from the doctor checks because install-browser needs the
|
|
354
|
+
* same answer before it writes a unit that could never start.
|
|
355
|
+
*/
|
|
356
|
+
export const BROWSER_COMMANDS = [
|
|
357
|
+
{
|
|
358
|
+
command: "Xtigervnc",
|
|
359
|
+
package: "tigervnc-standalone-server",
|
|
360
|
+
why: "the virtual display",
|
|
361
|
+
},
|
|
362
|
+
{ command: "openbox", package: "openbox", why: "the window manager" },
|
|
363
|
+
{
|
|
364
|
+
command: "xdpyinfo",
|
|
365
|
+
package: "x11-utils",
|
|
366
|
+
why: "waiting for the display to accept clients",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
command: "google-chrome",
|
|
370
|
+
package: "google-chrome-stable",
|
|
371
|
+
why: "the browser itself",
|
|
372
|
+
},
|
|
373
|
+
] as const;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether this machine could run the browser at all.
|
|
377
|
+
*
|
|
378
|
+
* Chrome for Linux is x86_64 only. Saying so here beats an install that writes
|
|
379
|
+
* five units and then fails to start one of them for a reason nothing explains.
|
|
380
|
+
*/
|
|
381
|
+
export function archSupported(arch: string = os.arch()): boolean {
|
|
382
|
+
return arch === BROWSER_ARCH;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The ports an installed unit actually uses.
|
|
387
|
+
*
|
|
388
|
+
* doctor is given flags, not a unit file, and it has no way to know what
|
|
389
|
+
* install-browser was told months ago. Reading them back out of what is on
|
|
390
|
+
* disk is the difference between checking the real ports and checking the
|
|
391
|
+
* defaults while claiming to have checked the real ones.
|
|
392
|
+
*/
|
|
393
|
+
export function portsFromUnits(units: {
|
|
394
|
+
xvnc?: string | null;
|
|
395
|
+
chrome?: string | null;
|
|
396
|
+
}): { vncPort: number; cdpPort: number } {
|
|
397
|
+
const rfb = units.xvnc ? /-rfbport\s+(\d{1,5})\b/.exec(units.xvnc) : null;
|
|
398
|
+
const cdp = units.chrome
|
|
399
|
+
? /--remote-debugging-port=(\d{1,5})\b/.exec(units.chrome)
|
|
400
|
+
: null;
|
|
401
|
+
return {
|
|
402
|
+
vncPort: rfb ? Number(rfb[1]) : DEFAULT_VNC_PORT,
|
|
403
|
+
cdpPort: cdp ? Number(cdp[1]) : DEFAULT_CDP_PORT,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The password file an installed unit is already using, if any.
|
|
409
|
+
*
|
|
410
|
+
* Read before writing, so a reinstall cannot quietly turn authentication off on
|
|
411
|
+
* a box that had it. Losing a setting you asked for is bad; losing it silently,
|
|
412
|
+
* on the one command whose whole job is to rewrite these units, is worse.
|
|
413
|
+
*/
|
|
414
|
+
export function vncPasswordFileFromUnit(
|
|
415
|
+
unit: string | null | undefined,
|
|
416
|
+
): string | null {
|
|
417
|
+
if (!unit) return null;
|
|
418
|
+
const match = /-PasswordFile\s+(\S+)/.exec(unit);
|
|
419
|
+
return match ? match[1] : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* What Hermes has to be told so its browser tools drive this Chrome.
|
|
424
|
+
*
|
|
425
|
+
* Hermes reads its CDP target from `browser.cdp_url` in `~/.hermes/config.yaml`
|
|
426
|
+
* and offers no flag for it, so this is set once for the box rather than per
|
|
427
|
+
* profile. `browser-use` is named explicitly even though it is already the
|
|
428
|
+
* default, because the default is conditional on the CLI being runnable and a
|
|
429
|
+
* config that says what it means survives someone else reading it.
|
|
430
|
+
*
|
|
431
|
+
* `cloud_provider: local` is what stops a Browserbase or Firecrawl key, set
|
|
432
|
+
* later for something else, from quietly taking the browser back.
|
|
433
|
+
*
|
|
434
|
+
* Returned as pairs rather than written here: they are handed to
|
|
435
|
+
* `hermes config set`, which owns that file and knows where a secret goes.
|
|
436
|
+
*/
|
|
437
|
+
export function hermesBrowserSettings(
|
|
438
|
+
cdpPort: number,
|
|
439
|
+
): { key: string; value: string }[] {
|
|
440
|
+
return [
|
|
441
|
+
{ key: "browser.cdp_url", value: `http://127.0.0.1:${cdpPort}` },
|
|
442
|
+
{ key: "browser.backend", value: "browser-use" },
|
|
443
|
+
{ key: "browser.cloud_provider", value: "local" },
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Whether a configured `cdp_url` names this box's Chrome.
|
|
449
|
+
*
|
|
450
|
+
* Written by hand as often as by `connect-hermes`, so the comparison is on what
|
|
451
|
+
* the URL means rather than on its spelling: `localhost` and `127.0.0.1` are the
|
|
452
|
+
* same host, and `ws://` and `http://` are the same endpoint to a CDP client.
|
|
453
|
+
* A bare `127.0.0.1:9222` is accepted too, because it is what people type.
|
|
454
|
+
*
|
|
455
|
+
* The port has to be explicit. A URL with no port is not a CDP endpoint that
|
|
456
|
+
* would ever have worked, and inferring 80 from `http://` would report a
|
|
457
|
+
* misconfigured box as connected.
|
|
458
|
+
*/
|
|
459
|
+
export function cdpUrlMatches(
|
|
460
|
+
configured: string | null | undefined,
|
|
461
|
+
cdpPort: number,
|
|
462
|
+
): boolean {
|
|
463
|
+
const raw = configured?.trim();
|
|
464
|
+
if (!raw) return false;
|
|
465
|
+
// A scheme is optional in what people write but not in what URL will parse.
|
|
466
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
|
|
467
|
+
? raw
|
|
468
|
+
: `http://${raw}`;
|
|
469
|
+
let url: URL;
|
|
470
|
+
try {
|
|
471
|
+
url = new URL(withScheme);
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (!/^(https?|wss?):$/.test(url.protocol)) return false;
|
|
476
|
+
if (url.port !== String(cdpPort)) return false;
|
|
477
|
+
// Chrome binds the debug port to loopback, so anything else is a different
|
|
478
|
+
// browser on a different machine and saying "connected" would be a guess.
|
|
479
|
+
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
480
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export interface MemInfo {
|
|
484
|
+
totalKb: number;
|
|
485
|
+
swapKb: number;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** MemTotal and SwapTotal out of /proc/meminfo. */
|
|
489
|
+
export function parseMemInfo(text: string): MemInfo | null {
|
|
490
|
+
const total = /^MemTotal:\s+(\d+)\s+kB/m.exec(text);
|
|
491
|
+
if (!total) return null;
|
|
492
|
+
const swap = /^SwapTotal:\s+(\d+)\s+kB/m.exec(text);
|
|
493
|
+
return {
|
|
494
|
+
totalKb: Number(total[1]),
|
|
495
|
+
swapKb: swap ? Number(swap[1]) : 0,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Whether Chrome has room to run here.
|
|
501
|
+
*
|
|
502
|
+
* Chrome idles around half a gigabyte and grows with every tab. On a small box
|
|
503
|
+
* with no swap the first thing the OOM killer reaps is whatever it finds, which
|
|
504
|
+
* presents as terminal sessions dying for no visible reason rather than as a
|
|
505
|
+
* browser problem. Two gigabytes of headroom, counting swap, is the line.
|
|
506
|
+
*/
|
|
507
|
+
export function memoryHeadroom(mem: MemInfo): {
|
|
508
|
+
ok: boolean;
|
|
509
|
+
detail: string;
|
|
510
|
+
} {
|
|
511
|
+
const gib = (kb: number) => (kb / 1024 / 1024).toFixed(1);
|
|
512
|
+
const totalGib = `${gib(mem.totalKb)}GiB RAM`;
|
|
513
|
+
const swapNote = mem.swapKb === 0 ? "no swap" : `${gib(mem.swapKb)}GiB swap`;
|
|
514
|
+
const headroomKb = mem.totalKb + mem.swapKb;
|
|
515
|
+
return {
|
|
516
|
+
ok: headroomKb >= 4 * 1024 * 1024 || mem.swapKb > 0,
|
|
517
|
+
detail: `${totalGib}, ${swapNote}`,
|
|
518
|
+
};
|
|
519
|
+
}
|