Claude Remote — engineering contract (binding for every workstream)
Product: private, single-administrator, browser-accessible environment for the real Claude Code CLI running on a MacLustr node (M5M36), at https://claude.spboucher.ai. Never simulate Claude or a terminal. Never fabricate metrics: unknown/stale data is shown as UNKNOWN with its collection timestamp.
Agent profiles (additive, 2026-11-10)
The same code base also runs as Codex Remote (OpenAI Codex CLI, https://codex.spboucher.ai): a second,
independent instance (own port, database, data dir, tmux socket). CR_AGENT = claude (default) | codex
selects the profile in the runtime and the server; the web build is shared and reads branding from
GET /auth/status. Everything below describes the Claude profile, which is unchanged when no new variable
is set; the differences of the Codex profile are in codex-profile.md.
- Session kind — decision.
SessionKindstays'claude' | 'shell'on the wire and in the database (CHECKkind IN ('claude','shell')):'claude'means "the instance's agent". No'codex'kind and no migration: production rows stay valid and each instance has its own database. - Additive DTO fields:
AuthStatus.branding {agent, productName, agentName}(optional, public),SystemStatus.claude.agent/agentNameandGET /health→agent(both optional: sent by a Codex instance only; absent ⇒ claude, so a Claude instance's payloads are unchanged). RuntimeGET /health→agent,agentHooks {requested, status, detail}.@cr/sharedexportsAgentId,AGENT_PROFILES,Branding,CONVERSATION_ID_RE. - New env:
CR_AGENT,CR_AGENT_BIN(fallbackCR_CLAUDE_BINfor claude),CR_INSTALL_AGENT_HOOKS(CR_INSTALL_CLAUDE_HOOKSstays an alias for claude),CODEX_HOME(optional). Defaults per profile: tmux socket and data dir<claude|codex>-remote[-data]. - Never run
codexwith an approval/sandbox bypass or a-c/--configoverride; the runtime refuses them (400) and accepts only[],resume --last,resume <uuid>. - Profiles without a prompt-submit hook (Codex): the first terminal input from a browser after WAITING_FOR_INPUT returns the session to RUNNING. Never applied to Claude.
Processes (modular monolith + separate runtime)
browser ──HTTPS/WSS──> Caddy (BHS64 tunnel) ──> apps/server :8620 (Fastify; auth, REST, WS, static web, Postgres)
│ unix socket 0600 + bearer token (HTTP + WS)
▼
apps/runtime (the ONLY process that spawns anything:
│ tmux, node-pty, fs, git, ssh, claude)
▼
tmux server (socket `-L claude-remote`) → `claude` / login shell per sessionapps/web: Next.js (App Router) static export (output: 'export',trailingSlash: true, built withnext build --webpack) →apps/web/out, served by the server. No Next server at runtime, no dynamic route segments (use query params, e.g./terminal/?s=<id>). Everything self-hosted (fonts, Monaco workers, icons): the CSP forbids third-party origins (frame-src 'self' blob:for the PDF preview only;object-src 'none',frame-ancestors 'none').apps/server: owns Postgres, authentication, authorization, audit, notifications, cluster adapter, OpenAPI. It never spawns processes and never touches project files directly — it asks the runtime.apps/runtime: owns tmux/PTY, tasks, filesystem + git operations, Claude introspection, host metrics. Listens ONLY on the unix socketCR_RUNTIME_SOCKET(mode 0600, parent dir 0700). No TCP listener.packages/shared(@cr/shared): zod schemas + types — states, browser DTOs, WS wire formats, runtime IPC types. Read it first. It is frozen: if you need a change, make it additive and list it in your final report.
Ownership (do not edit outside your area; do not change root config or packages/shared semantics)
| Workstream | Directory |
|---|---|
| runtime-core (tmux, PTY attach, tasks, hooks, events, host metrics) | apps/runtime/src/{main,config,auth,tmux,sessions,tasks,hooks,events,metrics}* |
| runtime-fs (fs, git, search, claude introspection, skills, mcp) | apps/runtime/src/{paths,fs,git,claude,skills,mcp}* — registers via apps/runtime/src/plugins.ts |
| server | apps/server/** |
| web | apps/web/** |
Configuration (env; see .env.example)
CR_PORT (8620) · CR_HOST · CR_PUBLIC_ORIGIN (exact origin for cookies / Origin checks / WebAuthn RP ID) ·
CR_DATA_DIR (persistent, outside the deploy dir: run/, tasks/<id>/{log,exit,meta.json}, artifacts/, uploads/,
backups/, setup-token) · CR_WORKSPACE_ROOTS (comma list label=/abs/path or /abs/path; label defaults to basename) ·
CR_RUNTIME_SOCKET · CR_RUNTIME_TOKEN · DATABASE_URL · CR_SECRET_KEY (hex 32 bytes; encrypts TOTP secrets etc.
with AES-256-GCM) · CR_AGENTD_URL/CR_AGENTD_TOKEN · CR_CLAUDE_BIN · CR_MAX_SESSIONS (12) ·
CR_EXEC_HOSTS (comma allowlist of node aliases for remote tasks; empty = local only) ·
CR_TERM_BUFFER_BYTES (per-connection outbound high-water mark, default 4 MiB) ·
CR_REPLAY_LINES (runtime: history lines replayed to a browser on attach, default 5000, max 20000; a replay is
also capped at 2 MiB).
Terminal design
- One tmux session per terminal session, named
cr-<uuid>, on the dedicated tmux socket; created detached withremain-on-exit on(so exit code is observable),history-limitbounded,status off,CR_SESSION_ID=<uuid>in env,TERM=xterm-256color, started via the user's login shell so PATH matches an interactive terminal: claude →$SHELL -lc 'exec claude <args>'; shell →$SHELL -l. - Never pass
--dangerously-skip-permissionsor any permission-bypass flag. Claude's native permission prompts are preserved. - A browser connection =
node-ptyspawningtmux -L claude-remote attach-session -t cr-<uuid>sized to the client. Closing the WebSocket kills only that attach client. tmuxwindow-size latest,mouse off. Reconnect = new attach: the browser resets its terminal, the runtime replays the history, tmux repaints the screen. - Scrollback (
apps/runtime/src/core-scrollback.ts).tmux attachonly renders the visible screen and skips intermediate output, so it cannot feed a browser scrollback. The attach client therefore runs withterminal-overrides xterm-256color:smcup@:rmcup@:csr@:indn@: tmux paints in the browser's normal buffer (no alternate screen) and redraws instead of scrolling (it never writes into the browser scrollback). The runtime is the only writer of that scrollback, as a mirror of the pane history: on every attach, before the PTY starts, it sends the lastCR_REPLAY_LINEShistory lines (capture-pane -p -e -J, history only, colours kept); while attached it appends the lines tmux added to the history (≤ 250 ms behind; up to 1 s after a stretch of output that did not scroll), pushed through a two-row scroll region, then makes tmux repaint (SIGWINCH to the attach client). When continuity cannot be proven (pane resized, history cleared or trimmed, >CR_REPLAY_LINESnew lines) the scrollback is cleared (CSI 3 J) and replayed. A client whose size is not the window's (second browser of another size, not the one being typed in) is not touched until sizes agree. Full-screen programs inside the pane (their own alternate screen) render through tmux and add nothing to the scrollback. - Backpressure: if the WS outbound buffer exceeds
CR_TERM_BUFFER_BYTES, pause the PTY (pty.pause()), resume when drained; if it stays saturated > 30 s close with 4429 (no scrollback sync while saturated). Inbound frames > 1 MiB are rejected. Scrollback is bounded (tmux history-limit 20000; xterm scrollback from user settings). - WAITING_FOR_INPUT is set only from Claude Code hooks (
Notification,Stop→ waiting;UserPromptSubmit→ running). The runtime installs/merges a hooks block into the runtime user's~/.claude/settings.json(idempotent, markedcr-hook) that runsapps/runtime/bin/cr-hook→ POST/hooks/claudeon the unix socket withCR_SESSION_ID. Hook handling must be a no-op (exit 0, fast) outside Claude Remote sessions. Terminal output is never parsed for state. The hook token (run/hook-token) is kept across a runtime restart whilecr-*sessions are alive (they carry it in their environment); it is regenerated only when no session survived. - RUNNING / DETACHED follow the attached clients: the larger of the runtime's count (tmux clients) and the terminal
streams the server is proxying itself. A browser attaching ⇒ RUNNING at once (server's own knowledge); after its
last stream closes the server asks the runtime (250 ms, again at 1.5 s) ⇒ DETACHED unless a client is attached
elsewhere. This bookkeeping never changes WAITING_FOR_INPUT (it only updates
attachedClients). - After host reboot the tmux server is gone: live DB sessions become RECOVERABLE; "restart" starts a fresh process
with
claude --resume <conversationId>when known (else--continue), in the same cwd. Never claim process memory survives.
Runtime IPC (unix socket; Authorization: Bearer $CR_RUNTIME_TOKEN; JSON; errors {error:{code,message}})
| Method | Path | Notes |
|---|---|---|
| GET | /health |
`{ok, version, tmux: bool, claude: {path, version |
| GET | /sessions |
RtSessionInfo[] |
| POST | /sessions |
body RtSessionSpec → RtSessionInfo (409 if exists; 429 if over CR_MAX_SESSIONS) |
| GET | /sessions/:id |
RtSessionInfo or 404 |
| DELETE | /sessions/:id |
?force=1 kills immediately; otherwise SIGHUP then kill after 5 s |
| GET | /sessions/:id/snapshot |
{text} last 200 lines (capture-pane -p), for explicit user-requested previews only |
| WS | /sessions/:id/attach?cols=&rows= |
binary PTY bytes both ways + text TermClientMsg/TermServerMsg. After ready: history replay, then the live screen (see Scrollback). Unknown session: error message then close 4404; dead pane: exit then 4410 |
| POST | /tasks |
RtTaskSpec → RtTaskInfo. Runs in detached tmux session crt-<uuid>: $SHELL -lc <command> with stdout+stderr → tasks/<id>/log, exit code → tasks/<id>/exit. host != local ⇒ ssh -o BatchMode=yes <host> -- <command> (host must be in CR_EXEC_HOSTS). Timeout enforced by the runtime. |
| GET | /tasks · /tasks/:id |
RtTaskInfo (state rebuilt from disk + tmux after a runtime restart; missing runner ⇒ LOST) |
| DELETE | /tasks/:id |
cancel (TERM process group, KILL after 10 s) |
| GET | /tasks/:id/log?offset=&limit= |
raw bytes, X-Log-Size header |
| WS | /tasks/:id/log/follow?offset= |
binary log bytes as they are appended; closes when task ends |
| POST | /hooks/claude |
{sessionId, event, conversationId?, message?} (called by cr-hook) |
| WS | /events |
text JSON RtEvent stream (session.exit, session.activity ≤ 1/5 s per session for output — at once when the attached-client count changes —, claude.hook, task.updated) |
| GET | /host/metrics |
{collectedAtMs, hostname, cpuPct, load[3], memUsedMb, memTotalMb, diskFreeGb, diskTotalGb, uptimeSec, sessions:[{id,cpuPct,rssMb}]} — real values from os, vm_stat, df, ps |
| POST | /fs/list |
{root, rel} → FileEntry[] (dirs first; never follows symlinks out of root) |
| POST | /fs/stat · /fs/read |
{root, rel} → FileContent; 413 if > 2 MiB, 415 if binary (NUL sniff) |
| POST | /fs/write |
{root, rel, content, baseSha256?, create?} → FileContent meta; 409 conflict if on-disk sha ≠ base; atomic (tmp + rename, same dir, preserve mode) |
| POST | /fs/op |
{root, ...FsOp} (mkdir / rename / delete; delete of root refused) |
| POST | /fs/upload?root=&rel=&overwrite= |
raw body stream, ≤ 200 MiB, atomic; existing destination ⇒ 409 exists unless overwrite=1 |
| GET | /fs/download?root=&rel= |
stream; dirs → 400 |
| POST | /fs/search |
`{root, rel?, query, mode: 'name' |
| POST | /git/status · /git/diff · /git/clone · /git/info |
{root,...}; diff {root, rel?, staged?} → {diff, truncated}; clone {root, rel, url} with GIT_TERMINAL_PROMPT=0, only https://, ssh://, git@host:path, or gitsrv: URLs, never options starting with - |
| GET | /claude/info |
`{path, version, authenticated |
| POST | /claude/conversations |
{cwd} → ClaudeConversation[] read from ~/.claude/projects/<encoded cwd>/*.jsonl (metadata only) |
| GET | /skills |
SkillInfo[] from ~/.claude/{skills,commands,agents} + project .claude/ when ?root= given |
| POST | /skills/op |
install-from-directory / remove / enable-disable (disable = move to ~/.claude/.disabled/); never executes skill code |
| GET | /mcp?root=&live=&refresh= |
McpServerInfo[] from the config files (env/header VALUES never returned). live=0 ⇒ configuration only (status unknown); otherwise live statuses from claude mcp list (cached 60 s; refresh=1 bypasses the cache) |
| POST | /mcp/add · /mcp/remove · /mcp/test |
via `claude mcp add |
All subprocesses are spawned with argv arrays (execFile/spawn, shell: false). The only place a user string is
interpreted by a shell is the task command and the terminal itself, which is the product's purpose and is authenticated.
Path safety (runtime, mandatory, unit-tested)
resolveInside(root, rel): reject NUL, absolute rel, and .. segments; root must itself be inside a configured
workspace root after realpath; resolve the deepest existing ancestor with realpath and require it to stay inside
realpath(root); for writes also lstat the final component and refuse to write through a symlink that points outside.
Browser API (server; all under /api/v1; JSON; errors use ApiError)
Rate limits (per minute): credential checks (/auth/login, /auth/setup, /auth/totp, passkey login options/verify)
share a strict per-IP bucket (20); GET /auth/status has its own per-IP bucket (240) and counts in the session's
bucket for an authenticated caller; GET /health has its own per-IP bucket (600); everything else 600 per session.
Auth: opaque session token in cookie __Host-cr_sid (HttpOnly, Secure, SameSite=Strict, Path=/), stored hashed (SHA-256)
in auth_sessions. Idle timeout 12 h, absolute 30 d. Mutating requests require X-CSRF-Token (per-session token from
GET /auth/status) and an Origin equal to CR_PUBLIC_ORIGIN. WebSocket upgrades require the cookie, a matching
Origin, and are re-authorised on every (re)connection; a refused upgrade is a plain HTTP answer, never a socket
(401/403, 404 unknown session or task, 410 session not running). Everything except /health, /auth/* login/setup endpoints and
static assets requires an authenticated session with a completed second factor.
| Area | Endpoints |
|---|---|
| health | GET /health (public: {ok, version} only) · GET /system/status → SystemStatus · GET /system/audit?limit=&before= |
| auth | GET /auth/status · POST /auth/setup (one-time token from $CR_DATA_DIR/setup-token) · POST /auth/login → LoginStep · POST /auth/totp · POST /auth/logout · POST /auth/password · passkeys: `POST /auth/passkey/register/options |
| projects | GET/POST /projects · GET/PATCH/DELETE /projects/:id (DELETE unregisters only; never deletes files) · GET /workspace/roots |
| sessions | GET /sessions?archived= · POST /sessions · GET/PATCH /sessions/:id · POST /sessions/:id/reconnect (re-sync state; restart if RECOVERABLE with {restart:true}) · POST /sessions/:id/terminate · GET /sessions/:id/snapshot · GET /projects/:id/conversations · WS /sessions/:id/stream?cols=&rows=. resumeConversationId must match ^[A-Za-z0-9][A-Za-z0-9-]{7,63}$ (it becomes claude --resume <id>: never option-like); the same rule filters conversation ids reported by hooks |
| files | GET /projects/:id/files?path= · GET /projects/:id/file?path= · PUT /projects/:id/file (WriteFile) · POST /projects/:id/fs (FsOp) · POST /projects/:id/upload?path=&overwrite=1 (409 exists without overwrite=1) · GET /projects/:id/download?path= · GET /projects/:id/search?q=&mode=&path= · GET /projects/:id/git/status · GET /projects/:id/git/diff?path=&staged=1 → {diff, truncated} |
| tasks | GET/POST /tasks · GET /tasks/:id · POST /tasks/:id/cancel · POST /tasks/:id/retry (only if idempotent) · GET /tasks/:id/log?offset= · WS /tasks/:id/log/stream · GET /tasks/:id/artifacts/:artifactId |
| cluster | GET /cluster/nodes → ClusterOverview · GET /cluster/nodes/:id · GET /cluster/metrics · GET /cluster/models → LocalModel[] · POST /cluster/models/:id/test |
| skills/mcp | GET /skills · POST /skills/op · GET /skills/registry/search?q= (adapter; returns {connected:false} until skills.spboucher.ai exposes an API) · GET /mcp (configuration only; ?live=1 adds live statuses, &refresh=1 bypasses the runtime cache) · POST /mcp · DELETE /mcp/:name · POST /mcp/:name/test |
| misc | GET /notifications · POST /notifications/read · GET/PUT /settings (UserSettings) · WS /events (ServerEvent) · GET /openapi.json |
Non-negotiables
Strict TypeScript, no any without justification · zod on every untrusted input · structured logs (pino) that never
contain secrets, cookies, terminal input or output · tests for every state machine and security boundary ·
no fake data anywhere in operational UI · never run claude with permission bypass · do not modify anything outside
this repository (in particular not ~/.claude of the development laptop: tests must point HOME/CLAUDE_CONFIG_DIR
at a temp dir whenever they would write Claude configuration) · do not start paid Claude conversations in tests
(claude --version and launching the interactive UI without sending a prompt are fine).