@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +928 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +843 -0
  41. package/server/config.ts +362 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +966 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +654 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/ssh-ca.ts +368 -0
  58. package/server/static.ts +147 -0
  59. package/server/wallpapers.ts +248 -0
  60. package/server/windows.ts +237 -0
  61. package/shared/args.ts +91 -0
  62. package/shared/grid.ts +32 -0
  63. package/shared/wire.ts +226 -0
@@ -0,0 +1,966 @@
1
+ // Pre-flight for a real machine.
2
+ //
3
+ // Everything here exists because it fails *silently* otherwise. A browser
4
+ // window that cannot log in says "handshake failed: websocket was closed by
5
+ // server" and nothing else — the actual cause is in sshd's log, on a box you
6
+ // were hoping to administer through the terminal that just failed to open. So
7
+ // these checks read sshd's own effective configuration and answer the only
8
+ // question that matters: will a certificate this host signs actually be
9
+ // accepted for this user?
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { readFile, stat } from "node:fs/promises";
13
+ import net from "node:net";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { promisify } from "node:util";
17
+
18
+ import {
19
+ archSupported,
20
+ BROWSER_COMMANDS,
21
+ memoryHeadroom,
22
+ parseMemInfo,
23
+ portsFromUnits,
24
+ vncExposure,
25
+ vncPasswordFileFromUnit,
26
+ } from "./browser.ts";
27
+ import type { Config } from "./config.ts";
28
+ import {
29
+ CURSOR_INSTALL,
30
+ discoverCursor,
31
+ discoverHermes,
32
+ HERMES_INSTALL,
33
+ UV_INSTALL,
34
+ } from "./harness.ts";
35
+ import { findLeaks } from "./reaper.ts";
36
+ import { IS_COMPILED } from "./runtime.ts";
37
+ import { discoverHostKey, SshCa } from "./ssh-ca.ts";
38
+
39
+ const run = promisify(execFile);
40
+
41
+ /**
42
+ * What the host firewall is doing, as far as this machine can tell.
43
+ *
44
+ * Two honest limits, both stated in the check's own output rather than left for
45
+ * the reader to infer:
46
+ *
47
+ * - This is *configuration*, not reachability. Proving a port is unreachable
48
+ * needs a packet sent from somewhere else, and nothing on this box qualifies.
49
+ * - Your provider's firewall — OVH's Network Firewall, AWS security groups,
50
+ * Hetzner's — sits in front of the machine and is invisible from inside it. A
51
+ * clean answer here says nothing about that layer.
52
+ *
53
+ * Both of those are why this reports rather than blocks: a wrong "you are safe"
54
+ * is worse than no answer.
55
+ */
56
+ interface FirewallState {
57
+ /** null when no firewall tool could be read at all. */
58
+ tool: "ufw" | "nftables" | "iptables" | null;
59
+ active: boolean;
60
+ /** Incoming denied unless explicitly allowed. */
61
+ defaultDeny: boolean;
62
+ /** An allow rule for the tailscale interface. */
63
+ tailnetAllowed: boolean;
64
+ /** 41641/udp, which keeps Tailscale on a direct path instead of a relay. */
65
+ directUdpAllowed: boolean;
66
+ /** True when we could not read the rules — almost always "not root". */
67
+ unreadable: boolean;
68
+ }
69
+
70
+ export function parseUfw(
71
+ status: string,
72
+ ): Omit<FirewallState, "tool" | "unreadable"> {
73
+ const text = status.toLowerCase();
74
+ return {
75
+ active: /status:\s*active/.test(text),
76
+ defaultDeny: /default:\s*deny\s*\(incoming\)/.test(text),
77
+ tailnetAllowed: /tailscale\d*\b[^\n]*allow in/.test(text),
78
+ directUdpAllowed: /41641\/udp[^\n]*allow in/.test(text),
79
+ };
80
+ }
81
+
82
+ export function parseNftOrIptables(
83
+ rules: string,
84
+ ): Omit<FirewallState, "tool" | "unreadable"> {
85
+ const text = rules.toLowerCase();
86
+ return {
87
+ // A ruleset we could read at all, with an input chain, counts as active.
88
+ active: /input/.test(text),
89
+ defaultDeny:
90
+ /-p\s+input\s+drop/.test(text) ||
91
+ /chain\s+input\b[^\n]*policy\s+drop/.test(text) ||
92
+ // nft writes `hook input priority 0; policy drop;` — the policy sits after
93
+ // a semicolon, so this must be allowed to cross one.
94
+ /hook\s+input[\s\S]{0,120}?policy\s+drop/.test(text),
95
+ tailnetAllowed:
96
+ /(iifname\s+"?tailscale\d*"?|-i\s+tailscale\d*)[^\n]*(accept|-j accept)/.test(
97
+ text,
98
+ ),
99
+ directUdpAllowed: /41641[^\n]*(accept|-j accept)/.test(text),
100
+ };
101
+ }
102
+
103
+ async function firewallState(): Promise<FirewallState> {
104
+ const blank = {
105
+ active: false,
106
+ defaultDeny: false,
107
+ tailnetAllowed: false,
108
+ directUdpAllowed: false,
109
+ };
110
+
111
+ // ufw first: it is what the README documents, and its status output says
112
+ // plainly whether it is switched on, which a raw ruleset does not.
113
+ const ufw = await run("ufw", ["status", "verbose"], { timeout: 5000 })
114
+ .then((r) => r.stdout)
115
+ .catch(() => null);
116
+ if (ufw) return { tool: "ufw", unreadable: false, ...parseUfw(ufw) };
117
+
118
+ for (const [tool, cmd, args] of [
119
+ ["nftables", "nft", ["list", "ruleset"]],
120
+ ["iptables", "iptables", ["-S"]],
121
+ ] as const) {
122
+ const out = await run(cmd, [...args], { timeout: 5000 })
123
+ .then((r) => r.stdout)
124
+ .catch(() => null);
125
+ if (out?.trim())
126
+ return { tool, unreadable: false, ...parseNftOrIptables(out) };
127
+ }
128
+
129
+ return { tool: null, unreadable: true, ...blank };
130
+ }
131
+
132
+ const CHROME_UNIT = "/etc/systemd/system/vibe-os-chrome.service";
133
+ const XVNC_UNIT = "/etc/systemd/system/vibe-os-xvnc.service";
134
+
135
+ export interface Check {
136
+ label: string;
137
+ ok: boolean;
138
+ detail: string;
139
+ /** A failure that stops vibe-os working at all, rather than degrading it. */
140
+ fatal?: boolean;
141
+ /** A command or edit that would fix it. */
142
+ fix?: string;
143
+ }
144
+
145
+ const ok = (label: string, detail: string): Check => ({
146
+ label,
147
+ ok: true,
148
+ detail,
149
+ });
150
+ const bad = (label: string, detail: string, fix?: string): Check => ({
151
+ label,
152
+ ok: false,
153
+ detail,
154
+ fix,
155
+ });
156
+ const fatal = (label: string, detail: string, fix?: string): Check => ({
157
+ label,
158
+ ok: false,
159
+ detail,
160
+ fix,
161
+ fatal: true,
162
+ });
163
+
164
+ function probeTcp(
165
+ host: string,
166
+ port: number,
167
+ timeout = 2000,
168
+ ): Promise<boolean> {
169
+ return new Promise((resolve) => {
170
+ const socket = net.connect({ host, port });
171
+ const done = (result: boolean) => {
172
+ socket.destroy();
173
+ resolve(result);
174
+ };
175
+ socket.setTimeout(timeout);
176
+ socket.once("connect", () => done(true));
177
+ socket.once("timeout", () => done(false));
178
+ socket.once("error", () => done(false));
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Why a port is unavailable, which is not one answer but two.
184
+ *
185
+ * "Permission denied" wants a capability; "address in use" usually means
186
+ * vibe-os is already running and the check has nothing to report. Telling
187
+ * someone to run setcap because their own server is up is worse than useless.
188
+ */
189
+ function bindStatus(
190
+ port: number,
191
+ host: string,
192
+ ): Promise<"free" | "in-use" | "denied"> {
193
+ return new Promise((resolve) => {
194
+ const server = net.createServer();
195
+ server.once("error", (err: NodeJS.ErrnoException) => {
196
+ resolve(err.code === "EADDRINUSE" ? "in-use" : "denied");
197
+ });
198
+ server.once("listening", () => server.close(() => resolve("free")));
199
+ server.listen(port, host);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Is this command on PATH?
205
+ *
206
+ * One `/bin/sh -c` invocation, with the command passed as `$1` rather than
207
+ * interpolated into the script. The previous version ran the same thing twice —
208
+ * `execFile` with `shell: "/bin/sh"` already builds a `sh -c` string, so the
209
+ * "fall back to asking the shell directly" branch was the identical call and
210
+ * could only fire if /bin/sh were missing, in which case it failed too. It also
211
+ * built its script by interpolation, which no caller exploited but nothing
212
+ * stopped either.
213
+ */
214
+ export async function onPath(command: string): Promise<boolean> {
215
+ try {
216
+ await run("/bin/sh", ["-c", 'command -v "$1" > /dev/null', "sh", command]);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The always-on browser, when there is one.
225
+ *
226
+ * Returns nothing at all unless the units are installed or Chrome is present,
227
+ * so this stays invisible on the boxes that never asked for it.
228
+ */
229
+ async function browserChecks(): Promise<Check[]> {
230
+ const readIfPresent = (p: string) =>
231
+ readFile(p, "utf8").catch(() => null as string | null);
232
+ const [chromeUnit, xvncUnit] = await Promise.all([
233
+ readIfPresent(CHROME_UNIT),
234
+ readIfPresent(XVNC_UNIT),
235
+ ]);
236
+ const chromeInstalled = await onPath("google-chrome");
237
+ if (!chromeUnit && !xvncUnit && !chromeInstalled) return [];
238
+
239
+ const checks: Check[] = [];
240
+ const { vncPort, cdpPort } = portsFromUnits({
241
+ xvnc: xvncUnit,
242
+ chrome: chromeUnit,
243
+ });
244
+
245
+ if (!archSupported()) {
246
+ checks.push(
247
+ bad(
248
+ "browser",
249
+ `Chrome for Linux is x86_64 only, and this is ${os.arch()}`,
250
+ "there is no fix — the extension needs Chrome itself, not Chromium",
251
+ ),
252
+ );
253
+ return checks;
254
+ }
255
+
256
+ const missing = [];
257
+ for (const entry of BROWSER_COMMANDS) {
258
+ if (!(await onPath(entry.command))) missing.push(entry.package);
259
+ }
260
+ if (missing.length > 0) {
261
+ checks.push(
262
+ bad(
263
+ "browser",
264
+ `installed as a service, but missing: ${missing.join(", ")}`,
265
+ "vibe-os install-browser prints the apt line",
266
+ ),
267
+ );
268
+ }
269
+
270
+ // Is it actually up? A unit that exists and a browser that answers are
271
+ // different claims, and only the second one means you can use it.
272
+ if (chromeUnit) {
273
+ const answering = await probeTcp("127.0.0.1", cdpPort, 3000);
274
+ checks.push(
275
+ answering
276
+ ? ok("browser", `Chrome answering on 127.0.0.1:${cdpPort}`)
277
+ : bad(
278
+ "browser",
279
+ `installed, but nothing answers the debug port on 127.0.0.1:${cdpPort}`,
280
+ "systemctl status vibe-os-chrome && journalctl -u vibe-os-chrome -n 50",
281
+ ),
282
+ );
283
+ }
284
+
285
+ // Where the display is listening is the whole security model for it: served
286
+ // with no password, on the argument that only loopback can reach it.
287
+ const ss = await run("ss", ["-ltnH"], { timeout: 5_000 })
288
+ .then((r) => r.stdout)
289
+ .catch(() => null);
290
+
291
+ if (!ss) {
292
+ checks.push(
293
+ bad(
294
+ "display",
295
+ "could not read listening sockets, so nothing here can say where VNC is bound",
296
+ "install iproute2, or check by hand: ss -ltn | grep 5900",
297
+ ),
298
+ );
299
+ } else {
300
+ const exposure = vncExposure(ss, vncPort);
301
+ const auth = vncPasswordFileFromUnit(xvncUnit)
302
+ ? "password set"
303
+ : "no password, so SSH is the only gate";
304
+ if (exposure === "loopback") {
305
+ checks.push(
306
+ ok(
307
+ "display",
308
+ `VNC on ${vncPort}, loopback only, ${auth} — reach it with ssh -L ${vncPort}:127.0.0.1:${vncPort}`,
309
+ ),
310
+ );
311
+ } else if (exposure === "exposed") {
312
+ checks.push(
313
+ fatal(
314
+ "display",
315
+ `VNC on ${vncPort} is bound beyond loopback, and it has no password — ` +
316
+ "anyone who can route here gets the browser and everything signed in to it",
317
+ "add -localhost to the Xtigervnc line in vibe-os-xvnc.service, then: systemctl restart vibe-os-xvnc",
318
+ ),
319
+ );
320
+ } else if (chromeUnit || xvncUnit) {
321
+ checks.push(
322
+ bad(
323
+ "display",
324
+ `nothing listening on ${vncPort} — the virtual display is not running`,
325
+ "systemctl status vibe-os-xvnc",
326
+ ),
327
+ );
328
+ }
329
+ }
330
+
331
+ const mem = parseMemInfo(
332
+ await readIfPresent("/proc/meminfo").then((t) => t ?? ""),
333
+ );
334
+ if (mem) {
335
+ const headroom = memoryHeadroom(mem);
336
+ checks.push(
337
+ headroom.ok
338
+ ? ok("browser memory", headroom.detail)
339
+ : bad(
340
+ "browser memory",
341
+ `${headroom.detail} — Chrome idles near 0.5GiB and the OOM killer takes terminals, not tabs`,
342
+ "sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile",
343
+ ),
344
+ );
345
+ }
346
+
347
+ return checks;
348
+ }
349
+
350
+ /**
351
+ * Hermes, and whether its browser tools land anywhere useful.
352
+ *
353
+ * Three questions, and the second two only make sense once the first is yes.
354
+ * None of them is fatal: a box that never installs Hermes is a working box, the
355
+ * same way one without Claude is.
356
+ */
357
+ async function hermesChecks(): Promise<Check[]> {
358
+ // Cheap when it is missing — this returns as soon as the binary is not found,
359
+ // which is the answer on most boxes.
360
+ const hermes = await discoverHermes();
361
+ if (!hermes.available) {
362
+ return [
363
+ bad(
364
+ "hermes",
365
+ "not on PATH — profiles using the Hermes harness fall back to a shell",
366
+ HERMES_INSTALL,
367
+ ),
368
+ ];
369
+ }
370
+
371
+ const checks: Check[] = [
372
+ ok(
373
+ "hermes",
374
+ `${hermes.version ?? "installed"} — the Hermes harness will run`,
375
+ ),
376
+ ];
377
+
378
+ // Without the CLI, Browser Use mode silently does not engage. Hermes keeps
379
+ // working with its twelve built-in browser tools, so nothing looks wrong;
380
+ // you just pay for a dozen tool schemas in every request and never find out.
381
+ checks.push(
382
+ hermes.browser.browserUse
383
+ ? ok("browser-use", "runnable — Hermes gets the single browser_exec tool")
384
+ : bad(
385
+ "browser-use",
386
+ "no browser-use or uvx on PATH — Hermes keeps its twelve built-in browser tools",
387
+ UV_INSTALL,
388
+ ),
389
+ );
390
+
391
+ // Only worth asking when there is a browser here to be pointed at. On a box
392
+ // with no `install-browser`, an unset cdp_url is the correct configuration.
393
+ if (hermes.browser.cdpPort !== null) {
394
+ checks.push(
395
+ hermes.browser.connected
396
+ ? ok(
397
+ "hermes browser",
398
+ `driving this box's Chrome on 127.0.0.1:${hermes.browser.cdpPort}`,
399
+ )
400
+ : bad(
401
+ "hermes browser",
402
+ hermes.browser.cdpUrl
403
+ ? `browser.cdp_url is ${hermes.browser.cdpUrl}, not this box's Chrome on ${hermes.browser.cdpPort}`
404
+ : "browser.cdp_url is unset — Hermes will not use the browser running here",
405
+ "vibe-os connect-hermes",
406
+ ),
407
+ );
408
+ }
409
+
410
+ return checks;
411
+ }
412
+
413
+ /**
414
+ * Cursor, and whether a window opened as it would get past the login prompt.
415
+ *
416
+ * Two questions. Neither is fatal, for the reason the Hermes checks give: a box
417
+ * that never installs Cursor is a working box. The login check exists because
418
+ * its failure is quiet in exactly the way this file cares about — the window
419
+ * opens, the harness runs, and it sits asking for a browser login on a box that
420
+ * may not have one attached.
421
+ */
422
+ async function cursorChecks(): Promise<Check[]> {
423
+ const cursor = await discoverCursor();
424
+ if (!cursor.available) {
425
+ return [
426
+ bad(
427
+ "cursor",
428
+ "not on PATH — profiles using the Cursor harness fall back to a shell",
429
+ CURSOR_INSTALL,
430
+ ),
431
+ ];
432
+ }
433
+
434
+ const checks: Check[] = [
435
+ ok(
436
+ "cursor",
437
+ `${cursor.version ?? "installed"} — the Cursor harness will run`,
438
+ ),
439
+ ];
440
+
441
+ if (cursor.loggedIn === false) {
442
+ checks.push(
443
+ bad(
444
+ "cursor login",
445
+ "not logged in — Cursor windows will sit at a login prompt",
446
+ "cursor-agent login",
447
+ ),
448
+ );
449
+ } else if (cursor.loggedIn === true) {
450
+ checks.push(
451
+ ok(
452
+ "cursor login",
453
+ `logged in${cursor.models.length > 0 ? `, ${cursor.models.length} models offered` : ""}`,
454
+ ),
455
+ );
456
+ }
457
+ // null stays silent: status output this build cannot read is not a finding.
458
+
459
+ return checks;
460
+ }
461
+
462
+ /** The home directory sshd will use for a user, which need not be ours. */
463
+ export async function homeFor(user: string): Promise<string | null> {
464
+ if (user === os.userInfo().username) return os.homedir();
465
+ try {
466
+ const { stdout } = await run("getent", ["passwd", user], {
467
+ timeout: 5_000,
468
+ });
469
+ const home = stdout.split("\n")[0]?.split(":")[5];
470
+ return home && home.length > 0 ? home : null;
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * sshd's effective configuration, evaluated for the user who will log in.
478
+ *
479
+ * `sshd -T` is the only honest source: it applies Include directives, drop-in
480
+ * files and Match blocks, which reading sshd_config by hand does not. It needs
481
+ * root, so a non-root run degrades to "unknown" rather than guessing.
482
+ */
483
+ async function sshdEffectiveConfig(
484
+ user: string,
485
+ ): Promise<Map<string, string> | null> {
486
+ const binaries = ["sshd", "/usr/sbin/sshd", "/usr/local/sbin/sshd"];
487
+ for (const bin of binaries) {
488
+ try {
489
+ const { stdout } = await run(
490
+ bin,
491
+ ["-T", "-C", `user=${user},host=localhost,addr=127.0.0.1`],
492
+ { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 },
493
+ );
494
+ const map = new Map<string, string>();
495
+ for (const line of stdout.split("\n")) {
496
+ const trimmed = line.trim();
497
+ if (!trimmed) continue;
498
+ const space = trimmed.indexOf(" ");
499
+ // sshd -T lowercases keywords and prints repeated ones on their own
500
+ // lines; joining preserves multi-value keywords like allowusers.
501
+ const key = space === -1 ? trimmed : trimmed.slice(0, space);
502
+ const value = space === -1 ? "" : trimmed.slice(space + 1);
503
+ map.set(key, map.has(key) ? `${map.get(key)} ${value}` : value);
504
+ }
505
+ return map;
506
+ } catch {
507
+ // not this path, or not root — try the next
508
+ }
509
+ }
510
+ return null;
511
+ }
512
+
513
+ /**
514
+ * Expands sshd's AuthorizedKeysFile tokens into real paths.
515
+ *
516
+ * `%%` is an escaped percent, so it has to stop what follows being read as a
517
+ * token: `%%h` is the literal `%h`, not the home directory. Parking it on a
518
+ * character the input cannot contain, expanding the real tokens, then putting
519
+ * the percent back is what keeps those two apart in a single pass.
520
+ *
521
+ * The sentinel is spelled `\u0000` rather than written as a raw NUL byte. Same
522
+ * value, but a NUL in the source makes the entire file binary to grep, ripgrep
523
+ * and diffs, which silently breaks searching it.
524
+ */
525
+ export function authorizedKeysPaths(
526
+ spec: string,
527
+ home: string,
528
+ user: string,
529
+ ): string[] {
530
+ return spec
531
+ .split(/\s+/)
532
+ .filter(Boolean)
533
+ .map((entry) =>
534
+ entry
535
+ .replaceAll("%%", "\u0000")
536
+ .replaceAll("%h", home)
537
+ .replaceAll("%u", user)
538
+ .replaceAll("\u0000", "%"),
539
+ )
540
+ .map((entry) => (path.isAbsolute(entry) ? entry : path.join(home, entry)));
541
+ }
542
+
543
+ /**
544
+ * StrictModes: sshd ignores authorized_keys outright if the path to it is
545
+ * writable by anyone but its owner. It logs why; nothing else tells you.
546
+ */
547
+ async function badPermissions(home: string): Promise<string[]> {
548
+ const problems: string[] = [];
549
+ const targets = [
550
+ home,
551
+ path.join(home, ".ssh"),
552
+ path.join(home, ".ssh", "authorized_keys"),
553
+ ];
554
+ for (const target of targets) {
555
+ try {
556
+ const info = await stat(target);
557
+ if ((info.mode & 0o022) !== 0) {
558
+ problems.push(
559
+ `${target} is ${(info.mode & 0o777).toString(8)} (group/other writable)`,
560
+ );
561
+ }
562
+ } catch {
563
+ // absent is not a permission problem; other checks cover it
564
+ }
565
+ }
566
+ return problems;
567
+ }
568
+
569
+ /** A tailnet address on this host, if Tailscale is up. */
570
+ async function tailscaleAddress(): Promise<string | null> {
571
+ for (const iface of Object.values(os.networkInterfaces())) {
572
+ for (const info of iface ?? []) {
573
+ // The CGNAT range Tailscale assigns from: 100.64.0.0/10.
574
+ if (
575
+ info.family === "IPv4" &&
576
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(info.address)
577
+ ) {
578
+ return info.address;
579
+ }
580
+ }
581
+ }
582
+ return null;
583
+ }
584
+
585
+ export async function runDoctor(config: Config): Promise<Check[]> {
586
+ const checks: Check[] = [];
587
+ const user = config.user;
588
+
589
+ // ── the toolchain ────────────────────────────────────────────────────────
590
+ const bunMajor = Number((Bun.version ?? "0").split(".")[0]);
591
+ checks.push(
592
+ bunMajor >= 1
593
+ ? ok("bun", `v${Bun.version}`)
594
+ : fatal("bun", "vibe-os runs on Bun 1+"),
595
+ );
596
+
597
+ for (const [command, label, why, fix] of [
598
+ [
599
+ "ssh-keygen",
600
+ "ssh-keygen",
601
+ "signs every certificate",
602
+ "apt install openssh-client",
603
+ ],
604
+ [
605
+ "ssh-keyscan",
606
+ "ssh-keyscan",
607
+ "discovers the host key to pin",
608
+ "apt install openssh-client",
609
+ ],
610
+ ["git", "git", "projects and worktrees", "apt install git"],
611
+ ] as const) {
612
+ const found = await onPath(command);
613
+ if (command === "ssh-keygen") {
614
+ checks.push(
615
+ found ? ok(label, why) : fatal(label, `not on PATH — ${why}`, fix),
616
+ );
617
+ } else {
618
+ checks.push(
619
+ found ? ok(label, why) : bad(label, `not on PATH — ${why}`, fix),
620
+ );
621
+ }
622
+ }
623
+
624
+ checks.push(
625
+ config.sessions
626
+ ? ok(
627
+ "dtach",
628
+ "found — sessions survive reloads, and profiles can launch a harness",
629
+ )
630
+ : bad(
631
+ "dtach",
632
+ "not installed — windows become plain shells and profiles launch nothing at all",
633
+ "apt install dtach",
634
+ ),
635
+ );
636
+
637
+ const claude = await onPath("claude");
638
+ checks.push(
639
+ claude
640
+ ? ok("claude", "on PATH — the Claude harness will run")
641
+ : bad(
642
+ "claude",
643
+ "not on PATH — profiles using the Claude harness fall back to a shell",
644
+ "curl -fsSL https://claude.ai/install.sh | bash",
645
+ ),
646
+ );
647
+
648
+ checks.push(...(await hermesChecks()));
649
+ checks.push(...(await cursorChecks()));
650
+
651
+ // ── can a browser actually log in? ───────────────────────────────────────
652
+ const sshdUp = await probeTcp(config.sshHost, config.sshPort);
653
+ checks.push(
654
+ sshdUp
655
+ ? ok("sshd", `reachable at ${config.sshHost}:${config.sshPort}`)
656
+ : fatal(
657
+ "sshd",
658
+ `nothing listening on ${config.sshHost}:${config.sshPort} — no window will connect`,
659
+ "systemctl enable --now ssh",
660
+ ),
661
+ );
662
+
663
+ const home = await homeFor(user);
664
+ if (!home) {
665
+ checks.push(
666
+ fatal(
667
+ "login user",
668
+ `no such user: ${user}`,
669
+ "pass --user <name> for an account that exists",
670
+ ),
671
+ );
672
+ } else {
673
+ const mine = os.homedir();
674
+ checks.push(
675
+ home === mine
676
+ ? ok("login user", `${user} (${home})`)
677
+ : bad(
678
+ "login user",
679
+ `windows log in as ${user} (${home}), but vibe-os is running as ` +
680
+ `${os.userInfo().username} (${mine}) and writes its CA line there`,
681
+ `run vibe-os as ${user}, or drop the --user flag`,
682
+ ),
683
+ );
684
+ }
685
+
686
+ const sshd = await sshdEffectiveConfig(user);
687
+ if (!sshd) {
688
+ checks.push(
689
+ bad(
690
+ "sshd config",
691
+ "could not read sshd's effective config — the checks below are the defaults, not the truth",
692
+ "sudo vibe-os doctor",
693
+ ),
694
+ );
695
+ }
696
+
697
+ if (sshd) {
698
+ const pubkey = sshd.get("pubkeyauthentication") ?? "yes";
699
+ checks.push(
700
+ pubkey === "yes"
701
+ ? ok("pubkey auth", "enabled")
702
+ : fatal(
703
+ "pubkey auth",
704
+ "PubkeyAuthentication is off — certificates cannot be used",
705
+ "set PubkeyAuthentication yes",
706
+ ),
707
+ );
708
+
709
+ // Allow/Deny lists are the quiet way a hardened VPS refuses the very user
710
+ // vibe-os is about to hand out certificates for.
711
+ const allowUsers = sshd.get("allowusers");
712
+ const denyUsers = sshd.get("denyusers");
713
+ const listed = (spec: string | undefined) =>
714
+ spec?.split(/\s+/).filter(Boolean) ?? [];
715
+ if (denyUsers && listed(denyUsers).some((pattern) => pattern === user)) {
716
+ checks.push(
717
+ fatal(
718
+ "sshd access",
719
+ `DenyUsers excludes ${user}`,
720
+ `remove ${user} from DenyUsers`,
721
+ ),
722
+ );
723
+ } else if (
724
+ allowUsers &&
725
+ !listed(allowUsers).some(
726
+ (pattern) => pattern === user || pattern.includes("*"),
727
+ )
728
+ ) {
729
+ checks.push(
730
+ fatal(
731
+ "sshd access",
732
+ `AllowUsers does not include ${user}`,
733
+ `add ${user} to AllowUsers in sshd_config`,
734
+ ),
735
+ );
736
+ } else {
737
+ checks.push(ok("sshd access", `${user} is permitted to log in`));
738
+ }
739
+ }
740
+
741
+ // ── is the CA trusted in the file sshd will actually read? ───────────────
742
+ const ca = new SshCa(config.stateDir);
743
+ let caBlob = "";
744
+ try {
745
+ await stat(ca.pubPath);
746
+ await ca.ensure();
747
+ caBlob = ca.publicKey.split(/\s+/)[1] ?? "";
748
+ } catch {
749
+ // not created yet
750
+ }
751
+
752
+ if (!caBlob) {
753
+ checks.push(ok("ssh CA", "not created yet — the first start writes it"));
754
+ } else if (home) {
755
+ const spec =
756
+ sshd?.get("authorizedkeysfile") ??
757
+ ".ssh/authorized_keys .ssh/authorized_keys2";
758
+ const candidates = authorizedKeysPaths(spec, home, user);
759
+ let foundIn: string | null = null;
760
+ for (const file of candidates) {
761
+ const contents = await readFile(file, "utf8").catch(() => "");
762
+ if (contents.includes(caBlob)) {
763
+ foundIn = file;
764
+ break;
765
+ }
766
+ }
767
+ checks.push(
768
+ foundIn
769
+ ? ok("ssh CA", `trusted in ${foundIn}`)
770
+ : fatal(
771
+ "ssh CA",
772
+ `the CA line is not in any file sshd reads for ${user} (${candidates.join(", ")})`,
773
+ `append this to ${candidates[0]}: cert-authority ${ca.publicKey}`,
774
+ ),
775
+ );
776
+
777
+ if (sshd?.get("strictmodes") !== "no") {
778
+ const problems = await badPermissions(home);
779
+ checks.push(
780
+ problems.length === 0
781
+ ? ok("key file perms", "StrictModes satisfied")
782
+ : fatal(
783
+ "key file perms",
784
+ `sshd will ignore authorized_keys: ${problems.join("; ")}`,
785
+ `chmod go-w ${home} ${home}/.ssh ${home}/.ssh/authorized_keys`,
786
+ ),
787
+ );
788
+ }
789
+ }
790
+
791
+ const hostKey = await discoverHostKey(config.sshHost, config.sshPort);
792
+ checks.push(
793
+ hostKey
794
+ ? ok(
795
+ "host key",
796
+ `${hostKey.split(" ")[0]} discovered — pinned, no trust prompt`,
797
+ )
798
+ : bad(
799
+ "host key",
800
+ "not discoverable — the browser will prompt once on first connect",
801
+ ),
802
+ );
803
+
804
+ // ── serving ──────────────────────────────────────────────────────────────
805
+ // A compiled binary carries the web assets inside it — there is no directory
806
+ // to check, and checking one reports a fatal error on a perfectly good
807
+ // install. This is the recommended way to run on a VPS, so getting it wrong
808
+ // here would fail exactly the people this command exists for.
809
+ if (IS_COMPILED) {
810
+ checks.push(ok("web build", "embedded in this binary"));
811
+ } else {
812
+ const built = await stat(path.join(config.webRoot, "index.html")).then(
813
+ () => true,
814
+ () => false,
815
+ );
816
+ checks.push(
817
+ built
818
+ ? ok("web build", config.webRoot)
819
+ : fatal("web build", `missing at ${config.webRoot}`, "bun run build"),
820
+ );
821
+ }
822
+
823
+ const bind = await bindStatus(config.port, config.host);
824
+ if (bind === "free") {
825
+ checks.push(ok(`port ${config.port}`, `bindable on ${config.host}`));
826
+ } else if (bind === "in-use") {
827
+ checks.push(
828
+ ok(`port ${config.port}`, "already in use — vibe-os is probably running"),
829
+ );
830
+ } else {
831
+ checks.push(
832
+ bad(
833
+ `port ${config.port}`,
834
+ `permission denied binding ${config.host}:${config.port}`,
835
+ config.port < 1024
836
+ ? "use a high port with Tailscale in front, or: sudo setcap 'cap_net_bind_service=+ep' $(readlink -f \"$(which bun)\")"
837
+ : "check what else holds this port",
838
+ ),
839
+ );
840
+ }
841
+
842
+ // ── firewall ─────────────────────────────────────────────────────────────
843
+ //
844
+ // Reads the host firewall's configuration. Deliberately a warning and never
845
+ // fatal: a box on a trusted LAN or inside the container legitimately has no
846
+ // firewall, and doctor exits non-zero on fatals.
847
+ const fw = await firewallState();
848
+ // Said on every outcome, including the good one, because a ✓ here is the
849
+ // moment someone is most likely to conclude more than it means.
850
+ const scope =
851
+ " (local rules only: your provider's firewall is separate, and proving a port is shut needs a packet from outside)";
852
+
853
+ if (fw.unreadable) {
854
+ checks.push(
855
+ bad(
856
+ "firewall",
857
+ "could not read any firewall — needs root, or none is installed",
858
+ "sudo vibe-os doctor (and see the firewall rules in the README)",
859
+ ),
860
+ );
861
+ } else if (!fw.active) {
862
+ checks.push(
863
+ bad(
864
+ "firewall",
865
+ `${fw.tool} found but not active — this machine answers on its public address`,
866
+ "sudo ufw allow in on tailscale0 && sudo ufw allow 41641/udp && sudo ufw default deny incoming && sudo ufw enable",
867
+ ),
868
+ );
869
+ } else if (!fw.defaultDeny) {
870
+ checks.push(
871
+ bad(
872
+ "firewall",
873
+ `${fw.tool} is active but does not deny incoming by default`,
874
+ "sudo ufw default deny incoming",
875
+ ),
876
+ );
877
+ } else {
878
+ const missing = [
879
+ fw.tailnetAllowed ? null : "no allow rule for the tailscale interface",
880
+ fw.directUdpAllowed
881
+ ? null
882
+ : "41641/udp closed, so Tailscale relays via DERP",
883
+ ].filter(Boolean);
884
+ checks.push(
885
+ missing.length === 0
886
+ ? ok(
887
+ "firewall",
888
+ `${fw.tool}: incoming denied by default, tailnet and 41641/udp allowed${scope}`,
889
+ )
890
+ : bad(
891
+ "firewall",
892
+ `${fw.tool} denies incoming by default, but ${missing.join("; ")}`,
893
+ "sudo ufw allow in on tailscale0 && sudo ufw allow 41641/udp",
894
+ ),
895
+ );
896
+ }
897
+
898
+ // ── the always-on browser ────────────────────────────────────────────────
899
+ //
900
+ // Only reported when there is something to report. vibe-os is complete
901
+ // without a browser on the box, and a machine that never asked for one should
902
+ // not be told about four things it does not have.
903
+ checks.push(...(await browserChecks()));
904
+
905
+ // ── leaked processes ─────────────────────────────────────────────────────
906
+ //
907
+ // Processes that outlived their workspace or session: a cwd that is a
908
+ // deleted path under `.vibe-worktrees/`, or a tie to a session socket that
909
+ // no longer exists. Teardown is supposed to make these impossible; this is
910
+ // how the times it did not (#38, #39) get found without an afternoon of ps.
911
+ const leaks = await findLeaks(config);
912
+ if (leaks.length === 0) {
913
+ checks.push(
914
+ ok("leaks", "no process has outlived its workspace or session"),
915
+ );
916
+ } else {
917
+ const shown = leaks
918
+ .slice(0, 4)
919
+ .map((l) => `${l.pid} (${l.args.slice(0, 40)})`)
920
+ .join(", ");
921
+ const more = leaks.length > 4 ? `, and ${leaks.length - 4} more` : "";
922
+ checks.push(
923
+ bad(
924
+ "leaks",
925
+ `${leaks.length} process${leaks.length === 1 ? " has" : "es have"} outlived their workspace or session: ${shown}${more}`,
926
+ "vibe-os doctor --reap",
927
+ ),
928
+ );
929
+ }
930
+
931
+ // ── exposure ─────────────────────────────────────────────────────────────
932
+ //
933
+ // The one check that is about the box rather than the software. vibe-os hands
934
+ // out shells; who can reach the port is the whole security model.
935
+ const tailnet = await tailscaleAddress();
936
+ const wildcard = config.host === "0.0.0.0" || config.host === "::";
937
+
938
+ if (config.token) {
939
+ checks.push(
940
+ ok(
941
+ "exposure",
942
+ `token required${tailnet ? `, and Tailscale is up (${tailnet})` : ""}`,
943
+ ),
944
+ );
945
+ } else if (!wildcard) {
946
+ checks.push(ok("exposure", `no token, but bound only to ${config.host}`));
947
+ } else if (tailnet) {
948
+ checks.push(
949
+ bad(
950
+ "exposure",
951
+ `no token and bound to every interface — Tailscale is up (${tailnet}), but so is any public address`,
952
+ `bind to the tailnet only: --host ${tailnet} (and see the firewall rules in the README)`,
953
+ ),
954
+ );
955
+ } else {
956
+ checks.push(
957
+ bad(
958
+ "exposure",
959
+ "no token, bound to every interface, and no tailnet found — anyone who can reach this port gets a shell",
960
+ "start with --token, or put it behind Tailscale and bind to the tailnet address",
961
+ ),
962
+ );
963
+ }
964
+
965
+ return checks;
966
+ }