SPB Git

Repository Public

CONTRACT.md 18.3 KB

# 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. SessionKind stays 'claude' | 'shell' on the wire and in the database (CHECK kind 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/agentName and GET /health → agent (both optional: sent by a Codex instance only; absent ⇒ claude, so a Claude instance's payloads are unchanged). Runtime GET /health → agent, agentHooks {requested, status, detail}. @cr/shared exports AgentId, AGENT_PROFILES, Branding, CONVERSATION_ID_RE.
  • New env: CR_AGENT, CR_AGENT_BIN (fallback CR_CLAUDE_BIN for claude), CR_INSTALL_AGENT_HOOKS (CR_INSTALL_CLAUDE_HOOKS stays an alias for claude), CODEX_HOME (optional). Defaults per profile: tmux socket and data dir <claude|codex>-remote[-data].
  • Never run codex with an approval/sandbox bypass or a -c/--config override; 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)

text
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 session
  • apps/web: Next.js (App Router) static export (output: 'export', trailingSlash: true, built with next 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 socket CR_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 with remain-on-exit on (so exit code is observable), history-limit bounded, 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-permissions or any permission-bypass flag. Claude's native permission prompts are preserved.
  • A browser connection = node-pty spawning tmux -L claude-remote attach-session -t cr-<uuid> sized to the client. Closing the WebSocket kills only that attach client. tmux window-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 attach only renders the visible screen and skips intermediate output, so it cannot feed a browser scrollback. The attach client therefore runs with terminal-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 last CR_REPLAY_LINES history 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_LINES new 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, marked cr-hook) that runs apps/runtime/bin/cr-hook → POST /hooks/claude on the unix socket with CR_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 while cr-* 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).

Files main
Menu

Theme