@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.
- package/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/README.md
CHANGED
|
@@ -1,3 +1,929 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://github.com/tsconfigdotjson/vibe-os/actions/workflows/ci.yml)
|
|
13
|
+
[](#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`.
|