SPB Git

Repository Public

architecture.md 11.3 KB

# Architecture

Claude Remote is a modular monolith (apps/server) plus a separate privileged daemon (apps/runtime), a static web client (apps/web) and a shared type package (packages/shared). The binding rules are in CONTRACT.md; the network path is in network-architecture.md. This document describes what the code does.

# Processes

text
 browser ── HTTPS/WSS ──> Caddy (gateway) ── WireGuard ──> apps/server :8620
                                                              │   PostgreSQL 17 (loopback)
                                                              │
                                     unix socket 0600 + bearer token (HTTP + WS)
                                                              ▼
                                                          apps/runtime
                                                              │ execFile / spawn (argv arrays, no shell)
                                                              ▼
                              tmux server  -L claude-remote  (daemonized; outlives the runtime)
                                 cr-<uuid>   terminal session:  $SHELL -lc 'exec claude <args>'  |  $SHELL -l
                                 crt-<uuid>  task runner:       /bin/sh tasks/<id>/run.sh
Process Listens on Owns Never does
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
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
tmux server its own unix socket the session and task processes — (it is a stock tmux started by the runtime with a generated config)
PostgreSQL 17 loopback metadata (see "Storage") store terminal content or file content

The web application is not a process: next build --webpack produces a static export (apps/web/out, trailingSlash: true, no dynamic route segments — pages use query parameters such as /terminal/?s=<id>), served by apps/server/src/static.ts.

# Request flow

  1. Every HTTP request and WebSocket upgrade under /api/ passes one global onRequest guard (apps/server/src/security/guard.ts). Routes are full (session with completed second factor) unless they explicitly declare public, preauth or restricted. See authentication.md.
  2. Bodies, queries and parameters are validated with zod schemas from @cr/shared (parse() in apps/server/src/http.ts); a failure is a 400 invalid_request.
  3. For anything that touches the host, the server calls the runtime through apps/server/src/runtime-client.ts (HTTP or WebSocket over the unix socket, Authorization: Bearer). The server sends an absolute root it derived from the project table and a validated relative path; the runtime re-validates both (apps/runtime/src/paths.ts, core-util.ts).
  4. Runtime errors with status 400/403/404/409/413/415/422/429/507 are forwarded with their code; an unreachable runtime becomes 503 runtime_unavailable, anything else 502 runtime_error (apps/server/src/paths.ts, app.ts).

# Terminal data path

text
 xterm.js ⇄ WS /api/v1/sessions/:id/stream ⇄ server ws-pipe ⇄ WS /sessions/:id/attach ⇄ node-pty ⇄ tmux attach ⇄ pane
  • Binary frames are raw PTY bytes in both directions; text frames are JSON control messages (TermClientMsg: resize, ping; TermServerMsg: ready, pong, exit, error) defined in packages/shared/src/wire.ts.
  • The server pipe (apps/server/src/ws-pipe.ts) preserves frame types, validates client text frames and drops invalid ones, and applies backpressure in both directions. It never logs or inspects binary frames.
  • One browser connection is one tmux attach-session client in a node-pty (apps/runtime/src/core-attach.ts). Closing the WebSocket kills that client only.
  • The browser scrollback is written by the runtime from the tmux pane history, not by the attach stream (core-scrollback.ts). See terminal-runtime.md.

# Events

text
 tmux poll (2 s) / hook POST / task watcher ──> runtime EventBus ──> WS /events ──> server
      ──> SessionService / TaskService (state transitions in Postgres) ──> EventHub ──> WS /api/v1/events ──> browser
  • Runtime events (RtEvent): session.exit, session.activity, claude.hook, task.updated.
  • Browser events (ServerEvent): hello, session.updated, session.removed, task.updated, notification, auth.expired.
  • Both fan-outs drop a consumer whose outbound buffer exceeds 1 MiB instead of buffering without bound.
  • The server's subscription to the runtime reconnects with a 1 s → 10 s backoff; on every reconnection and every 15 s a reconciler compares the database with what the runtime reports (apps/server/src/app.ts, modules/sessions/service.ts, modules/tasks/service.ts).
  • The web client refetches the affected lists on every reconnect of its events socket (apps/web/src/lib/events.ts).

# State machines

