feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
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.
This commit is contained in:
49
README.md
49
README.md
@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
|
||||
|
||||
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, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||||
> ⚠️ **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).
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +42,8 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
||||
### 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 a **create-worktree** action.
|
||||
- **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.
|
||||
@@ -59,19 +60,35 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
||||
|
||||
## Clients
|
||||
|
||||
The browser is the reference client; two native clients and a remote-access
|
||||
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/), branch `feat/ios-client`) — 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, Projects, multi-session
|
||||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
||||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
||||
changes; P1 adds two declared additive touch-points. See
|
||||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
||||
- **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
|
||||
@@ -146,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
||||
| `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. |
|
||||
@@ -191,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
||||
|
||||
## Security & deployment
|
||||
|
||||
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
|
||||
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`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||||
- **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).
|
||||
- **No authentication yet.** 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user