@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/README.md CHANGED
@@ -1,3 +1,929 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="public/icon.svg" width="88" height="88" alt="">
4
+
5
+ # vibe-os
6
+
7
+ **A coding desktop in the browser, on a box you own.**
8
+
9
+ Switch between the git repos on a machine, spin up a worktree per piece of work,
10
+ and open terminals into it, from anything with a browser.
11
+
12
+ [![CI](https://github.com/tsconfigdotjson/vibe-os/actions/workflows/ci.yml/badge.svg)](https://github.com/tsconfigdotjson/vibe-os/actions/workflows/ci.yml)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#license)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ Open the desktop and you get a project picker, a sidebar of workspaces, and
20
+ windows you can drag around a snap grid. Every window is a
21
+ [dtach](https://github.com/crigler/dtach) session in its workspace's worktree,
22
+ so closing the tab and coming back tomorrow finds whatever was running still
23
+ running.
24
+
25
+ Down the right is a rail of **profiles**, the roles you work as. *QA Engineer*,
26
+ *Backend Manager*, whatever your work divides into. Each carries a colour, a
27
+ harness with its settings, and a standing prompt. Click one and you get a window
28
+ tinted in its colour with Claude already running in the worktree.
29
+
30
+ There is nothing to configure. No key to copy and paste, no `authorized_keys` to
31
+ edit, no `sshd_config` change, and no root.
32
+
33
+ ## Contents
34
+
35
+ - [Quick start](#quick-start)
36
+ - [How it works](#how-it-works)
37
+ - [Using it](#using-it)
38
+ - [Projects and workspaces](#projects-and-workspaces)
39
+ - [Profiles](#profiles)
40
+ - [Hermes](#hermes)
41
+ - [Hermes and the browser](#hermes-and-the-browser)
42
+ - [Cursor](#cursor)
43
+ - [Popping a terminal out](#popping-a-terminal-out)
44
+ - [The desktop](#the-desktop)
45
+ - [Installing it as an app](#installing-it-as-an-app)
46
+ - [Deploying on a VPS](#deploying-on-a-vps)
47
+ - [What the box needs](#what-the-box-needs)
48
+ - [Install](#install)
49
+ - [Behind Tailscale](#behind-tailscale)
50
+ - [Firewall](#firewall)
51
+ - [Running it as a service](#running-it-as-a-service)
52
+ - [An always-on browser](#an-always-on-browser)
53
+ - [Giving the box a GitHub identity](#giving-the-box-a-github-identity)
54
+ - [Updating a running box](#updating-a-running-box)
55
+ - [Security](#security)
56
+ - [CLI reference](#cli-reference)
57
+ - [Development](#development)
58
+ - [Project status](#project-status)
59
+ - [Contributing](#contributing)
60
+ - [License](#license)
61
+
62
+ ---
63
+
64
+ ## Quick start
65
+
66
+ ### Try it locally
67
+
68
+ ```bash
69
+ docker compose up --build
70
+ open http://localhost:8080
71
+ ```
72
+
73
+ A blank Debian box with sshd, dtach and git, which is the same shape as a fresh
74
+ VPS. The compose file turns the token off so the URL above just works, and
75
+ publishes the port on loopback only, because a vibe-os with no token is a shell
76
+ for anyone who can reach it.
77
+
78
+ ### Or point an agent at a VPS
79
+
80
+ You have an agent. Give it SSH access to a fresh box and this:
81
+
82
+ > Set up vibe-os (https://github.com/tsconfigdotjson/vibe-os) on this VPS.
83
+ >
84
+ > 1. Create a non-root user with sudo if I am logged in as root, and do the rest
85
+ > as that user. vibe-os hands out shells as whoever runs it.
86
+ > 2. Install the prerequisites: `openssh-server`, `openssh-client`, `dtach`,
87
+ > `git`, and `gh`. Install Claude Code with the standalone installer from
88
+ > https://claude.ai/install.sh, not npm.
89
+ > 3. Install Tailscale, run `tailscale up`, and tell me the tailnet address.
90
+ > 4. Build the binary. If you have Bun locally, `bun run compile` and copy
91
+ > `dist/bin/vibe-os-linux-x64` to `/usr/local/bin/vibe-os`. Otherwise clone
92
+ > the repo on the box, install Bun, and run `bun install && bun run build`.
93
+ > 5. Run `vibe-os doctor` and then `sudo vibe-os doctor`. Fix what they report
94
+ > before continuing. Do not skip this.
95
+ > 6. Install the service: `sudo vibe-os install-service --port 7681 --host
96
+ > 127.0.0.1 --token`. Run that with sudo from the user account, not as root.
97
+ > 7. Put HTTPS in front: `sudo tailscale serve --bg 7681`.
98
+ > 8. Close the box down with ufw: allow in on `tailscale0`, allow `41641/udp`,
99
+ > default deny incoming, then enable. Confirm you can still reach the box
100
+ > over the tailnet in a second session before enabling it.
101
+ > 9. Turn off SSH password authentication by writing
102
+ > `PasswordAuthentication no` to `/etc/ssh/sshd_config.d/01-hardening.conf`.
103
+ > That file has to sort before `50-cloud-init.conf`, which sets it to yes.
104
+ > Run `sshd -t` before reloading.
105
+ > 10. Run `sudo vibe-os doctor` one more time and paste the output, along with
106
+ > the URL and token from `journalctl -u vibe-os`.
107
+ >
108
+ > Tell me what you are about to run before you run anything that opens a port
109
+ > or changes sshd.
110
+
111
+ Read what it proposes before you let it run. Step 8 can lock you out of the box
112
+ if the tailnet is not actually working yet.
113
+
114
+ ---
115
+
116
+ ## How it works
117
+
118
+ ```
119
+ ┌─────────────────┐ ┌──────────────────┐ ┌──────┐
120
+ │ browser │ wss://…/websocket │ vibe-os (bun) │ TCP │ sshd │
121
+ │ ssh.wasm │ ─────────────────────► │ :80 / :443 │ ─────► │ :22 │
122
+ │ private key │ │ byte pipe │ │ │
123
+ │ in IndexedDB │ POST /api/ssh/… │ + SSH CA │ │dtach │
124
+ └─────────────────┘ ─────────────────────► └──────────────────┘ └───▲──┘
125
+ │
126
+ ┌─────────────────┐ │
127
+ │ your terminal │ ssh -t … vibe-os attach quiet-amber-otter-1 │
128
+ │ ssh + dtach │ ──────────────────────────────────────────────────────►─┘
129
+ └─────────────────┘
130
+ ```
131
+
132
+ **The SSH protocol runs inside the browser.** Key exchange, authentication and
133
+ channel multiplexing happen in a Go/WASM sandbox in the tab. The server is a
134
+ byte pipe: it never sees plaintext and holds no credentials for your session.
135
+
136
+ **vibe-os is its own short-lived certificate authority.** On first start it
137
+ generates an ed25519 CA and appends one `cert-authority` line to
138
+ `~/.ssh/authorized_keys`. Each window generates a keypair in the browser, keeps
139
+ the private half in IndexedDB, and posts only the public half to be signed.
140
+ Certificates last 12 hours. Revoking every browser that ever connected is
141
+ deleting one line.
142
+
143
+ **Windows survive because dtach owns them, not vibe-os.** Each certificate
144
+ carries a `force-command` that attaches to the window's dtach socket, creating
145
+ it in the workspace's worktree if it is not there yet. Restarting the server,
146
+ reloading the page and closing the laptop all leave the session running.
147
+
148
+ The bottom route in that diagram is the same session by a different door: your
149
+ own ssh client, your own key, straight to sshd. See
150
+ [popping out](#popping-a-terminal-out).
151
+
152
+ ---
153
+
154
+ ## Using it
155
+
156
+ ### Projects and workspaces
157
+
158
+ The picker in the top-left switches between git repositories on the machine.
159
+ **Refresh** walks the disk for them, bounded by depth and a visit budget, and
160
+ only on that button.
161
+
162
+ Each project has **workspaces** in the sidebar. A workspace is a git worktree on
163
+ its own branch, named with three random words. Creating one fetches `origin` and
164
+ branches from its default branch, so it starts level with `origin/main` instead
165
+ of with whatever the project's own checkout was left on. That fetch is what the
166
+ spinner is waiting for. With no `origin`, or none that can be reached, the
167
+ workspace is still created from the last fetch or from the checkout, and the
168
+ sidebar says which. Terminals belong to a workspace:
169
+ switching swaps which windows are on screen and restores their placement, while
170
+ the sessions you left keep running. Only ✕ ends anything.
171
+
172
+ `git push` works with no arguments from a new workspace, because creating one
173
+ sets `push.autoSetupRemote` on the project. Nothing is pushed for you.
174
+
175
+ Every window in a workspace shares one checkout, one branch and one git index.
176
+ That is the point, but it means two agents running `git commit` at the same
177
+ moment will collide on `index.lock`. Nothing is corrupted, one of them retries.
178
+ If two pieces of work need to proceed independently, give them a workspace each.
179
+
180
+ ### Profiles
181
+
182
+ A profile is a name, a colour, a harness (`claude`, `hermes`, `cursor`, a
183
+ shell, or any command on the box) with its flags, and a standing prompt. Profiles belong to a
184
+ project and appear in every workspace of it.
185
+
186
+ The colour is the point: it tints the window, its title bar and its dock entry,
187
+ so three roles running at once are distinguishable without reading anything.
188
+
189
+ - **Quitting the harness closes the window.** A role window exists to run that
190
+ role. ⟳ opens it again.
191
+ - **Clicking a role that is already open raises it.** Hover a live row and press
192
+ **+** to open a second one.
193
+ - **Flags are stored as a list of arguments**, quoted individually when the
194
+ command is built. The browser never sends a command at all.
195
+
196
+ The editor gives you a model dropdown, a thinking dropdown (`--effort`), a
197
+ permission dropdown and a few switches, with the generated flags and a preview
198
+ behind **Advanced**. Models and permission modes are read off the Claude binary
199
+ installed on the server, so the list follows Claude's releases.
200
+
201
+ #### Hermes
202
+
203
+ Picking the Hermes harness runs `hermes chat` in the worktree, and the editor
204
+ offers a model, a provider, a thinking dropdown (`--reasoning`), an interface
205
+ (`--cli` or `--tui`) and the same kind of switches, `--yolo` among them.
206
+
207
+ Models are typed rather than picked. `hermes model` is an interactive wizard
208
+ with no listing mode, so there is nothing to read off the box. The provider list
209
+ is real: it is whatever `~/.hermes/config.yaml` has been set up with, so adding
210
+ one with `hermes model` makes it appear here. Reasoning levels are read off
211
+ `hermes chat --help`, so that ladder follows Hermes' releases.
212
+
213
+ #### Hermes and the browser
214
+
215
+ Hermes' Browser Use mode replaces its twelve browser tools with one that drives
216
+ a browser over CDP. Pointed at the always-on browser below, that is the Chrome
217
+ you can watch over VNC.
218
+
219
+ ```bash
220
+ vibe-os connect-hermes
221
+ ```
222
+
223
+ That sets `browser.cdp_url`, `browser.backend` and `browser.cloud_provider` in
224
+ `~/.hermes/config.yaml`. `install-browser` does it as its last step; run it by
225
+ hand when Hermes arrives after the browser did. It is a box-wide setting, not a
226
+ per-profile one: Hermes reads the CDP target from its config file and has no
227
+ flag for it.
228
+
229
+ The agent shares that browser with whatever is signed in to it, including the
230
+ Claude extension. That is the point (it inherits your sessions) and it is the
231
+ cost (it acts inside your accounts).
232
+
233
+ Browser Use mode needs the `browser-use` CLI, or `uvx` to run it on demand.
234
+ Without either, Hermes keeps its twelve built-in browser tools and says nothing.
235
+ `vibe-os doctor` is where that shows up.
236
+
237
+ #### Cursor
238
+
239
+ Picking the Cursor harness runs `cursor-agent` in the worktree, and the editor
240
+ offers a model and a handful of switches. Two of them matter most: `--force`,
241
+ which is Cursor's spelling of skip-every-check, and `--trust`, which matters
242
+ here more than anywhere else: every workspace is a freshly cut worktree, which
243
+ to Cursor is a directory it has never seen, so without it every new window opens
244
+ on the trust prompt instead of the conversation.
245
+
246
+ Models are typed with real suggestions behind them. `cursor-agent models` is a
247
+ proper listing mode — the only harness with one — but the list belongs to the
248
+ account rather than the binary: it needs a login, and sometimes the network, so
249
+ when it cannot be read the field is plain free text and typed ids still work.
250
+
251
+ Logging in is a property of the box, not of a profile: run `cursor-agent
252
+ login` once on the box. Logged out, a Cursor window opens and sits at the
253
+ login prompt — `vibe-os doctor` says so before you find out that way.
254
+
255
+ MCP servers are a property of the box as well. Configure them in
256
+ `~/.cursor/mcp.json`, then log in once, from inside any window:
257
+
258
+ ```sh
259
+ cursor-agent mcp login linear
260
+ ```
261
+
262
+ Cursor files MCP credentials per directory, which would mean one login per
263
+ workspace. vibe-os points every workspace at a single store under the state dir
264
+ instead, so that one login covers the ones opened later too. A switch,
265
+ `--approve-mcps`, skips the approval prompt each new workspace would otherwise
266
+ ask for.
267
+
268
+ #### MCP servers
269
+
270
+ For Claude profiles. Cursor reads its own list from `~/.cursor/mcp.json`, see
271
+ [Cursor](#cursor) above.
272
+
273
+ Three settings rather than a list of switches, because that is what Claude
274
+ accepts: everything the box has, nothing, or exactly what you pick.
275
+
276
+ The list is read off the box. Add one and it appears:
277
+
278
+ ```sh
279
+ claude mcp add --scope user linear --transport sse https://mcp.linear.app/sse
280
+ ```
281
+
282
+ Use `--scope user`. Without it Claude files the server under whichever directory
283
+ you ran the command in, and if that was a workspace, the server goes when the
284
+ worktree does.
285
+
286
+ Picking servers writes one file per server under `~/.vibe-os/mcp/` at 0600, and
287
+ the profile stores the path rather than the definition, which can contain an API
288
+ key. The files are rewritten from `~/.claude.json` whenever a window starts, so
289
+ `claude mcp add` stays the only place a server is configured.
290
+
291
+ Credentials follow the server name, so a server you have already logged into
292
+ keeps working. `--strict-mcp-config` also drops plugin-provided servers, but not
293
+ claude.ai connectors like Gmail or Drive, which are attached to your account.
294
+
295
+ #### Blanks
296
+
297
+ A prompt can leave gaps:
298
+
299
+ ```
300
+ Read {{which files}} on branch {{branch}} and report back.
301
+ ```
302
+
303
+ Each `{{…}}` becomes a field in the band above the terminal, drawn inline in the
304
+ sentence. Select a word in the editor and press **+ blank** to make one.
305
+
306
+ Blanks are keyed by position, so two `{{file}}` stay two separate fields.
307
+ Pressing Copy or Send with blanks empty refuses once, then goes anyway, and an
308
+ unfilled blank falls back to its own label.
309
+
310
+ **Copy** puts the prompt on the clipboard. **Send** writes it into the session
311
+ with bracketed paste, so a multi-line prompt arrives in Claude's composer as one
312
+ block rather than submitting on the first newline. Send always works;
313
+ `navigator.clipboard` does not exist on a page served over plain HTTP to an IP.
314
+
315
+ ### Popping a terminal out
316
+
317
+ The ⇗ button sends a terminal to its own browser window, or to a real terminal
318
+ on your machine:
319
+
320
+ ```bash
321
+ ssh -t vibe@vibe-os.your-tailnet.ts.net vibe-os attach quiet-amber-otter-1
322
+ ```
323
+
324
+ Every route addresses the same window id and the same dtach session, so popping
325
+ out is detaching one client and attaching another. **Bring it back** returns it
326
+ with scrollback intact, and the desktop reclaims a window on its own if the
327
+ terminal goes away.
328
+
329
+ `vibe-os attach` with no argument gives you a picker of every window on the box:
330
+
331
+ ```
332
+ vibe-os · 3 windows
333
+
334
+ 1 live quiet-amber-otter-1 QA Engineer vibe-os/quiet-amber-otter
335
+ 2 idle quiet-amber-otter-2 terminal vibe-os/quiet-amber-otter
336
+ 3 live brave-copper-lynx-1 Backend Manager vibe-os/brave-copper-lynx
337
+
338
+ attach [1-3, q to quit]:
339
+ ```
340
+
341
+ **This grants no access.** That ssh connection authenticates with your own key,
342
+ or your tailnet identity under `tailscale up --ssh`. vibe-os is not in the auth
343
+ path and `--token` does not gate it. Anyone who can ssh to the box could already
344
+ type `dtach -a`.
345
+
346
+ ### The desktop
347
+
348
+ Windows snap to a 24 × 14 grid. Drag a title bar to move, any edge to resize,
349
+ double-click to fill the desktop. **⊞** on the dock tiles up to four windows.
350
+
351
+ There are no keyboard chords. The terminal has focus nearly all the time, and
352
+ every key the desktop took would be a key the session could not have.
353
+
354
+ The dock and the profile rail hide themselves. Push the pointer into the bottom
355
+ or right edge to bring them back.
356
+
357
+ Closing a window ends its session. **Minimise** puts it away and keeps it
358
+ running.
359
+
360
+ The dock's **◑** opens the wallpaper picker. Uploads are stored on the server,
361
+ so the same desktop appears on every device. **Dim** darkens the wallpaper
362
+ behind the windows.
363
+
364
+ ### Installing it as an app
365
+
366
+ vibe-os ships a web app manifest, so Chrome, Edge and Brave offer to install it
367
+ and it runs in its own window. On iOS, **Share → Add to Home Screen**.
368
+
369
+ It needs a secure origin: `localhost`, or HTTPS by way of
370
+ [`tailscale serve`](#behind-tailscale) or `--domain`.
371
+
372
+ Give each box its own colour so you can tell installed instances apart:
373
+
374
+ ```bash
375
+ vibe-os start --theme-color '#7a4fd6'
376
+ ```
377
+
378
+ The service worker caches nothing. It exists only because Chrome will not offer
379
+ installation without one.
380
+
381
+ ---
382
+
383
+ ## Deploying on a VPS
384
+
385
+ ### What the box needs
386
+
387
+ | Package | What uses it | Without it |
388
+ | --- | --- | --- |
389
+ | `openssh-server` | every window logs in through it | nothing connects |
390
+ | `openssh-client` | signing certificates, finding the host key | **the server refuses to start** |
391
+ | `dtach` | keeps windows alive across reloads | windows die on reload, and **profiles launch no harness** |
392
+ | `git` | projects and worktrees | no projects |
393
+ | `gh` | PRs, issues and reviews (optional) | git works, the GitHub API does not |
394
+ | `claude` | the Claude harness | those profiles open a window that closes immediately |
395
+ | `hermes` | the Hermes harness (optional) | those profiles open a window that closes immediately |
396
+ | `cursor-agent` | the Cursor harness (optional) | those profiles open a window that closes immediately |
397
+ | `uv` | `uvx browser-use`, for Hermes' Browser Use mode (optional) | Hermes keeps its twelve built-in browser tools |
398
+
399
+ ```bash
400
+ sudo apt update && sudo apt install -y openssh-server openssh-client dtach git gh
401
+ curl -fsSL https://claude.ai/install.sh | bash # standalone, needs no Node
402
+
403
+ # Optional, for the Hermes harness
404
+ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
405
+ curl -LsSf https://astral.sh/uv/install.sh | sh
406
+ hermes setup # pick a provider
407
+
408
+ # Optional, for the Cursor harness
409
+ curl https://cursor.com/install -fsS | bash
410
+ cursor-agent login # one login per box
411
+ ```
412
+
413
+ Verified on Debian 12 and 13, Ubuntu 24.04 and 26.04 LTS, x86\_64.
414
+
415
+ `dtach` is the one people skip. The harness command lives in the dtach
416
+ invocation, so without it a profile opens a shell and does nothing else.
417
+
418
+ ### Install
419
+
420
+ ```bash
421
+ bun run compile # writes dist/bin/vibe-os-linux-{x64,arm64}
422
+ scp dist/bin/vibe-os-linux-x64 you@host:/usr/local/bin/vibe-os
423
+ ssh you@host 'chmod +x /usr/local/bin/vibe-os'
424
+ ```
425
+
426
+ The binary carries the whole app, including the 20MB SSH WASM runtime. No Bun,
427
+ Node or npm on the target.
428
+
429
+ On a box that already has Bun, the npm package is the same thing without the
430
+ build step:
431
+
432
+ ```bash
433
+ bun add -g @tsconfigdotjson/vibe-os # installs the vibe-os command
434
+ ```
435
+
436
+ Then check it, twice. `vibe-os doctor` answers whether a certificate this host
437
+ signs will actually be accepted, which catches the failures that otherwise
438
+ appear in a browser as `handshake failed` and nowhere else. Reading sshd's
439
+ effective config and the firewall needs root:
440
+
441
+ ```bash
442
+ vibe-os doctor # everything that does not need privileges
443
+ sudo vibe-os doctor # adds the sshd and firewall checks
444
+ ```
445
+
446
+ `doctor` evaluates the flags it is given and cannot see a systemd unit, so pass
447
+ the same flags the service uses to get a true answer.
448
+
449
+ ### Behind Tailscale
450
+
451
+ The recommended setup. You stop needing to bind port 80, and you stop needing
452
+ Let's Encrypt.
453
+
454
+ ```bash
455
+ curl -fsSL https://tailscale.com/install.sh | sh
456
+ sudo tailscale up
457
+
458
+ # Loopback, not the tailnet address: serve proxies to 127.0.0.1, and binding
459
+ # the tailnet address gives a 502 because serve cannot reach it.
460
+ vibe-os start --port 7681 --host 127.0.0.1 --token
461
+ sudo tailscale serve --bg 7681
462
+ ```
463
+
464
+ That serves it at `https://<machine>.<tailnet>.ts.net` with a real certificate,
465
+ which is also what the browser needs before it will expose the clipboard or
466
+ offer to install the app.
467
+
468
+ ### Firewall
469
+
470
+ Tailscale does not close ports for you. Until you do, the machine still answers
471
+ on its public address, and vibe-os hands out shells.
472
+
473
+ **Confirm you can reach the box over the tailnet in a second terminal before
474
+ denying anything**, and know where your provider's rescue console is.
475
+
476
+ ```bash
477
+ sudo ufw allow in on tailscale0
478
+ sudo ufw allow 41641/udp # keeps Tailscale on a direct path
479
+ sudo ufw default deny incoming
480
+ sudo ufw default allow outgoing
481
+ sudo ufw enable
482
+ ```
483
+
484
+ Verified from outside the tailnet against a box running this recipe: ports 22,
485
+ 80, 443, 7681 and 8080 all time out, while the tailnet address answers.
486
+
487
+ `sudo vibe-os doctor` checks these. It reads ufw, falling back to nftables and
488
+ iptables, and it reads configuration rather than reachability. It cannot see
489
+ your provider's firewall, which is a separate thing worth closing too. OVH's is
490
+ stateless, so leave `41641/udp` open there or Tailscale relays everything.
491
+
492
+ Cloud images often ship `PasswordAuthentication yes` in
493
+ `/etc/ssh/sshd_config.d/50-cloud-init.conf`. sshd takes the first occurrence of
494
+ a keyword, so overriding it needs a file that sorts *earlier*:
495
+
496
+ ```bash
497
+ printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' \
498
+ | sudo tee /etc/ssh/sshd_config.d/01-hardening.conf
499
+ sudo sshd -t && sudo systemctl reload ssh
500
+ ```
501
+
502
+ Confirm a fresh key-based login works before closing the session you have.
503
+
504
+ ### Running it as a service
505
+
506
+ ```bash
507
+ sudo vibe-os install-service --port 7681 --token hunter2
508
+ journalctl -u vibe-os -f
509
+ ```
510
+
511
+ Run it with `sudo` from your own account, not as root. It takes the user from
512
+ `SUDO_USER` and writes a unit that runs as them, forwarding whatever flags you
513
+ passed. It refuses to write a unit that runs as root.
514
+
515
+ If you are binding port 80 rather than using Tailscale, that needs
516
+ `CAP_NET_BIND_SERVICE`, which `install-service` grants. By hand:
517
+
518
+ ```bash
519
+ sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/vibe-os
520
+ ```
521
+
522
+ Otherwise vibe-os falls back to port 8080 and says so.
523
+
524
+ ### An always-on browser
525
+
526
+ One Google Chrome, on a virtual display, running for as long as the box does.
527
+ It is where a browser extension lives on a machine with no screen. Optional, and
528
+ separate from `install-service`.
529
+
530
+ x86\_64 only. Google publishes no arm64 Chrome for Linux, and Chromium is not a
531
+ substitute if you want the Claude in Chrome extension.
532
+
533
+ ```bash
534
+ sudo apt install -y tigervnc-standalone-server openbox x11-utils
535
+
536
+ curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
537
+ | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg
538
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] \
539
+ https://dl.google.com/linux/chrome/deb/ stable main" \
540
+ | sudo tee /etc/apt/sources.list.d/google-chrome.list
541
+ sudo apt update && sudo apt install -y google-chrome-stable
542
+
543
+ sudo vibe-os install-browser
544
+ ```
545
+
546
+ That writes seven units: an X server with VNC built in, a window manager, Chrome
547
+ itself, a liveness probe on a timer, and a nightly restart on a timer. All of
548
+ them restart on failure.
549
+
550
+ | Flag | Default |
551
+ | --- | --- |
552
+ | `--geometry <WxH>` | `1600x900` |
553
+ | `--display <n>` | `99` |
554
+ | `--vnc-port <n>` | `5900`, loopback only |
555
+ | `--cdp-port <n>` | `9222`, loopback only |
556
+ | `--restart-at <expr>` | `*-*-* 02:00:00`, in the box's timezone |
557
+ | `--no-restart` | install no nightly timer |
558
+ | `--vnc-password [value]` | none, and an existing one is kept |
559
+ | `--no-vnc-password` | serve the display with no authentication |
560
+
561
+ The default names the box's own timezone, which on most VPS images is UTC, and
562
+ the install summary prints it. Pass your own zone if your night is elsewhere, as
563
+ in `*-*-* 02:00:00 America/New_York`.
564
+
565
+ Give it two gigabytes of headroom counting swap. Chrome idles near 0.5GB, and on
566
+ a box without it the OOM killer takes terminal sessions rather than tabs.
567
+
568
+ ```bash
569
+ sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
570
+ sudo mkswap /swapfile && sudo swapon /swapfile
571
+ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
572
+ ```
573
+
574
+ #### Using it
575
+
576
+ The display has no VNC password. It listens on loopback, so reach it through
577
+ SSH, and `sudo vibe-os doctor` fails loudly if it is ever bound anywhere else.
578
+
579
+ ```bash
580
+ ssh -L 5900:127.0.0.1:5900 you@host
581
+ ```
582
+
583
+ Point any VNC viewer at `127.0.0.1:5900`. TigerVNC Viewer, RealVNC Viewer and
584
+ Remmina all connect straight through.
585
+
586
+ macOS Screen Sharing will not take a server with no authentication: it asks for
587
+ a password that does not exist. Give it one, keeping the loopback bind as the
588
+ layer that actually protects the box:
589
+
590
+ ```bash
591
+ sudo vibe-os install-browser --vnc-password # generates and prints one
592
+ sudo vibe-os install-browser --vnc-password hunter2
593
+ ```
594
+
595
+ Then open `vnc://127.0.0.1:5900` and leave the username blank. VNC
596
+ authentication is password-only.
597
+
598
+ Later runs of `install-browser` keep a password that is already set, so a
599
+ reinstall does not quietly drop you back to no authentication. `--no-vnc-password`
600
+ turns it off on purpose.
601
+
602
+ VNC authentication is DES-based and truncates at 8 characters, so treat it as a
603
+ second layer and not as the thing keeping the box shut. Prefer the bare
604
+ `--vnc-password`: a password passed as an argument is visible in shell history
605
+ and, briefly, in `ps`.
606
+
607
+ ```bash
608
+ systemctl restart vibe-os-chrome # bounce it now
609
+ journalctl -u vibe-os-chrome -f # what it is saying
610
+ sudo systemctl stop vibe-os-chrome vibe-os-wm vibe-os-xvnc # stop all of it
611
+ ```
612
+
613
+ Open a URL in the running instance from a terminal on the box:
614
+
615
+ ```bash
616
+ DISPLAY=:99 google-chrome https://example.com
617
+ ```
618
+
619
+ The nightly restart closes leftover tabs and returns the memory a day of
620
+ browsing took. The profile lives in `~/.vibe-os/chrome` and carries sign-ins
621
+ across a restart, so this does not log you out.
622
+
623
+ #### Extensions
624
+
625
+ Install by hand through the viewer, or force-install without any interaction:
626
+
627
+ ```bash
628
+ sudo mkdir -p /etc/opt/chrome/policies/managed
629
+ sudo tee /etc/opt/chrome/policies/managed/vibe-os.json <<'JSON'
630
+ {
631
+ "ExtensionInstallForcelist": [
632
+ "fcoeoabgfenejglbffodgkkbkcdhcgfn;https://clients2.google.com/service/update2/crx"
633
+ ]
634
+ }
635
+ JSON
636
+ sudo systemctl restart vibe-os-chrome
637
+ ```
638
+
639
+ That ID is Claude in Chrome. Take any other from its Chrome Web Store URL.
640
+
641
+ Signing in is interactive, including a captcha, so do it once through the
642
+ viewer. Claude in Chrome needs a paid plan.
643
+
644
+ Anyone who reaches this browser gets whatever it is signed in to. That is the
645
+ same token and the same tailnet that already hand out shells, but it is a wider
646
+ blast radius than a shell alone.
647
+
648
+ ### Giving the box a GitHub identity
649
+
650
+ Workspaces are branches, and pushing happens in the terminal, so a box with no
651
+ GitHub credentials dead-ends at the first `git push`. Do this as the login user,
652
+ not root:
653
+
654
+ ```bash
655
+ ssh-keygen -t ed25519 -C "vibe-os@$(hostname)" -f ~/.ssh/id_ed25519 -N ""
656
+ cat ~/.ssh/id_ed25519.pub
657
+ ```
658
+
659
+ No passphrase: an agent-less prompt would appear inside whichever window ran
660
+ `git push`, where nothing can answer it.
661
+
662
+ Add that key to [github.com/settings/keys](https://github.com/settings/keys)
663
+ twice, as an **Authentication key** and as a **Signing key** if you want commits
664
+ made here to show as verified:
665
+
666
+ ```bash
667
+ git config --global gpg.format ssh
668
+ git config --global user.signingkey ~/.ssh/id_ed25519.pub
669
+ git config --global commit.gpgsign true
670
+ git config --global user.name "Your Name"
671
+ git config --global user.email "you@example.com" # a verified GitHub email
672
+ ```
673
+
674
+ The key covers git. It does not cover the GitHub API, so PRs and issues need
675
+ `gh auth login` (choose SSH, which keeps the token confined to API calls).
676
+
677
+ A push-capable key here means anyone who gets a shell can push to your
678
+ repositories. Use a per-repository deploy key instead if that matters, and
679
+ accept that `gh` will not work.
680
+
681
+ ### Updating a running box
682
+
683
+ ```bash
684
+ bun run build && bun scripts/compile.ts linux-x64
685
+ scp dist/bin/vibe-os-linux-x64 you@host:/tmp/vibe-os
686
+ ssh you@host 'sudo systemctl stop vibe-os \
687
+ && sudo mv /tmp/vibe-os /usr/local/bin/vibe-os \
688
+ && sudo chmod +x /usr/local/bin/vibe-os \
689
+ && sudo systemctl start vibe-os'
690
+ ```
691
+
692
+ Nothing is lost by that restart. Two things it does not pick up:
693
+
694
+ - **A changed harness command.** An existing session keeps running whatever it
695
+ was started with. Close the window and open it again.
696
+ - **Changed service flags.** Those live in the unit, so re-run
697
+ `install-service`.
698
+
699
+ A reboot is different: dtach sessions do not survive one.
700
+
701
+ ---
702
+
703
+ ## Security
704
+
705
+ **vibe-os hands out shell access to the machine it runs on.** Every window is
706
+ the same unix user, there is no per-user isolation, and anyone who can reach the
707
+ port and pass the gate can run anything that user can. Treat access to vibe-os
708
+ as equivalent to SSH access to the box.
709
+
710
+ Report vulnerabilities privately, as described in [SECURITY.md](SECURITY.md).
711
+
712
+ The recommended shape is all of it: behind Tailscale, firewalled to the tailnet,
713
+ bound to the tailnet address, and with the token on.
714
+
715
+ **The gate is on by default.** The first start generates a token, remembers it
716
+ in the state dir, and prints the URL with it.
717
+
718
+ ```bash
719
+ vibe-os # the remembered token, or a new one
720
+ vibe-os --token hunter2 # or pick your own
721
+ vibe-os --no-token # no gate, with a warning at startup
722
+ ```
723
+
724
+ It gates the app, the API, the certificate signer, the wallpaper upload and the
725
+ WebSocket bridge.
726
+
727
+ Two checks run in front of it:
728
+
729
+ - **Origin, always.** A request carrying an `Origin` that is not this server is
730
+ refused, so a page on another site cannot drive the API or open the bridge
731
+ through your browser.
732
+ - **Host, with no token.** A request has to name the server by an address,
733
+ `localhost`, the machine's hostname, `--domain`, `--ssh-advertise` or its
734
+ Tailscale name. That stops DNS rebinding, where a page points its own domain
735
+ at the box to become same-origin with it. Add any other name with
736
+ `--allowed-host`. With a token this check is off, since a rebound page has
737
+ neither the cookie nor the token.
738
+
739
+ Open the URL with `?token=…` once and the server sets an `HttpOnly` cookie, then
740
+ redirects without the token so it does not linger in history. Scripts can send
741
+ `Authorization: Bearer <token>`. Comparison is constant-time.
742
+
743
+ The cookie value is the token rather than a derived session id, so there is no
744
+ per-browser session to revoke. Rotating means changing it on the server.
745
+
746
+ `--domain` provisions a Let's Encrypt certificate over HTTP-01 and serves HTTPS,
747
+ renewing 30 days before expiry. On plain HTTP the browser clipboard API is
748
+ unavailable, so copy-on-select and paste stop working and the app cannot be
749
+ installed. OSC 52 clipboard *reads* are always refused, since anything running
750
+ in a session could otherwise ask what you last copied.
751
+
752
+ ---
753
+
754
+ ## CLI reference
755
+
756
+ | command | what it does |
757
+ | --- | --- |
758
+ | `vibe-os` / `vibe-os start` | serve the UI and the SSH bridge |
759
+ | `vibe-os attach [window]` | attach this terminal to a window's session |
760
+ | `vibe-os doctor` | check this machine is ready, and say what is missing |
761
+ | `sudo vibe-os install-service` | write and enable a systemd unit |
762
+ | `sudo vibe-os install-browser` | run one Chrome on a virtual display |
763
+ | `vibe-os connect-hermes` | point Hermes' browser tools at that Chrome |
764
+ | `vibe-os fetch-wasm` | re-download the SSH WASM runtime |
765
+
766
+ ```
767
+ --port <n> HTTP port (default 80)
768
+ --host <addr> bind address (default 0.0.0.0)
769
+ --domain <fqdn> provision a Let's Encrypt certificate and serve HTTPS
770
+ --email <addr> contact address for Let's Encrypt
771
+ --tls-port <n> HTTPS port (default 443)
772
+
773
+ --token [value] the token to require (default: the last one used, or
774
+ a new one, remembered in the state dir)
775
+ --no-token disable the gate (prints a warning)
776
+ --allowed-host <name>
777
+ with no token, a name this server answers to beyond
778
+ its hostname, --domain and its Tailscale name.
779
+ Repeatable
780
+
781
+ --ssh-host <addr> SSH target for the bridge (default 127.0.0.1)
782
+ --ssh-port <n> SSH target port (default 22)
783
+ --ssh-advertise <host[:port]>
784
+ host to print in attach commands, when sshd is not on
785
+ the name the browser reached the desktop on
786
+ --user <name> unix user to log in as (default: current user)
787
+ --no-sessions plain login shells instead of persistent dtach sessions
788
+ --cert-ttl <secs> certificate lifetime (default 43200)
789
+
790
+ --theme-color <hex> window chrome colour for the installed app (default #1c2128)
791
+ --workspace <dir> where worktrees are created (default ~/workspace)
792
+ --state-dir <dir> CA, TLS material and generated MCP configs
793
+ (default ~/.vibe-os)
794
+ ```
795
+
796
+ `install-browser` takes its own flags:
797
+
798
+ ```
799
+ --geometry <WxH> virtual screen size (default 1600x900)
800
+ --display <n> X display number (default 99)
801
+ --vnc-port <n> VNC port, bound to loopback (default 5900)
802
+ --cdp-port <n> Chrome debug port, bound to loopback (default 9222)
803
+ --restart-at <expr> nightly restart, a systemd OnCalendar expression
804
+ (default '*-*-* 02:00:00', the box's own timezone)
805
+ --no-restart do not install the nightly restart timer
806
+ --vnc-password [value]
807
+ require a VNC password; generates and prints one if
808
+ omitted. macOS Screen Sharing will not connect without
809
+ this. An existing one is kept unless --no-vnc-password
810
+ --no-vnc-password serve the display with no authentication
811
+ ```
812
+
813
+ `connect-hermes` takes one:
814
+
815
+ ```
816
+ --cdp-port <n> the debug port to point Hermes at, when there is no
817
+ installed browser unit to read it from
818
+ ```
819
+
820
+ Every option except `--tls-port` and `--cert-ttl` also reads a `VIBE_OS_`
821
+ environment variable, which is how the container is configured. `--no-token` is
822
+ `VIBE_OS_NO_TOKEN=1`, and `--allowed-host` is `VIBE_OS_ALLOWED_HOSTS`, comma
823
+ separated.
824
+
825
+ ---
826
+
827
+ ## Development
828
+
829
+ ```bash
830
+ bun install # also fetches ssh.wasm from the upstream release
831
+ bun run build # web app + precompressed assets + embedded manifest
832
+ bun run dev # Vite on :5173, proxying to a vibe-os on :7681
833
+ bun run dev:server # the server with --hot, on :7681
834
+ bun run compile # standalone binaries into dist/bin
835
+
836
+ bun run check # lint + typecheck + tests, the same three CI runs
837
+ bun test # specs in test/ and beside the code
838
+ ```
839
+
840
+ Biome with the recommended rule set and Prettier's defaults. Nothing is switched
841
+ off in `biome.json`. The server is TypeScript run directly by Bun, with no build
842
+ step of its own.
843
+
844
+ | | |
845
+ | --- | --- |
846
+ | server | Bun, TypeScript run directly, `Bun.serve`, no framework |
847
+ | storage | SQLite (`bun:sqlite`) via drizzle-orm, idempotent DDL at startup |
848
+ | browser | React 19, Vite, xterm.js v6, SWR for polling |
849
+ | ssh | `ssh.wasm` in the tab, `ssh-keygen` and `ssh-keyscan` on the host |
850
+ | shared | `shared/`: wire types, the grid, the argv tokeniser |
851
+
852
+ `ssh.wasm` comes prebuilt from
853
+ [c2FmZQ/sshterm](https://github.com/c2FmZQ/sshterm) releases, pinned by tag with
854
+ its SHA-256 verified before extraction. To move it, set `SSHTERM_VERSION`, run
855
+ `bun run fetch-wasm`, and copy the checksum it prints into `PINNED`.
856
+
857
+ App icons are generated from `public/icon.svg`:
858
+
859
+ ```bash
860
+ rsvg-convert -w 192 -h 192 public/icon-app.svg -o public/icon-192.png
861
+ rsvg-convert -w 512 -h 512 public/icon-app.svg -o public/icon-512.png
862
+ rsvg-convert -w 180 -h 180 public/icon-app.svg -o public/apple-touch-icon.png
863
+ rsvg-convert -w 512 -h 512 public/icon-maskable.svg -o public/icon-maskable-512.png
864
+ ```
865
+
866
+ ---
867
+
868
+ ## Project status
869
+
870
+ Usable and in daily use, but young. No release binaries are published yet, so
871
+ build one or run the container.
872
+
873
+ Everything in [Deploying on a VPS](#deploying-on-a-vps) has been walked end to
874
+ end on a fresh OVHcloud VPS on Ubuntu 26.04, behind Tailscale, serving HTTPS.
875
+ The firewall rules were checked from outside the tailnet.
876
+
877
+ Known gaps:
878
+
879
+ - **One token, one user.** Everyone with the token is the same unix user, and
880
+ there is no per-browser session to revoke.
881
+ - **ACME has never issued a real certificate.** `--domain` works in tests, but
882
+ the Tailscale path makes it unnecessary, so it stays unproven.
883
+ - **IPv6 reachability is unverified.** The v4 firewall rules were tested from
884
+ outside; the v6 rules mirror them and default to `DROP`, but that was read
885
+ from the box rather than probed.
886
+ - **No keyboard shortcuts.** Deliberate, see [the desktop](#the-desktop), but
887
+ something that does not steal keys from the terminal is worth having.
888
+ - **MCP servers can be picked but not added.** Adding them is `claude mcp` on
889
+ the box.
890
+ - **Hermes models are typed, not listed.** `hermes model` is interactive and
891
+ has no listing mode, so there is nothing to read off the box.
892
+ - **Hermes' browser is a box-wide setting.** Every Hermes profile drives the
893
+ same Chrome, because that is where Hermes reads the CDP target from.
894
+ - **Cursor's login is a box-wide setting.** The CLI holds one credential per
895
+ user, so every Cursor profile runs as the same account, and its model list is
896
+ readable only while logged in. Its MCP credentials are shared across every
897
+ workspace for the same reason.
898
+ - **No branch operations.** Pushing, PRs and merging happen in the terminal.
899
+ - **Deleting a workspace keeps its branch**, so work is recoverable. Nothing
900
+ prunes them for you.
901
+
902
+ ---
903
+
904
+ ## Contributing
905
+
906
+ Issues and pull requests welcome. Before opening one:
907
+
908
+ ```bash
909
+ bun run check # lint, typecheck and tests, the same three CI runs
910
+ ```
911
+
912
+ Tests live in `test/` for the server and beside the code for the browser.
913
+ Anything with logic worth trusting should arrive with some.
914
+
915
+ [docs/gotchas.md](docs/gotchas.md) lists the traps in this codebase that cost
916
+ someone an afternoon. Worth a skim before changing the terminal or the session
917
+ plumbing.
918
+
919
+ Comments in this repository explain why rather than what. If you undo a
920
+ documented decision, update the reasoning with it.
921
+
922
+ ---
923
+
924
+ ## License
925
+
926
+ MIT, see [LICENSE](LICENSE). Bundles [c2FmZQ/sshterm](https://github.com/c2FmZQ/sshterm)
927
+ (MIT) and the packages listed in
928
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). After changing dependencies,
929
+ regenerate that file with `bun run notices`.