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