App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
233 lines
23 KiB
Markdown
233 lines
23 KiB
Markdown
# Web Terminal
|
||
|
||
A self-hosted, browser-based terminal **and** Claude-Code session/project workbench. It exposes the **host machine's local shell** over WebSocket: open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another laptop) and you get a live, interactive shell. The core use case is **vibe coding** — hand Claude Code a task, walk away, then reconnect from anywhere to check on it, approve a tool call from your phone's lock screen, or kick off the next one.
|
||
|
||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
|
||
|
||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
### Core terminal
|
||
- **WebSocket terminal** — xterm.js in the browser, node-pty on the host. Keystrokes shuttle to the shell; output streams back. No terminal parsing on the server.
|
||
- **Sessions survive disconnects** — the PTY lifecycle is decoupled from the WebSocket. Closing a tab *detaches* a client; the shell keeps running. Reconnect replays a ~2 MB scrollback ring buffer.
|
||
- **Auto-reconnect** — exponential backoff (1s/2s/4s… capped at 30s), carrying the `localStorage` session id, so a flaky network or a phone waking from sleep just resumes.
|
||
- **Mobile touch key-bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / ^R / ^L / ^D / Tab / `/` — the high-frequency Claude Code keys a soft keyboard can't produce, sent as raw bytes (no soft-keyboard pop).
|
||
- **Multi-device session mirroring** — many devices can attach to the *same* session at once. Output, exit and status broadcast to all; any device can type (shared control). PTY sizing is **latest-writer-wins**: whichever device you're actively using drives the size and stays full-screen.
|
||
|
||
### Tabs & home
|
||
- **Multi-tab** — each tab is an independent shell. 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.
|
||
- **Home Sessions chooser** — opening the app lands on a chooser, not a blank shell. It shows the host's running sessions as **live preview thumbnails** (read-only renders of each screen) so you can see what each one is doing, plus a dashed **+ New session** tile. Pick one to open (full scrollback replay) or start fresh.
|
||
- **Per-session Open / Kill** — manage sessions right from the chooser; no separate manage page.
|
||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
||
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
||
|
||
### Split-grid watch board (v0.8, desktop)
|
||
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
|
||
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
|
||
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
|
||
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
|
||
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
|
||
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
|
||
|
||
### Claude Code cockpit
|
||
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
||
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
||
- **Plan-mode / permission-mode relay** — start a session in a chosen `--permission-mode` (default / acceptEdits / plan / auto). When Claude exits plan mode, the approval bar becomes a **three-way** gate (approve+auto / approve+review / keep planning). The high-risk `auto` mode is gated behind `ALLOW_AUTO_MODE`.
|
||
- **tmux keepalive** — run the shell inside tmux so sessions survive a **server/host restart**, not just a disconnect. Auto-detected, or forced with `USE_TMUX`.
|
||
- **Session history / resume** — browse past Claude Code sessions (from `~/.claude/projects`) and resume one.
|
||
|
||
### Projects (v0.6)
|
||
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
|
||
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
|
||
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
|
||
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
|
||
|
||
### Walk-away workbench (v0.7)
|
||
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
|
||
- **Voice dictation** — push-to-talk mic on the input bar (Web Speech API); release to send the transcript as input (optional auto-Enter). Hidden where unsupported; audio never touches the server.
|
||
- **Quick-reply chips + saved-prompt palette** — tap-to-send chips (`yes`, `continue`, `1/2/3`, `Esc`) plus a persistent palette of your own named snippets.
|
||
- **Activity timeline** — a human-readable, timestamped stream of what happened while you were gone ("ran Bash · edited 3 files · waiting for approval · done"), built from discrete hook events in a bounded per-session ring.
|
||
- **Stuck / idle alert** — if a session goes silent past a threshold (no output, not idle, not exited) it fires a one-shot "possibly stuck" alert through the same notification channel. Runs on the existing reaper tick (no extra timer).
|
||
- **statusLine telemetry → per-tab gauges** — a statusLine script feeds back context-usage, cumulative cost, model, lines +/−, PR state, and rate-limit (5h/7d) telemetry, rendered as **per-tab gauges** on tabs, thumbnails and project cards. Stale telemetry greys out.
|
||
|
||
### UX
|
||
- Themed UI (Amber dark theme + light / solarized, adjustable font size), scrollback **search**, an all-sessions **dashboard**, **share-session QR/link** (`?join=<id>`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips.
|
||
|
||
---
|
||
|
||
## Clients
|
||
|
||
The browser is the reference client; three native clients and a remote-access
|
||
service are also in the repo (all consume the same wire protocol — the server
|
||
stays a byte-shuttle).
|
||
|
||
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
||
remote client** built for the walk-away loop: native terminal with scrollback
|
||
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
||
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
||
deep links (including the web's `?join=` share link), Projects with a **git
|
||
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
||
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
||
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
||
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
|
||
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
|
||
P0 needed zero server changes; P1 added two declared additive touch-points.
|
||
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
|
||
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
|
||
functional parity with iOS, module-for-module mirroring the iOS package set
|
||
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
|
||
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
|
||
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
|
||
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
|
||
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
|
||
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
|
||
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
|
||
has been installed and launched on an emulator; **real-device QA is still
|
||
pending**. Design in
|
||
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
|
||
[`android/README.md`](android/README.md).
|
||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||
tray, deep links and launch-at-login. Design in
|
||
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
|
||
verified on Apple Silicon.)*
|
||
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
|
||
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
|
||
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
|
||
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
|
||
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
|
||
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
|
||
**Tailscale** (§ Security & deployment below).
|
||
|
||
---
|
||
|
||
## Quick start
|
||
|
||
### Prerequisites
|
||
- **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. After a major Node version bump, run `npm rebuild`.
|
||
|
||
### Install & run
|
||
```bash
|
||
npm install # installs deps (incl. node-pty native build); postinstall fixes the spawn-helper bit
|
||
npm run build:web # bundle the frontend (public/main.ts → public/build/) with esbuild
|
||
npm start # serve on 0.0.0.0:3000
|
||
```
|
||
|
||
Then find your LAN IP and open it from any device on the same network:
|
||
```bash
|
||
ipconfig getifaddr en0 # macOS
|
||
# open http://<that-ip>:3000
|
||
```
|
||
|
||
For frontend development, run `npm run dev:web` (esbuild `--watch`) alongside `npm start`.
|
||
|
||
### Enable the Claude Code cockpit (optional but recommended)
|
||
```bash
|
||
npm run setup-hooks # adds the hooks + statusLine to ~/.claude/settings.json (backs it up)
|
||
# npm run setup-hooks -- --remove # to uninstall
|
||
```
|
||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||
|
||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||
|
||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||
|
||
### Tests
|
||
```bash
|
||
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
||
npm run typecheck # tsc (backend + frontend)
|
||
npm run build # compile backend to dist/
|
||
```
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
All config is via environment variables (no hardcoding). Invalid values fail fast at startup.
|
||
|
||
### Core
|
||
| Var | Default | Purpose |
|
||
|-----|---------|---------|
|
||
| `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 (no new output since detach). |
|
||
| `SCROLLBACK_BYTES` | `2097152` (2 MB) | Per-session replay ring buffer size. |
|
||
| `MAX_PAYLOAD_BYTES` | `1048576` (1 MB) | Max single WS frame; oversized frames are rejected. |
|
||
| `WS_PATH` | `/term` | The only path accepted for WS upgrade. |
|
||
| `MAX_SESSIONS` | `50` | Cap on concurrent sessions (DoS guard). |
|
||
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
||
| `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. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
||
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16–512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
|
||
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
||
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||
|
||
### Projects (v0.6)
|
||
| Var | Default | Purpose |
|
||
|-----|---------|---------|
|
||
| `PROJECT_ROOTS` | `$HOME` | Comma-separated absolute roots to scan for git repos (`~`/`~/...` expanded; relative paths rejected). |
|
||
| `PROJECT_SCAN_DEPTH` | `4` | How deep to descend looking for `.git`. |
|
||
| `PROJECT_SCAN_TTL` | `10000` (ms) | Cache TTL for repo discovery. |
|
||
| `PROJECT_DIRTY_CHECK` | `1` (on) | Run `git status --porcelain` to flag dirty repos. |
|
||
| `EDITOR_CMD` | `code` | Command the host runs for the project "VS Code" button. |
|
||
|
||
### Walk-away workbench (v0.7)
|
||
| Var | Default | Purpose |
|
||
|-----|---------|---------|
|
||
| `VAPID_PUBLIC_KEY` | (unset → push off) | Web Push public key, exposed to the service worker. |
|
||
| `VAPID_PRIVATE_KEY` | (unset, **secret**) | Web Push private key; required to enable push. Never logged. |
|
||
| `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub`. |
|
||
| `PUSH_STORE_PATH` | `~/.web-terminal-push-subs.json` | Persisted push-subscription store. |
|
||
| `PUSH_MAX_SUBS` | `50` | Max stored push subscriptions (DoS guard). |
|
||
| `NOTIFY_DONE` | `1` (on) | Send the low-priority DONE push on Stop/SessionEnd. |
|
||
| `NOTIFY_DND` | `0` (off) | Global do-not-disturb default. |
|
||
| `DECISION_TOKEN_TTL_MS` | = `PERM_TIMEOUT_MS` | Lifetime of a lock-screen decision capability token. |
|
||
| `TIMELINE_MAX` | `200` | Per-session activity-timeline event ring cap. |
|
||
| `TIMELINE_ENABLED` | `1` (on) | Capture/serve the activity timeline. |
|
||
| `STUCK_TTL` | `600` (s) | Silence window before a "possibly stuck" alert; `0` disables. |
|
||
| `STUCK_ALERT` | `1` (on) | Master switch for stuck alerts. |
|
||
| `DIFF_TIMEOUT_MS` | `2000` | Timeout for a single `git diff`. |
|
||
| `DIFF_MAX_BYTES` | `2097152` (2 MB) | Patch truncation cap. |
|
||
| `DIFF_MAX_FILES` | `300` | Max files returned before the diff is marked `truncated`. |
|
||
| `STATUSLINE_TTL_MS` | `30000` | After this with no update, per-tab telemetry greys out as stale. |
|
||
| `WORKTREE_ENABLED` | `1` (on) | Master switch for the create-worktree feature (the only write-to-disk action). |
|
||
| `WORKTREE_ROOT` | (unset → `<repo>-worktrees`) | Root that new worktrees must land inside. |
|
||
| `WORKTREE_TIMEOUT_MS` | `10000` | Timeout for `git worktree add`. |
|
||
| `DEFAULT_PERMISSION_MODE` | `default` | `--permission-mode` used when a session is started without an explicit choice (`default`/`acceptEdits`/`plan`/`auto`). |
|
||
| `ALLOW_AUTO_MODE` | `0` (off) | Whether the high-risk `auto` (bypass-permissions) mode is offered/honored. |
|
||
|
||
### Spawn-injected env (set by the server / read by hook scripts — not user config)
|
||
`WEBTERM_SESSION`, `WEBTERM_HOOK_URL`, `WEBTERM_STATUSLINE_URL` are injected into each spawned shell so the hooks/statusLine know which session they belong to and where to POST. The optional ntfy/Pushover bridge reads `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` and `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` — secrets stay in env, never written into `settings.json` or command argv.
|
||
|
||
---
|
||
|
||
## Security & deployment
|
||
|
||
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
|
||
|
||
- **WS Origin validation (cannot be skipped):** the WebSocket 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`. Only `WS_PATH` is accepted for upgrade.
|
||
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
|
||
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
|
||
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
||
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16–512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
|
||
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a real pseudo-terminal, the server shuttles raw bytes over the WebSocket, and `xterm.js` in the browser interprets ANSI and renders. The server never parses terminal semantics — every "smart" feature (status, timeline, telemetry, diff, push) rides an **out-of-band side-channel** (loopback HTTP/JSON from Claude Code hooks, or `git` subprocesses), keeping the terminal stream a pure pipe.
|
||
|
||
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
||
|
||
Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||
|
||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|