104 lines
5.8 KiB
Markdown
104 lines
5.8 KiB
Markdown
# Web Terminal
|
|
|
|
A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another computer) and you get a live, interactive terminal — primarily for **vibe coding**: hand Claude Code a task, walk away, reconnect from anywhere to check on it.
|
|
|
|
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the output replays.
|
|
|
|
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security).
|
|
|
|
## Features
|
|
|
|
- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
|
|
- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing.
|
|
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect.
|
|
- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce).
|
|
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer.
|
|
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA.
|
|
|
|
## Requirements
|
|
|
|
- Node.js ≥ 18 (developed on v24)
|
|
- macOS/Linux. On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
npm install # installs deps; postinstall makes node-pty's spawn-helper executable
|
|
```
|
|
|
|
## Run
|
|
|
|
```bash
|
|
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
|
|
npm start # serve on 0.0.0.0:3000
|
|
```
|
|
|
|
Then:
|
|
|
|
```bash
|
|
# find your LAN IP (macOS):
|
|
ipconfig getifaddr en0
|
|
# open http://<that-ip>:3000 on any device on the same network
|
|
```
|
|
|
|
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`.
|
|
|
|
## Claude Code cockpit (optional)
|
|
|
|
To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once:
|
|
|
|
```bash
|
|
npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up)
|
|
# npm run setup-hooks -- --remove # to uninstall
|
|
```
|
|
|
|
Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed.
|
|
|
|
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
|
|
|
|
```bash
|
|
USE_TMUX=1 npm start
|
|
```
|
|
|
|
## Configuration
|
|
|
|
All via environment variables (no hardcoding):
|
|
|
|
| Var | Default | Meaning |
|
|
|-----|---------|---------|
|
|
| `PORT` | `3000` | listen port |
|
|
| `BIND_HOST` | `0.0.0.0` | listen address |
|
|
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | shell to spawn |
|
|
| `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time |
|
|
| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) |
|
|
| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame |
|
|
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH |
|
|
| `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated |
|
|
|
|
`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin.
|
|
|
|
## Security
|
|
|
|
This is a no-auth, LAN-only tool by design. The defenses that matter:
|
|
|
|
- **Origin check (cannot be skipped):** the WS handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`.
|
|
- **Path-scoped upgrades:** only `/term` is accepted for WS upgrade.
|
|
- **Frame size cap:** oversized frames are rejected (`MAX_PAYLOAD_BYTES`).
|
|
- **Never expose to the public internet.** `ws://` is **unencrypted** — on an untrusted network (café/office Wi-Fi) traffic (including keystrokes: passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also lets you use `wss://` (the frontend auto-selects `wss` on HTTPS).
|
|
|
|
## Development
|
|
|
|
```bash
|
|
npm test # vitest, all modules
|
|
npm run typecheck # tsc (backend + frontend)
|
|
npm run build # compile backend to dist/
|
|
```
|
|
|
|
Real-PTY end-to-end tests (`test/integration/`) auto-skip where `posix_spawn` is unavailable (e.g. sandboxes) and run everywhere else.
|
|
|
|
## How it works
|
|
|
|
The server is a **byte-shuttle, not a terminal**: `node-pty` provides a pseudo-terminal so the shell believes it has a real TTY, and `xterm.js` in the browser interprets the ANSI bytes and renders. The server never parses terminal semantics. PTY lifecycle is decoupled from the WebSocket — a disconnect *detaches* (the PTY keeps running) rather than killing it, which is what makes session survival work.
|
|
|
|
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (why) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how).
|