Defined once in packages/shared/src/states.ts, enforced server-side in transition() under a row lock; every session transition writes a terminal_events row.

Terminal sessions:

From Allowed next states
CREATED STARTING, FAILED, STOPPED
STARTING RUNNING, DETACHED, FAILED, STOPPED
RUNNING / DETACHED / WAITING_FOR_INPUT each other, STOPPING, STOPPED, FAILED, RECOVERABLE
STOPPING STOPPED, FAILED
RECOVERABLE STARTING (restart), STOPPED
STOPPED, FAILED terminal
  • RUNNING / DETACHED follow the number of attached clients.
  • WAITING_FOR_INPUT is set only from a Claude Code hook (Notification, Stop); UserPromptSubmit returns the session to RUNNING. Terminal output is never parsed.
  • RECOVERABLE means the runtime is healthy and does not know the session (tmux session gone).

Tasks: QUEUED → RUNNING → SUCCEEDED | FAILED | CANCELLING → CANCELLED | TIMED_OUT | LOST. The server inserts a QUEUED row and immediately hands it to the runtime; there is no scheduler or queue depth limit. LOST (runner vanished without an exit file, or unknown to a healthy runtime) is never retried automatically. Retry is a user action allowed only for tasks created with idempotent: true.

Health vocabulary (ONLINE, OFFLINE, DEGRADED, UNKNOWN): stale data is UNKNOWN, never ONLINE.

# Storage

Store Location Content
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
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)
Workspace root(s) CR_WORKSPACE_ROOTS ~/workspace project directories; the only tree the file API can reach
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
tmux server memory — the live processes and up to 20 000 history lines per pane

Retention is applied daily by apps/server/src/janitor.ts: terminal_events per the user setting sessionRetentionDays (default 90), node_metrics 14 days, login_attempts 30 days, audit_events 400 days, notifications 90 days, auth_sessions 7 days after expiry or revocation, expired preauth_challenges.

Migrations (apps/server/migrations/*.sql) are applied in lexical order, each in its own transaction, under a session-level advisory lock, by dist/migrate.js and also at every server start.

# Module map

Area Server (apps/server/src) Runtime (apps/runtime/src) Web (apps/web/src)
Auth security/*, modules/auth/* auth.ts (IPC tokens) app/(auth)/*, components/auth, components/settings/security.tsx
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
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
Tasks modules/tasks/* tasks.ts, core-task-manager.ts, core-task-script.ts app/(app)/tasks, components/tasks
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
Cluster modules/cluster/* metrics.ts app/(app)/cluster, components/cluster
Observability modules/system, audit.ts, modules/notifications metrics.ts, events.ts app/(app)/system, components/shell/notifications.tsx

# Design decisions visible in the code

  • The runtime is the only spawner. All subprocesses use argv arrays (execFile / spawn, never a shell). A user string reaches a shell in exactly two places, both by design: the task command and the terminal itself.
  • Claude arguments are built server-side only (buildClaudeArgs in modules/sessions/index.ts): the browser can request "new", "continue" or "resume <id>"; raw CLI arguments are not accepted. The runtime independently refuses permission-bypass flags and option-like --resume values.
  • Never guess. If the runtime is unreachable the reconciler changes no state; metrics that cannot be measured are null; stale cluster data is UNKNOWN.
  • tmux as the persistence layer. Sessions and tasks live in a tmux server that outlives both Node processes; state is rebuilt from tmux and from files on disk after a restart.

# Limitations / not verified

  • The identity that runs everything is the cluster operator's macOS account. Process separation, the 0600 socket and path confinement are the isolation mechanisms; there is no OS-level user separation between server, runtime and the terminal sessions. See security.md.
  • Single host, single PostgreSQL instance, no replication.
  • CONTRACT.md lists artifacts/, uploads/ and backups/ under CR_DATA_DIR; the code creates none of them. Artifacts stay in the task's working directory (only metadata is stored), uploads are streamed to their destination through a temporary file in the same directory, and backups go to ~/backups/claude-remote.
  • ClusterNode.execAllowed is documented in packages/shared/src/dto.ts as "on the allowlist AND reachable with the runtime key"; the implementation (modules/cluster/mapping.ts) only checks the allowlist.
Files main
Menu

Theme