@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +988 -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 +857 -0
  41. package/server/config.ts +385 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +977 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +655 -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/setup.ts +1151 -0
  58. package/server/ssh-ca.ts +368 -0
  59. package/server/static.ts +147 -0
  60. package/server/wallpapers.ts +248 -0
  61. package/server/windows.ts +237 -0
  62. package/shared/args.ts +91 -0
  63. package/shared/grid.ts +32 -0
  64. package/shared/wire.ts +226 -0
package/server/cli.ts ADDED
@@ -0,0 +1,857 @@
1
+ import { execFile, spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { promisify } from "node:util";
8
+ import pkg from "../package.json" with { type: "json" };
9
+ import {
10
+ type AttachTarget,
11
+ commandFor,
12
+ listTargets,
13
+ liveSessions,
14
+ resolveTarget,
15
+ } from "./attach.ts";
16
+ import {
17
+ archSupported,
18
+ BROWSER_COMMANDS,
19
+ browserServices,
20
+ browserUnits,
21
+ CHROME_UNIT_PATH,
22
+ DEFAULT_CDP_PORT,
23
+ defaultBrowserOptions,
24
+ hermesBrowserSettings,
25
+ portsFromUnits,
26
+ type Unit,
27
+ VNC_PASSWORD_LENGTH,
28
+ vncPasswordFileFromUnit,
29
+ vncPasswordPath,
30
+ XVNC_UNIT_PATH,
31
+ } from "./browser.ts";
32
+ import {
33
+ type Config,
34
+ parseCliArgs,
35
+ type RawOptions,
36
+ resolveConfig,
37
+ savePersisted,
38
+ } from "./config.ts";
39
+ import { openDb } from "./db.ts";
40
+ import { type Check, homeFor, onPath, runDoctor } from "./doctor.ts";
41
+ import { HERMES_INSTALL } from "./harness.ts";
42
+ import { startServer } from "./index.ts";
43
+ import { color, describeError, log } from "./log.ts";
44
+ import { reapLeaks } from "./reaper.ts";
45
+ import { ENTRY, FETCH_WASM, IS_COMPILED } from "./runtime.ts";
46
+ import { confirmFirewall, runSetup } from "./setup.ts";
47
+
48
+ const run = promisify(execFile);
49
+ const BUN = process.execPath;
50
+ /** Plenty for the fetcher's few lines of progress output. */
51
+ const FETCH_OUTPUT_LIMIT = 1024 * 1024;
52
+
53
+ const HELP = `
54
+ ${color.bold("vibe-os")} — a terminal multiplexer in the browser
55
+
56
+ ${color.bold("Usage")}
57
+ vibe-os [start] serve the UI and the SSH bridge
58
+ vibe-os attach [window] attach a real terminal to a window's session
59
+ vibe-os doctor check this machine is ready
60
+ vibe-os setup fix what doctor finds, asking first
61
+ vibe-os install-service write and enable a systemd unit (needs root)
62
+ vibe-os install-browser run one Chrome on a virtual display (needs root)
63
+ vibe-os connect-hermes point Hermes' browser tools at that Chrome
64
+ vibe-os fetch-wasm (re)download the SSH WASM runtime
65
+
66
+ ${color.bold("Options")}
67
+ --port <n> HTTP port (default 80)
68
+ --host <addr> bind address (default 0.0.0.0)
69
+ --domain <fqdn> provision a Let's Encrypt certificate and serve HTTPS
70
+ --email <addr> contact address for Let's Encrypt
71
+ --acme-staging use the Let's Encrypt staging environment
72
+ --tls-port <n> HTTPS port (default 443)
73
+
74
+ --token [value] the token to require (default: the last one used, or
75
+ a new one, remembered in the state dir)
76
+ --no-token disable the gate (prints a warning)
77
+ --allowed-host <name>
78
+ with no token, a name this server answers to beyond
79
+ its hostname, --domain and its Tailscale name.
80
+ Repeatable
81
+
82
+ --ssh-host <addr> SSH target for the bridge (default 127.0.0.1)
83
+ --ssh-port <n> SSH target port (default 22)
84
+ --ssh-advertise <host[:port]>
85
+ host to print in attach commands, when it is not the
86
+ one the browser reached the desktop on
87
+ --user <name> unix user to log in as (default: current user)
88
+ --no-sessions plain login shells instead of persistent dtach sessions
89
+ --cert-ttl <secs> certificate lifetime (default 43200)
90
+
91
+ --workspace <dir> root for projects and worktrees (default ~/workspace)
92
+ --state-dir <dir> CA and TLS material (default ~/.vibe-os)
93
+ --web-root <dir> built web assets
94
+
95
+ -h, --help show this
96
+ -v, --version print the version
97
+
98
+ ${color.bold("install-browser")}
99
+ --geometry <WxH> virtual screen size (default 1600x900)
100
+ --display <n> X display number (default 99)
101
+ --vnc-port <n> VNC port, bound to loopback (default 5900)
102
+ --cdp-port <n> Chrome debug port, bound to loopback (default 9222)
103
+ --restart-at <expr> nightly restart, a systemd OnCalendar expression
104
+ (default '*-*-* 02:00:00', the box's own timezone)
105
+ --no-restart do not install the nightly restart timer
106
+ --vnc-password [value]
107
+ require a VNC password; generates and prints one if
108
+ omitted. macOS Screen Sharing will not connect without
109
+ this. An existing one is kept unless --no-vnc-password
110
+ --no-vnc-password serve the display with no authentication
111
+
112
+ ${color.bold("connect-hermes")}
113
+ --cdp-port <n> the debug port to point Hermes at, when there is no
114
+ installed browser unit to read it from
115
+
116
+ ${color.bold("doctor")}
117
+ --reap kill processes that outlived their workspace or session
118
+
119
+ ${color.bold("setup")}
120
+ -y, --yes take the default answer to every question
121
+ --firewall close the box to all but the tailnet with ufw; needed
122
+ with --yes, which otherwise leaves the firewall alone
123
+ --hermes install Hermes too (asked otherwise, default no)
124
+ --cursor install Cursor too (asked otherwise, default no)
125
+ --user <name> as root: the user to create and run vibe-os as
126
+ `;
127
+
128
+ function version(): string {
129
+ return pkg.version;
130
+ }
131
+
132
+ /**
133
+ * Prints the check list.
134
+ *
135
+ * Failures carry the command that fixes them, indented underneath, because the
136
+ * person reading this is on a fresh box and should not have to go and look it
137
+ * up somewhere else.
138
+ */
139
+ function printChecks(checks: Check[]): number {
140
+ console.log("");
141
+ let failed = 0;
142
+ for (const check of checks) {
143
+ const mark = check.ok
144
+ ? color.green("\u2713")
145
+ : check.fatal
146
+ ? color.red("\u2717")
147
+ : color.yellow("!");
148
+ if (!check.ok && check.fatal) failed += 1;
149
+ console.log(
150
+ ` ${mark} ${color.bold(check.label.padEnd(14))} ${color.dim(check.detail)}`,
151
+ );
152
+ if (!check.ok && check.fix)
153
+ console.log(` ${" ".repeat(14)} ${color.cyan(check.fix)}`);
154
+ }
155
+ console.log("");
156
+ if (failed > 0) {
157
+ console.log(
158
+ ` ${color.red(`${failed} blocking problem${failed === 1 ? "" : "s"}.`)} vibe-os will not work until these are fixed.`,
159
+ );
160
+ console.log("");
161
+ }
162
+ return failed > 0 ? 1 : 0;
163
+ }
164
+
165
+ const UNIT_PATH = "/etc/systemd/system/vibe-os.service";
166
+
167
+ async function installService(config: Config, argv: string[]): Promise<number> {
168
+ if (process.getuid?.() !== 0) {
169
+ log.error(
170
+ "install-service must run as root (try: sudo vibe-os install-service …)",
171
+ );
172
+ return 1;
173
+ }
174
+
175
+ // Forward the flags used here to the unit so the service behaves identically.
176
+ const forwarded = argv.filter((a) => a !== "install-service");
177
+
178
+ const user = process.env.SUDO_USER ?? config.user;
179
+ if (!user || user === "unknown" || user === "root") {
180
+ log.error(
181
+ `refusing to install a unit that runs as ${user || "an unknown user"} — ` +
182
+ "windows would get a root shell, and the CA line would land in root's home.",
183
+ );
184
+ log.error(
185
+ "run this with sudo from your own account, or pass --user <name>.",
186
+ );
187
+ return 1;
188
+ }
189
+
190
+ // Asked for, not built from a template: a home directory is whatever the
191
+ // passwd entry says, and /home/<user> is only usually right.
192
+ const home = await homeFor(user);
193
+ if (!home) {
194
+ log.error(`could not resolve a home directory for ${user}`);
195
+ return 1;
196
+ }
197
+
198
+ /**
199
+ * A compiled binary is its own entry point.
200
+ *
201
+ * `process.execPath` plus the source path is right from a checkout and wrong
202
+ * everywhere else: inside a standalone executable the source lives at a
203
+ * virtual `/$bunfs/` path that exists only while the process is running. The
204
+ * generated unit would pass that path as the subcommand and fail on every
205
+ * boot — on precisely the install the VPS instructions recommend.
206
+ */
207
+ const command = IS_COMPILED ? process.execPath : `${BUN} ${ENTRY}`;
208
+
209
+ const unit = `[Unit]
210
+ Description=vibe-os — terminal multiplexer in the browser
211
+ After=network-online.target sshd.service
212
+ Wants=network-online.target
213
+
214
+ [Service]
215
+ Type=simple
216
+ User=${user}
217
+ Environment=HOME=${home}
218
+ # systemd hands a service a minimal PATH, which omits the ~/.local/bin that the
219
+ # Claude installer — and most "curl | sh" installers — write to. A login shell
220
+ # gets it from .profile, so without this the harness a window launches resolves
221
+ # and the server's own lookup of the same binary does not.
222
+ Environment=PATH=${home}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
223
+ Environment=NODE_ENV=production
224
+ WorkingDirectory=${home}
225
+ ExecStart=${command} start${forwarded.length ? ` ${forwarded.join(" ")}` : ""}
226
+ Restart=on-failure
227
+ RestartSec=2
228
+ # Lets an unprivileged user bind port 80 without setcap on the node binary.
229
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
230
+ CapabilityBoundingSet=CAP_NET_BIND_SERVICE
231
+ NoNewPrivileges=false
232
+
233
+ [Install]
234
+ WantedBy=multi-user.target
235
+ `;
236
+
237
+ await writeFile(UNIT_PATH, unit, { mode: 0o644 });
238
+ log.ok(`wrote ${UNIT_PATH}`);
239
+
240
+ await run("systemctl", ["daemon-reload"]);
241
+ await run("systemctl", ["enable", "--now", "vibe-os"]);
242
+ log.ok("enabled and started vibe-os.service");
243
+ console.log("");
244
+ console.log(` logs: ${color.cyan("journalctl -u vibe-os -f")}`);
245
+ console.log(` restart: ${color.cyan("systemctl restart vibe-os")}`);
246
+ console.log("");
247
+ return 0;
248
+ }
249
+
250
+ /**
251
+ * Installs the always-on browser.
252
+ *
253
+ * Separate from `install-service` on purpose. vibe-os runs perfectly well with
254
+ * no browser on the box, the packages this needs are large, and Chrome for
255
+ * Linux is x86_64 only — so this is opted into rather than arriving with
256
+ * everything else.
257
+ */
258
+ async function installBrowser(
259
+ config: Config,
260
+ values: RawOptions,
261
+ ): Promise<number> {
262
+ if (process.getuid?.() !== 0) {
263
+ log.error(
264
+ "install-browser must run as root (try: sudo vibe-os install-browser …)",
265
+ );
266
+ return 1;
267
+ }
268
+
269
+ if (!archSupported()) {
270
+ log.error(
271
+ `Google publishes no Chrome for Linux on ${os.arch()} — only x86_64.`,
272
+ );
273
+ log.error(
274
+ "Chromium is not a substitute here: the Claude extension requires Chrome itself.",
275
+ );
276
+ return 1;
277
+ }
278
+
279
+ const user = process.env.SUDO_USER ?? config.user;
280
+ if (!user || user === "unknown" || user === "root") {
281
+ log.error(
282
+ `refusing to run a browser as ${user || "an unknown user"} — Chrome will ` +
283
+ "not start as root without --no-sandbox, and turning the sandbox off on " +
284
+ "a box that holds your credentials is not a trade worth making.",
285
+ );
286
+ log.error("run this with sudo from your own account.");
287
+ return 1;
288
+ }
289
+
290
+ const home = await homeFor(user);
291
+ if (!home) {
292
+ log.error(`could not resolve a home directory for ${user}`);
293
+ return 1;
294
+ }
295
+
296
+ // Every missing command at once. Finding out about them one install at a time
297
+ // is three round trips to a box you are probably ssh'd into.
298
+ const missing: string[] = [];
299
+ for (const entry of BROWSER_COMMANDS) {
300
+ if (!(await onPath(entry.command)))
301
+ missing.push(`${entry.command} (${entry.package}) — ${entry.why}`);
302
+ }
303
+ if (missing.length > 0) {
304
+ log.error("missing commands:");
305
+ for (const line of missing) console.log(` ${line}`);
306
+ console.log("");
307
+ console.log(
308
+ ` ${color.cyan("sudo apt install -y tigervnc-standalone-server openbox x11-utils")}`,
309
+ );
310
+ console.log(
311
+ ` ${color.dim("Chrome is not in the distro repos; see the README for the Google apt repo.")}`,
312
+ );
313
+ console.log("");
314
+ return 1;
315
+ }
316
+
317
+ const opts = defaultBrowserOptions(user, home);
318
+ if (values.geometry) opts.geometry = String(values.geometry);
319
+ if (values.display) opts.display = numeric(values.display, "display");
320
+ if (values["vnc-port"])
321
+ opts.vncPort = numeric(values["vnc-port"], "vnc-port");
322
+ if (values["cdp-port"])
323
+ opts.cdpPort = numeric(values["cdp-port"], "cdp-port");
324
+ if (values["restart-at"]) opts.restartAt = String(values["restart-at"]);
325
+ if (values["no-restart"]) opts.restartAt = null;
326
+
327
+ // VNC authentication, resolved before anything is written.
328
+ //
329
+ // The default is "whatever this box already had". install-browser rewrites
330
+ // every unit, so a reinstall is exactly when a password someone set by hand
331
+ // would disappear without a word.
332
+ const existing = vncPasswordFileFromUnit(
333
+ await readFile(XVNC_UNIT_PATH, "utf8").catch(() => null),
334
+ );
335
+ let generated: string | null = null;
336
+
337
+ if (values["no-vnc-password"]) {
338
+ opts.vncPasswordFile = null;
339
+ } else if (values["vnc-password"] !== undefined) {
340
+ const supplied = String(values["vnc-password"]);
341
+ // A bare --vnc-password means "make one up", and the caller has to be told
342
+ // what it was, so remember it for the summary rather than only writing it.
343
+ if (!supplied) generated = randomVncPassword();
344
+ const password = supplied || (generated as string);
345
+ const file = vncPasswordPath(home);
346
+ try {
347
+ await writeVncPassword(file, password, user);
348
+ } catch (err) {
349
+ log.error(`could not write ${file}: ${describeError(err)}`);
350
+ return 1;
351
+ }
352
+ opts.vncPasswordFile = file;
353
+ if (supplied.length > VNC_PASSWORD_LENGTH)
354
+ log.warn(
355
+ `VNC authentication truncates at ${VNC_PASSWORD_LENGTH} characters, so only the first ${VNC_PASSWORD_LENGTH} count`,
356
+ );
357
+ } else if (existing) {
358
+ opts.vncPasswordFile = existing;
359
+ log.info(`keeping the VNC password already set in ${existing}`);
360
+ }
361
+
362
+ let units: Unit[];
363
+ try {
364
+ units = browserUnits(opts);
365
+ } catch (err) {
366
+ log.error(describeError(err));
367
+ return 1;
368
+ }
369
+
370
+ // systemd is the authority on its own calendar syntax, and a timer that never
371
+ // fires is silent. Ask it before writing the unit rather than after.
372
+ if (opts.restartAt) {
373
+ try {
374
+ await run("systemd-analyze", ["calendar", opts.restartAt]);
375
+ } catch {
376
+ log.error(
377
+ `systemd cannot parse --restart-at ${JSON.stringify(opts.restartAt)}`,
378
+ );
379
+ log.error(
380
+ "try: --restart-at '*-*-* 02:00:00 America/New_York' (include the timezone)",
381
+ );
382
+ return 1;
383
+ }
384
+ }
385
+
386
+ await mkdir(opts.profileDir, { recursive: true });
387
+ await run("chown", ["-R", `${user}:`, opts.profileDir]).catch(() => {});
388
+
389
+ for (const unit of units) {
390
+ await writeFile(`/etc/systemd/system/${unit.name}`, unit.contents, {
391
+ mode: 0o644,
392
+ });
393
+ log.ok(`wrote /etc/systemd/system/${unit.name}`);
394
+ }
395
+
396
+ await run("systemctl", ["daemon-reload"]);
397
+ await run("systemctl", ["enable", "--now", ...browserServices(opts)]);
398
+ log.ok("enabled and started the browser");
399
+
400
+ console.log("");
401
+ console.log(
402
+ ` view it: ${color.cyan(`ssh -L ${opts.vncPort}:127.0.0.1:${opts.vncPort} ${user}@<this-host>`)}`,
403
+ );
404
+ console.log(
405
+ ` ${color.dim(`then point any VNC viewer at 127.0.0.1:${opts.vncPort}`)}`,
406
+ );
407
+ console.log(` logs: ${color.cyan("journalctl -u vibe-os-chrome -f")}`);
408
+ console.log(` restart: ${color.cyan("systemctl restart vibe-os-chrome")}`);
409
+ if (opts.restartAt) console.log(` nightly: ${color.dim(opts.restartAt)}`);
410
+ console.log("");
411
+ if (generated) {
412
+ console.log(` ${color.bold("VNC password:")} ${color.cyan(generated)}`);
413
+ console.log(
414
+ ` ${color.dim("shown once, and stored obfuscated in " + opts.vncPasswordFile)}`,
415
+ );
416
+ } else if (opts.vncPasswordFile) {
417
+ console.log(
418
+ ` ${color.dim(`VNC password from ${opts.vncPasswordFile}, username blank`)}`,
419
+ );
420
+ } else {
421
+ console.log(
422
+ ` ${color.dim("No VNC password. macOS Screen Sharing needs one: rerun with --vnc-password")}`,
423
+ );
424
+ }
425
+ console.log("");
426
+ console.log(
427
+ ` ${color.dim("Sign in to the extension once, through the viewer. The profile keeps it.")}`,
428
+ );
429
+
430
+ // Last, and quietly. Hermes usually arrives after the browser, so not finding
431
+ // it is the common case rather than a failure worth interrupting the summary.
432
+ if (await connectHermes(user, home, opts.cdpPort, true)) {
433
+ console.log("");
434
+ console.log(
435
+ ` ${color.dim(`Hermes browser tools point at 127.0.0.1:${opts.cdpPort}.`)}`,
436
+ );
437
+ } else {
438
+ console.log("");
439
+ console.log(
440
+ ` ${color.dim("For Hermes browser tools, install it and run: vibe-os connect-hermes")}`,
441
+ );
442
+ }
443
+ console.log("");
444
+ return 0;
445
+ }
446
+
447
+ /**
448
+ * Points Hermes' browser tools at this box's Chrome.
449
+ *
450
+ * Every setting goes through `hermes config set` rather than into the YAML
451
+ * directly. Hermes owns that file, knows which of its keys are secrets and
452
+ * belong in `.env` instead, and migrates its own schema between versions —
453
+ * three things a hand-written merge here would have to keep guessing at.
454
+ *
455
+ * ── Why this is a box-wide setting and not a profile one ─────────────────────
456
+ * The CDP target lives at `browser.cdp_url` in `~/.hermes/config.yaml` and has
457
+ * no command-line equivalent, so there is nothing a profile could carry. Doing
458
+ * it per launch would mean two Python startups before every window and a global
459
+ * file rewritten by whichever window opened last.
460
+ *
461
+ * Returns false when Hermes is not installed, which is not an error: plenty of
462
+ * boxes run the browser for the Claude extension and nothing else.
463
+ */
464
+ async function connectHermes(
465
+ user: string,
466
+ home: string,
467
+ cdpPort: number,
468
+ /** True when this process is root and has to drop to the login user. */
469
+ asUser: boolean,
470
+ ): Promise<boolean> {
471
+ /*
472
+ * A login shell, deliberately.
473
+ *
474
+ * Hermes installs to `/usr/local/bin` when it can write there and
475
+ * `~/.local/bin` when it cannot, and root's PATH reaches neither reliably
476
+ * under systemd. `runuser -l` runs `.profile`, which is what put the second
477
+ * one on PATH in the first place. runuser is util-linux, the same package
478
+ * `flock` comes from, so it is present wherever the session lock already is.
479
+ */
480
+ const asLoginUser = (script: string): [string, string[]] =>
481
+ asUser
482
+ ? ["runuser", ["-u", user, "--", "/bin/sh", "-lc", script]]
483
+ : ["/bin/sh", ["-lc", script]];
484
+
485
+ const [probe, probeArgs] = asLoginUser("command -v hermes");
486
+ const installed = await run(probe, probeArgs, {
487
+ timeout: 10_000,
488
+ env: { ...process.env, HOME: home },
489
+ })
490
+ .then(({ stdout }) => Boolean(stdout.trim()))
491
+ .catch(() => false);
492
+ if (!installed) return false;
493
+
494
+ for (const { key, value } of hermesBrowserSettings(cdpPort)) {
495
+ // Quoted as a single argument: a value never contains a space today, and
496
+ // the day one does is not the day to find out this was a bare expansion.
497
+ const [cmd, args] = asLoginUser(
498
+ `hermes config set ${JSON.stringify(key)} ${JSON.stringify(value)}`,
499
+ );
500
+ try {
501
+ await run(cmd, args, {
502
+ timeout: 60_000,
503
+ env: { ...process.env, HOME: home },
504
+ });
505
+ log.ok(`hermes ${key} = ${value}`);
506
+ } catch (err) {
507
+ log.error(`could not set hermes ${key}: ${describeError(err)}`);
508
+ return false;
509
+ }
510
+ }
511
+ return true;
512
+ }
513
+
514
+ /**
515
+ * `vibe-os connect-hermes` — the same thing, for a Hermes installed later.
516
+ *
517
+ * `install-browser` does this as its last step, but it is the browser's
518
+ * installer and Hermes usually arrives after it. No root: it writes one file in
519
+ * your own home directory, through a command you own.
520
+ */
521
+ async function connectHermesCommand(
522
+ config: Config,
523
+ values: RawOptions,
524
+ ): Promise<number> {
525
+ const user = process.env.SUDO_USER ?? config.user;
526
+ const home = (await homeFor(user)) ?? os.homedir();
527
+
528
+ // What the unit actually opened, not what the default is. install-browser may
529
+ // have been given --cdp-port long ago, and writing the default into Hermes
530
+ // would point it at a port nothing is listening on.
531
+ const unit = await readFile(CHROME_UNIT_PATH, "utf8").catch(() => null);
532
+ const cdpPort = values["cdp-port"]
533
+ ? numeric(values["cdp-port"], "cdp-port")
534
+ : unit
535
+ ? portsFromUnits({ chrome: unit }).cdpPort
536
+ : DEFAULT_CDP_PORT;
537
+
538
+ if (!unit && !values["cdp-port"]) {
539
+ log.warn(
540
+ `no browser installed here, so this assumes the default port ${cdpPort}`,
541
+ );
542
+ log.warn("run sudo vibe-os install-browser first, or pass --cdp-port");
543
+ }
544
+
545
+ const connected = await connectHermes(
546
+ user,
547
+ home,
548
+ cdpPort,
549
+ process.getuid?.() === 0,
550
+ );
551
+ if (!connected) {
552
+ log.error(`hermes is not installed for ${user}`);
553
+ console.log("");
554
+ console.log(` ${color.cyan(HERMES_INSTALL)}`);
555
+ console.log("");
556
+ return 1;
557
+ }
558
+
559
+ console.log("");
560
+ console.log(
561
+ ` Hermes browser tools will drive the box's Chrome on 127.0.0.1:${cdpPort}.`,
562
+ );
563
+ console.log(
564
+ ` ${color.dim("Watch it work: ssh -L 5900:127.0.0.1:5900, then any VNC viewer.")}`,
565
+ );
566
+ console.log("");
567
+ return 0;
568
+ }
569
+
570
+ /** A flag that must be a positive integer, rejected loudly when it is not. */
571
+ function numeric(value: string | boolean, label: string): number {
572
+ const n = Number(String(value));
573
+ if (!Number.isInteger(n) || n < 0)
574
+ throw new Error(`invalid --${label}: ${String(value)}`);
575
+ return n;
576
+ }
577
+
578
+ /**
579
+ * Eight characters, because VNC authentication silently ignores the rest.
580
+ *
581
+ * Alphanumeric only: this gets typed into a viewer's password box by hand, and
582
+ * a character that needs a modifier on somebody's keyboard layout is a support
583
+ * question rather than security.
584
+ */
585
+ function randomVncPassword(): string {
586
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
587
+ const bytes = randomBytes(VNC_PASSWORD_LENGTH * 2);
588
+ let out = "";
589
+ for (const byte of bytes) {
590
+ if (out.length === VNC_PASSWORD_LENGTH) break;
591
+ // Reject the tail of the byte range rather than modulo it, so every
592
+ // character stays equally likely.
593
+ if (byte >= 256 - (256 % alphabet.length)) continue;
594
+ out += alphabet[byte % alphabet.length];
595
+ }
596
+ return out.padEnd(VNC_PASSWORD_LENGTH, "x");
597
+ }
598
+
599
+ /**
600
+ * Writes TigerVNC's obfuscated password file.
601
+ *
602
+ * The plaintext goes in on stdin rather than as an argument: an argument would
603
+ * be visible in `ps` for as long as the call takes, on the one command whose
604
+ * entire purpose is to keep a secret.
605
+ */
606
+ async function writeVncPassword(
607
+ file: string,
608
+ password: string,
609
+ user: string,
610
+ ): Promise<void> {
611
+ const result = spawnSync("vncpasswd", ["-f"], { input: password });
612
+ if (result.error) throw result.error;
613
+ if (result.status !== 0)
614
+ throw new Error(
615
+ `vncpasswd exited ${result.status}: ${String(result.stderr).trim()}`,
616
+ );
617
+ const obfuscated = result.stdout;
618
+ if (!obfuscated || obfuscated.length === 0)
619
+ throw new Error("vncpasswd produced an empty password file");
620
+
621
+ await mkdir(path.dirname(file), { recursive: true });
622
+ await writeFile(file, obfuscated, { mode: 0o600 });
623
+ // Written by root, read by the X server running as the login user.
624
+ await run("chown", [`${user}:`, file]).catch(() => {});
625
+ }
626
+
627
+ /**
628
+ * Lets someone pick a window when they did not name one.
629
+ *
630
+ * The reason this exists: on your own laptop you do not have a window id or a
631
+ * ref, you have "the thing I was doing yesterday". `ssh -t box vibe-os attach`
632
+ * with nothing after it is the command worth remembering, and this is what
633
+ * makes it answerable.
634
+ *
635
+ * Returns undefined when the person backs out, which must not be confused with
636
+ * a failure — quitting the picker is a perfectly good outcome.
637
+ */
638
+ async function pickTarget(
639
+ config: Config,
640
+ targets: AttachTarget[],
641
+ ): Promise<AttachTarget | undefined> {
642
+ const live = await liveSessions(config);
643
+ console.log("");
644
+ console.log(
645
+ ` ${color.bold(color.cyan("vibe-os"))} ${color.dim(`· ${targets.length} window${targets.length === 1 ? "" : "s"}`)}`,
646
+ );
647
+ console.log("");
648
+ targets.forEach((t, i) => {
649
+ const n = color.bold(String(i + 1).padStart(3));
650
+ const state = live.has(t.session) ? color.green("live") : color.dim("idle");
651
+ const role = t.role ?? color.dim("terminal");
652
+ console.log(
653
+ ` ${n} ${state} ${t.ref.padEnd(28)} ${role.padEnd(22)} ${color.dim(`${t.project}/${t.workspace}`)}`,
654
+ );
655
+ });
656
+ console.log("");
657
+
658
+ // Closed before anything is spawned: dtach needs the terminal in raw mode and
659
+ // readline holds it in canonical mode until it lets go.
660
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
661
+ const answer = await rl.question(
662
+ ` attach [1-${targets.length}, q to quit]: `,
663
+ );
664
+ rl.close();
665
+
666
+ const choice = Number(answer.trim());
667
+ if (!Number.isInteger(choice) || choice < 1 || choice > targets.length)
668
+ return undefined;
669
+ return targets[choice - 1];
670
+ }
671
+
672
+ /**
673
+ * Attaches this terminal to a window's session.
674
+ *
675
+ * The command comes from session.ts, which is the same place the certificate
676
+ * signer gets it — so arriving over ssh puts you in the same session, in the
677
+ * same worktree, running the same harness as the browser would. Composing a
678
+ * dtach invocation here instead would be one line shorter and would drift away
679
+ * from the browser's the first time either changed.
680
+ */
681
+ async function attach(
682
+ config: Config,
683
+ ref: string | undefined,
684
+ ): Promise<number> {
685
+ if (!config.sessions) {
686
+ log.error(
687
+ "attach needs dtach — this server runs plain login shells (--no-sessions)",
688
+ );
689
+ return 1;
690
+ }
691
+
692
+ const db = openDb(config.stateDir);
693
+ const targets = listTargets(db);
694
+ if (targets.length === 0) {
695
+ log.error(`no windows in ${config.stateDir}`);
696
+ // Almost always the cause: sshd logged you in as someone else, so the
697
+ // state directory resolved to a different home and vibe-os made an empty
698
+ // database there rather than reading the one with the windows in it.
699
+ log.error(
700
+ `open the desktop and make one, or check you are logged in as the user vibe-os runs as`,
701
+ );
702
+ return 1;
703
+ }
704
+
705
+ const target = ref
706
+ ? resolveTarget(db, ref)
707
+ : await pickTarget(config, targets);
708
+ if (ref && !target) {
709
+ log.error(`no window called ${ref}`);
710
+ console.log("");
711
+ for (const t of targets) console.log(` ${t.ref}`);
712
+ console.log("");
713
+ return 1;
714
+ }
715
+ if (!target) return 0;
716
+
717
+ const command = commandFor(db, config, target);
718
+ if (!command) {
719
+ log.error(`could not build a command for ${target.ref}`);
720
+ return 1;
721
+ }
722
+
723
+ /*
724
+ * Run through a shell, because that is what the string is written for.
725
+ *
726
+ * It is the same string sshd hands to `$SHELL -c` as a forced command, `\;`
727
+ * separators and two layers of quoting included. Handing it to a shell here
728
+ * is what makes the two paths identical rather than merely similar.
729
+ *
730
+ * An async spawn, awaited, where this used to be spawnSync — and on Linux
731
+ * that difference is a CPU core. Inside Bun's spawnSync wait the main
732
+ * thread sat in a zero-timeout poll, ~35k epoll_pwait2 a second returning
733
+ * immediately, for the life of the child; once the terminal was gone
734
+ * nothing ended it, and one attach burned a core for eight days (#39).
735
+ * Awaiting an ordinary spawn leaves the event loop in its normal blocking
736
+ * wait. There is still no exec() to replace the process with in a Bun
737
+ * binary, so this stays resident for the session: idle now, not spinning.
738
+ */
739
+ const child = spawn("/bin/sh", ["-c", command], { stdio: "inherit" });
740
+
741
+ /*
742
+ * A client must not survive its terminal (#40, rule 2).
743
+ *
744
+ * When the terminal dies the kernel sends SIGHUP to the foreground process
745
+ * group, which is both of us. Forward it, give dtach a moment to detach
746
+ * cleanly, then leave regardless — whatever the state of the wait above,
747
+ * this caps a lost terminal at seconds rather than days. SIGTERM gets the
748
+ * same treatment so `kill <pid>` takes the pair down, not half of it.
749
+ */
750
+ for (const [signal, code] of [
751
+ ["SIGHUP", 129],
752
+ ["SIGTERM", 143],
753
+ ] as const) {
754
+ process.on(signal, () => {
755
+ child.kill(signal);
756
+ setTimeout(() => process.exit(code), 2_000).unref();
757
+ });
758
+ }
759
+
760
+ return await new Promise<number>((resolve) => {
761
+ child.once("error", (err) => {
762
+ log.error(`could not start the session: ${err.message}`);
763
+ resolve(1);
764
+ });
765
+ child.once("exit", (code) => resolve(code ?? 0));
766
+ });
767
+ }
768
+
769
+ export async function main(argv: string[]): Promise<number> {
770
+ let parsed: ReturnType<typeof parseCliArgs>;
771
+ try {
772
+ parsed = parseCliArgs(argv);
773
+ } catch (err) {
774
+ log.error(describeError(err));
775
+ console.log(HELP);
776
+ return 1;
777
+ }
778
+
779
+ const { values, positionals } = parsed;
780
+ const command = positionals[0] ?? "start";
781
+
782
+ if (values.help) {
783
+ console.log(HELP);
784
+ return 0;
785
+ }
786
+ if (values.version) {
787
+ console.log(version());
788
+ return 0;
789
+ }
790
+
791
+ // Inside the same guard as arg parsing: `--port abc` and `--bogus` are the
792
+ // same class of mistake, and only one of them used to get a readable answer.
793
+ // `resolveConfig` throws for any unparsable numeric flag, and main.ts has no
794
+ // catch, so the other one printed a raw unhandled-rejection stack trace.
795
+ let config: Awaited<ReturnType<typeof resolveConfig>>;
796
+ try {
797
+ config = await resolveConfig(values);
798
+ } catch (err) {
799
+ log.error(describeError(err));
800
+ console.log(HELP);
801
+ return 1;
802
+ }
803
+
804
+ switch (command) {
805
+ case "start": {
806
+ // A generated token is only useful if it survives a restart.
807
+ if (config.token)
808
+ await savePersisted(config.stateDir, { token: config.token });
809
+ // Sweep up what the last life left behind: processes in deleted
810
+ // worktrees, sessions whose socket is gone. Teardown is supposed to make
811
+ // these impossible, and a crash mid-teardown makes them real anyway.
812
+ await reapLeaks(config).catch(() => {});
813
+ await startServer(config);
814
+ return -1; // keep running
815
+ }
816
+ case "attach":
817
+ return attach(config, positionals[1]);
818
+ case "doctor": {
819
+ // Reap first, so the check list below reports the box as it now is.
820
+ if (values.reap) await reapLeaks(config);
821
+ return printChecks(await runDoctor(config));
822
+ }
823
+ case "setup":
824
+ return runSetup(config, values);
825
+ case "confirm-firewall":
826
+ return confirmFirewall(config);
827
+ case "install-service":
828
+ return installService(config, argv);
829
+ case "install-browser":
830
+ return installBrowser(config, values);
831
+ case "connect-hermes":
832
+ return connectHermesCommand(config, values);
833
+ case "fetch-wasm": {
834
+ // A standalone binary carries the wasm inside it: there is no script on
835
+ // disk to run (FETCH_WASM points into the virtual /$bunfs root) and
836
+ // process.execPath is this binary rather than bun, so running it would
837
+ // re-invoke vibe-os with a path as its subcommand, hit the default arm,
838
+ // and surface as an unhandled rejection.
839
+ if (IS_COMPILED) {
840
+ log.info("this build has the SSH runtime embedded — nothing to fetch");
841
+ return 0;
842
+ }
843
+ const { stdout } = await run(process.execPath, [FETCH_WASM], {
844
+ maxBuffer: FETCH_OUTPUT_LIMIT,
845
+ });
846
+ process.stdout.write(stdout);
847
+ return 0;
848
+ }
849
+ case "help":
850
+ console.log(HELP);
851
+ return 0;
852
+ default:
853
+ log.error(`unknown command: ${command}`);
854
+ console.log(HELP);
855
+ return 1;
856
+ }
857
+ }