Repository Public
1# Architecture23Claude Remote is a modular monolith (`apps/server`) plus a separate privileged daemon (`apps/runtime`),4a static web client (`apps/web`) and a shared type package (`packages/shared`). The binding rules are in5[CONTRACT.md](CONTRACT.md); the network path is in [network-architecture.md](network-architecture.md).6This document describes what the code does.78## Processes910```11 browser ── HTTPS/WSS ──> Caddy (gateway) ── WireGuard ──> apps/server :862012 │ PostgreSQL 17 (loopback)13 │14 unix socket 0600 + bearer token (HTTP + WS)15 ▼16 apps/runtime17 │ execFile / spawn (argv arrays, no shell)18 ▼19 tmux server -L claude-remote (daemonized; outlives the runtime)20 cr-<uuid> terminal session: $SHELL -lc 'exec claude <args>' | $SHELL -l21 crt-<uuid> task runner: /bin/sh tasks/<id>/run.sh22```2324| Process | Listens on | Owns | Never does |25|---|---|---|---|26| `apps/server` (`dist/main.js`) | TCP `CR_HOST:CR_PORT` | PostgreSQL, authentication, authorization, audit, notifications, cluster adapter, OpenAPI document, static files, WebSocket proxying | spawn processes; read or write project files |27| `apps/runtime` (`dist/main.js`) | unix socket `CR_RUNTIME_SOCKET` only | tmux, PTY attach clients, scrollback mirror, tasks, fs/git operations, skills and MCP configuration, Claude introspection, host metrics | open a TCP listener; talk to the database |28| tmux server | its own unix socket | the session and task processes | — (it is a stock tmux started by the runtime with a generated config) |29| PostgreSQL 17 | loopback | metadata (see "Storage") | store terminal content or file content |3031The web application is not a process: `next build --webpack` produces a static export32(`apps/web/out`, `trailingSlash: true`, no dynamic route segments — pages use query parameters such as33`/terminal/?s=<id>`), served by `apps/server/src/static.ts`.3435## Request flow36371. Every HTTP request and WebSocket upgrade under `/api/` passes one global `onRequest` guard38 (`apps/server/src/security/guard.ts`). Routes are `full` (session with completed second factor) unless39 they explicitly declare `public`, `preauth` or `restricted`. See [authentication.md](authentication.md).402. Bodies, queries and parameters are validated with zod schemas from `@cr/shared` (`parse()` in41 `apps/server/src/http.ts`); a failure is a 400 `invalid_request`.423. For anything that touches the host, the server calls the runtime through43 `apps/server/src/runtime-client.ts` (HTTP or WebSocket over the unix socket, `Authorization: Bearer`).44 The server sends an absolute `root` it derived from the project table and a validated relative path;45 the runtime re-validates both (`apps/runtime/src/paths.ts`, `core-util.ts`).464. Runtime errors with status 400/403/404/409/413/415/422/429/507 are forwarded with their code; an47 unreachable runtime becomes 503 `runtime_unavailable`, anything else 502 `runtime_error`48 (`apps/server/src/paths.ts`, `app.ts`).4950## Terminal data path5152```53 xterm.js ⇄ WS /api/v1/sessions/:id/stream ⇄ server ws-pipe ⇄ WS /sessions/:id/attach ⇄ node-pty ⇄ tmux attach ⇄ pane54```5556- Binary frames are raw PTY bytes in both directions; text frames are JSON control messages57 (`TermClientMsg`: `resize`, `ping`; `TermServerMsg`: `ready`, `pong`, `exit`, `error`) defined in58 `packages/shared/src/wire.ts`.59- The server pipe (`apps/server/src/ws-pipe.ts`) preserves frame types, validates client text frames and60 drops invalid ones, and applies backpressure in both directions. It never logs or inspects binary frames.61- One browser connection is one `tmux attach-session` client in a `node-pty`62 (`apps/runtime/src/core-attach.ts`). Closing the WebSocket kills that client only.63- The browser scrollback is written by the runtime from the tmux pane history, not by the attach stream64 (`core-scrollback.ts`). See [terminal-runtime.md](terminal-runtime.md).6566## Events6768```69 tmux poll (2 s) / hook POST / task watcher ──> runtime EventBus ──> WS /events ──> server70 ──> SessionService / TaskService (state transitions in Postgres) ──> EventHub ──> WS /api/v1/events ──> browser71```7273- Runtime events (`RtEvent`): `session.exit`, `session.activity`, `claude.hook`, `task.updated`.74- Browser events (`ServerEvent`): `hello`, `session.updated`, `session.removed`, `task.updated`,75 `notification`, `auth.expired`.76- Both fan-outs drop a consumer whose outbound buffer exceeds 1 MiB instead of buffering without bound.77- The server's subscription to the runtime reconnects with a 1 s → 10 s backoff; on every reconnection and78 every 15 s a reconciler compares the database with what the runtime reports79 (`apps/server/src/app.ts`, `modules/sessions/service.ts`, `modules/tasks/service.ts`).80- The web client refetches the affected lists on every reconnect of its events socket81 (`apps/web/src/lib/events.ts`).8283## State machines8485Defined once in `packages/shared/src/states.ts`, enforced server-side in `transition()` under a row lock;86every session transition writes a `terminal_events` row.8788Terminal sessions:8990| From | Allowed next states |91|---|---|92| `CREATED` | `STARTING`, `FAILED`, `STOPPED` |93| `STARTING` | `RUNNING`, `DETACHED`, `FAILED`, `STOPPED` |94| `RUNNING` / `DETACHED` / `WAITING_FOR_INPUT` | each other, `STOPPING`, `STOPPED`, `FAILED`, `RECOVERABLE` |95| `STOPPING` | `STOPPED`, `FAILED` |96| `RECOVERABLE` | `STARTING` (restart), `STOPPED` |97| `STOPPED`, `FAILED` | terminal |9899- `RUNNING` / `DETACHED` follow the number of attached clients.100- `WAITING_FOR_INPUT` is set only from a Claude Code hook (`Notification`, `Stop`); `UserPromptSubmit`101 returns the session to `RUNNING`. Terminal output is never parsed.102- `RECOVERABLE` means the runtime is healthy and does not know the session (tmux session gone).103104Tasks: `QUEUED → RUNNING → SUCCEEDED | FAILED | CANCELLING → CANCELLED | TIMED_OUT | LOST`. The server105inserts a `QUEUED` row and immediately hands it to the runtime; there is no scheduler or queue depth106limit. `LOST` (runner vanished without an exit file, or unknown to a healthy runtime) is never retried107automatically. Retry is a user action allowed only for tasks created with `idempotent: true`.108109Health vocabulary (`ONLINE`, `OFFLINE`, `DEGRADED`, `UNKNOWN`): stale data is `UNKNOWN`, never `ONLINE`.110111## Storage112113| Store | Location | Content |114|---|---|---|115| PostgreSQL `claude_remote` | loopback | `users`, `devices` (passkeys), `auth_sessions`, `preauth_challenges`, `login_attempts`, `projects`, `terminal_sessions`, `terminal_events` (lifecycle only), `agent_tasks`, `task_artifacts` (metadata), `cluster_nodes`, `node_metrics`, `mcp_servers` / `installed_skills` (listing caches), `notifications`, `audit_events`, `application_settings`, `schema_migrations` |116| Data directory `CR_DATA_DIR` (0700) | `~/claude-remote-data` | `run/runtime.sock`, `run/hook-token`, `run/tmux.conf`, `tasks/<id>/{command,run.sh,log,exit,started,time,meta.json}`, `setup-token` (only while no administrator exists) |117| Workspace root(s) `CR_WORKSPACE_ROOTS` | `~/workspace` | project directories; the only tree the file API can reach |118| Claude Code configuration | `~/.claude`, `~/.claude.json` | owned by Claude Code; the runtime reads it and edits it only through `claude mcp …`, skill file moves and the hook block |119| tmux server memory | — | the live processes and up to 20 000 history lines per pane |120121Retention is applied daily by `apps/server/src/janitor.ts`: `terminal_events` per the user setting122`sessionRetentionDays` (default 90), `node_metrics` 14 days, `login_attempts` 30 days, `audit_events`123400 days, `notifications` 90 days, `auth_sessions` 7 days after expiry or revocation, expired124`preauth_challenges`.125126Migrations (`apps/server/migrations/*.sql`) are applied in lexical order, each in its own transaction,127under a session-level advisory lock, by `dist/migrate.js` and also at every server start.128129## Module map130131| Area | Server (`apps/server/src`) | Runtime (`apps/runtime/src`) | Web (`apps/web/src`) |132|---|---|---|---|133| Auth | `security/*`, `modules/auth/*` | `auth.ts` (IPC tokens) | `app/(auth)/*`, `components/auth`, `components/settings/security.tsx` |134| Sessions / terminal | `modules/sessions/*`, `modules/terminal-ws.ts`, `ws-pipe.ts`, `live.ts` | `sessions.ts`, `tmux.ts`, `core-attach.ts`, `core-scrollback.ts`, `core-backpressure.ts`, `hooks.ts` | `components/terminal/*`, `app/(app)/terminal`, `app/(app)/sessions` |135| Projects / files | `modules/projects`, `modules/files` | `paths.ts`, `fs.ts`, `fsx-transfer.ts`, `fsx-search.ts`, `git.ts` | `app/(app)/projects`, `app/(app)/files`, `components/files`, `components/editor` |136| Tasks | `modules/tasks/*` | `tasks.ts`, `core-task-manager.ts`, `core-task-script.ts` | `app/(app)/tasks`, `components/tasks` |137| Skills / MCP | `modules/skills-mcp/*` | `skills.ts`, `fsx-skills-scan.ts`, `mcp.ts`, `fsx-mcp-config.ts`, `claude.ts` | `app/(app)/skills`, `app/(app)/mcp` |138| Cluster | `modules/cluster/*` | `metrics.ts` | `app/(app)/cluster`, `components/cluster` |139| Observability | `modules/system`, `audit.ts`, `modules/notifications` | `metrics.ts`, `events.ts` | `app/(app)/system`, `components/shell/notifications.tsx` |140141## Design decisions visible in the code142143- **The runtime is the only spawner.** All subprocesses use argv arrays (`execFile` / `spawn`, never a144 shell). A user string reaches a shell in exactly two places, both by design: the task command and the145 terminal itself.146- **Claude arguments are built server-side only** (`buildClaudeArgs` in `modules/sessions/index.ts`): the147 browser can request "new", "continue" or "resume `<id>`"; raw CLI arguments are not accepted. The runtime148 independently refuses permission-bypass flags and option-like `--resume` values.149- **Never guess.** If the runtime is unreachable the reconciler changes no state; metrics that cannot be150 measured are `null`; stale cluster data is `UNKNOWN`.151- **tmux as the persistence layer.** Sessions and tasks live in a tmux server that outlives both Node152 processes; state is rebuilt from tmux and from files on disk after a restart.153154## Limitations / not verified155156- The identity that runs everything is the cluster operator's macOS account. Process separation, the 0600157 socket and path confinement are the isolation mechanisms; there is no OS-level user separation between158 server, runtime and the terminal sessions. See [security.md](security.md).159- Single host, single PostgreSQL instance, no replication.160- `CONTRACT.md` lists `artifacts/`, `uploads/` and `backups/` under `CR_DATA_DIR`; the code creates none of161 them. Artifacts stay in the task's working directory (only metadata is stored), uploads are streamed to162 their destination through a temporary file in the same directory, and backups go to `~/backups/claude-remote`.163- `ClusterNode.execAllowed` is documented in `packages/shared/src/dto.ts` as "on the allowlist AND reachable164 with the runtime key"; the implementation (`modules/cluster/mapping.ts`) only checks the allowlist.165