SPB Git

Repository Public

CONTRACT.md 18.3 KB 188 lines markdown

1# Claude Remote — engineering contract (binding for every workstream)23Product: private, single-administrator, browser-accessible environment for the **real Claude Code CLI**4running on a MacLustr node (M5M36), at https://claude.spboucher.ai. Never simulate Claude or a terminal.5Never fabricate metrics: unknown/stale data is shown as UNKNOWN with its collection timestamp.67## Agent profiles (additive, 2026-11-10)89The same code base also runs as **Codex Remote** (OpenAI Codex CLI, https://codex.spboucher.ai): a second,10independent instance (own port, database, data dir, tmux socket). `CR_AGENT` = `claude` (default) | `codex`11selects the profile in the runtime and the server; the web build is shared and reads `branding` from12`GET /auth/status`. Everything below describes the Claude profile, which is unchanged when no new variable13is set; the differences of the Codex profile are in [codex-profile.md](codex-profile.md).1415- **Session kind — decision.** `SessionKind` stays `'claude' | 'shell'` on the wire and in the database16  (CHECK `kind IN ('claude','shell')`): `'claude'` means "the instance's agent". No `'codex'` kind and no17  migration: production rows stay valid and each instance has its own database.18- Additive DTO fields: `AuthStatus.branding {agent, productName, agentName}` (optional, public),19  `SystemStatus.claude.agent/agentName` and `GET /health` → `agent` (both optional: sent by a Codex20  instance only; absent ⇒ claude, so a Claude instance's payloads are unchanged). Runtime `GET /health` →21  `agent`, `agentHooks {requested, status, detail}`. `@cr/shared` exports `AgentId`, `AGENT_PROFILES`,22  `Branding`, `CONVERSATION_ID_RE`.23- New env: `CR_AGENT`, `CR_AGENT_BIN` (fallback `CR_CLAUDE_BIN` for claude), `CR_INSTALL_AGENT_HOOKS`24  (`CR_INSTALL_CLAUDE_HOOKS` stays an alias for claude), `CODEX_HOME` (optional). Defaults per profile:25  tmux socket and data dir `<claude|codex>-remote[-data]`.26- Never run `codex` with an approval/sandbox bypass or a `-c/--config` override; the runtime refuses them27  (400) and accepts only `[]`, `resume --last`, `resume <uuid>`.28- Profiles without a prompt-submit hook (Codex): the first terminal input from a browser after29  WAITING_FOR_INPUT returns the session to RUNNING. Never applied to Claude.3031## Processes (modular monolith + separate runtime)3233```34browser ──HTTPS/WSS──> Caddy (BHS64 tunnel) ──> apps/server  :8620   (Fastify; auth, REST, WS, static web, Postgres)35                                                   │  unix socket 0600 + bearer token (HTTP + WS)36                                                   ▼37                                               apps/runtime            (the ONLY process that spawns anything:38                                                   │                     tmux, node-pty, fs, git, ssh, claude)39                                                   ▼40                                  tmux server (socket `-L claude-remote`) → `claude` / login shell per session41```4243- `apps/web`: Next.js (App Router) **static export** (`output: 'export'`, `trailingSlash: true`, built with44  `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>`).45  Everything self-hosted (fonts, Monaco workers, icons): the CSP forbids third-party origins (`frame-src 'self' blob:`46  for the PDF preview only; `object-src 'none'`, `frame-ancestors 'none'`).47- `apps/server`: owns Postgres, authentication, authorization, audit, notifications, cluster adapter, OpenAPI.48  It never spawns processes and never touches project files directly — it asks the runtime.49- `apps/runtime`: owns tmux/PTY, tasks, filesystem + git operations, Claude introspection, host metrics.50  Listens ONLY on the unix socket `CR_RUNTIME_SOCKET` (mode 0600, parent dir 0700). No TCP listener.51- `packages/shared` (`@cr/shared`): zod schemas + types — states, browser DTOs, WS wire formats, runtime IPC types.52  **Read it first.** It is frozen: if you need a change, make it additive and list it in your final report.5354## Ownership (do not edit outside your area; do not change root config or packages/shared semantics)5556| Workstream | Directory |57|---|---|58| runtime-core (tmux, PTY attach, tasks, hooks, events, host metrics) | `apps/runtime/src/{main,config,auth,tmux,sessions,tasks,hooks,events,metrics}*` |59| 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` |60| server | `apps/server/**` |61| web | `apps/web/**` |6263## Configuration (env; see `.env.example`)6465`CR_PORT` (8620) · `CR_HOST` · `CR_PUBLIC_ORIGIN` (exact origin for cookies / Origin checks / WebAuthn RP ID) ·66`CR_DATA_DIR` (persistent, outside the deploy dir: `run/`, `tasks/<id>/{log,exit,meta.json}`, `artifacts/`, `uploads/`,67`backups/`, `setup-token`) · `CR_WORKSPACE_ROOTS` (comma list `label=/abs/path` or `/abs/path`; label defaults to basename) ·68`CR_RUNTIME_SOCKET` · `CR_RUNTIME_TOKEN` · `DATABASE_URL` · `CR_SECRET_KEY` (hex 32 bytes; encrypts TOTP secrets etc.69with AES-256-GCM) · `CR_AGENTD_URL`/`CR_AGENTD_TOKEN` · `CR_CLAUDE_BIN` · `CR_MAX_SESSIONS` (12) ·70`CR_EXEC_HOSTS` (comma allowlist of node aliases for remote tasks; empty = local only) ·71`CR_TERM_BUFFER_BYTES` (per-connection outbound high-water mark, default 4 MiB) ·72`CR_REPLAY_LINES` (runtime: history lines replayed to a browser on attach, default 5000, max 20000; a replay is73also capped at 2 MiB).7475## Terminal design7677- One tmux session per terminal session, named `cr-<uuid>`, on the dedicated tmux socket; created detached with78  `remain-on-exit on` (so exit code is observable), `history-limit` bounded, `status off`, `CR_SESSION_ID=<uuid>` in env,79  `TERM=xterm-256color`, started via the user's login shell so PATH matches an interactive terminal:80  claude → `$SHELL -lc 'exec claude <args>'`; shell → `$SHELL -l`.81- Never pass `--dangerously-skip-permissions` or any permission-bypass flag. Claude's native permission prompts are preserved.82- A browser connection = `node-pty` spawning `tmux -L claude-remote attach-session -t cr-<uuid>` sized to the client.83  Closing the WebSocket kills only that attach client. tmux `window-size latest`, `mouse off`.84  Reconnect = new attach: the browser resets its terminal, the runtime replays the history, tmux repaints the screen.85- **Scrollback** (`apps/runtime/src/core-scrollback.ts`). `tmux attach` only renders the visible screen and skips86  intermediate output, so it cannot feed a browser scrollback. The attach client therefore runs with87  `terminal-overrides xterm-256color:smcup@:rmcup@:csr@:indn@`: tmux paints in the browser's **normal** buffer (no88  alternate screen) and redraws instead of scrolling (it never writes into the browser scrollback). The runtime is the89  only writer of that scrollback, as a mirror of the pane history: on every attach, before the PTY starts, it sends90  the last `CR_REPLAY_LINES` history lines (`capture-pane -p -e -J`, history only, colours kept); while attached it91  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 makes92  tmux repaint (SIGWINCH to the attach client). When continuity cannot be proven (pane resized, history cleared or93  trimmed, > `CR_REPLAY_LINES` new lines) the scrollback is cleared (`CSI 3 J`) and replayed. A client whose size is94  not the window's (second browser of another size, not the one being typed in) is not touched until sizes agree.95  Full-screen programs inside the pane (their own alternate screen) render through tmux and add nothing to the scrollback.96- Backpressure: if the WS outbound buffer exceeds `CR_TERM_BUFFER_BYTES`, pause the PTY (`pty.pause()`), resume when97  drained; if it stays saturated > 30 s close with 4429 (no scrollback sync while saturated). Inbound frames > 1 MiB98  are rejected. Scrollback is bounded (tmux history-limit 20000; xterm scrollback from user settings).99- WAITING_FOR_INPUT is set **only** from Claude Code hooks (`Notification`, `Stop` → waiting; `UserPromptSubmit` → running).100  The runtime installs/merges a hooks block into the runtime user's `~/.claude/settings.json` (idempotent, marked101  `cr-hook`) that runs `apps/runtime/bin/cr-hook` → POST `/hooks/claude` on the unix socket with `CR_SESSION_ID`.102  Hook handling must be a no-op (exit 0, fast) outside Claude Remote sessions. Terminal output is never parsed for state.103  The hook token (`run/hook-token`) is kept across a runtime restart while `cr-*` sessions are alive (they carry it in104  their environment); it is regenerated only when no session survived.105- RUNNING / DETACHED follow the attached clients: the larger of the runtime's count (tmux clients) and the terminal106  streams the server is proxying itself. A browser attaching ⇒ RUNNING at once (server's own knowledge); after its107  last stream closes the server asks the runtime (250 ms, again at 1.5 s) ⇒ DETACHED unless a client is attached108  elsewhere. This bookkeeping never changes WAITING_FOR_INPUT (it only updates `attachedClients`).109- After host reboot the tmux server is gone: live DB sessions become RECOVERABLE; "restart" starts a fresh process110  with `claude --resume <conversationId>` when known (else `--continue`), in the same cwd. Never claim process memory survives.111112## Runtime IPC (unix socket; `Authorization: Bearer $CR_RUNTIME_TOKEN`; JSON; errors `{error:{code,message}}`)113114| Method | Path | Notes |115|---|---|---|116| GET | `/health` | `{ok, version, tmux: bool, claude: {path, version|null}, uptimeSec}` |117| GET | `/sessions` | `RtSessionInfo[]` |118| POST | `/sessions` | body `RtSessionSpec` → `RtSessionInfo` (409 if exists; 429 if over `CR_MAX_SESSIONS`) |119| GET | `/sessions/:id` | `RtSessionInfo` or 404 |120| DELETE | `/sessions/:id` | `?force=1` kills immediately; otherwise SIGHUP then kill after 5 s |121| GET | `/sessions/:id/snapshot` | `{text}` last 200 lines (`capture-pane -p`), for explicit user-requested previews only |122| 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 |123| 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. |124| GET | `/tasks` · `/tasks/:id` | `RtTaskInfo` (state rebuilt from disk + tmux after a runtime restart; missing runner ⇒ LOST) |125| DELETE | `/tasks/:id` | cancel (TERM process group, KILL after 10 s) |126| GET | `/tasks/:id/log?offset=&limit=` | raw bytes, `X-Log-Size` header |127| WS | `/tasks/:id/log/follow?offset=` | binary log bytes as they are appended; closes when task ends |128| POST | `/hooks/claude` | `{sessionId, event, conversationId?, message?}` (called by `cr-hook`) |129| 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) |130| 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` |131| POST | `/fs/list` | `{root, rel}` → `FileEntry[]` (dirs first; never follows symlinks out of root) |132| POST | `/fs/stat` · `/fs/read` | `{root, rel}` → `FileContent`; 413 if > 2 MiB, 415 if binary (NUL sniff) |133| POST | `/fs/write` | `{root, rel, content, baseSha256?, create?}` → `FileContent` meta; 409 `conflict` if on-disk sha ≠ base; atomic (tmp + rename, same dir, preserve mode) |134| POST | `/fs/op` | `{root, ...FsOp}` (mkdir / rename / delete; delete of root refused) |135| POST | `/fs/upload?root=&rel=&overwrite=` | raw body stream, ≤ 200 MiB, atomic; existing destination ⇒ 409 `exists` unless `overwrite=1` |136| GET | `/fs/download?root=&rel=` | stream; dirs → 400 |137| POST | `/fs/search` | `{root, rel?, query, mode: 'name'|'content', limit}` → `SearchHit[]` (ripgrep if present, else bounded walk; `rel` = sub-directory to search) |138| 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 `-` |139| GET | `/claude/info` | `{path, version, authenticated|null, configDir}` (uses `claude --version`, `claude auth status`) |140| POST | `/claude/conversations` | `{cwd}` → `ClaudeConversation[]` read from `~/.claude/projects/<encoded cwd>/*.jsonl` (metadata only) |141| GET | `/skills` | `SkillInfo[]` from `~/.claude/{skills,commands,agents}` + project `.claude/` when `?root=` given |142| POST | `/skills/op` | install-from-directory / remove / enable-disable (disable = move to `~/.claude/.disabled/`); never executes skill code |143| 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) |144| POST | `/mcp/add` · `/mcp/remove` · `/mcp/test` | via `claude mcp add|remove|get` with argv arrays (no shell interpolation) |145146All subprocesses are spawned with argv arrays (`execFile`/`spawn`, `shell: false`). The only place a user string is147interpreted by a shell is the *task command* and the terminal itself, which is the product's purpose and is authenticated.148149## Path safety (runtime, mandatory, unit-tested)150151`resolveInside(root, rel)`: reject NUL, absolute `rel`, and `..` segments; `root` must itself be inside a configured152workspace root after `realpath`; resolve the deepest existing ancestor with `realpath` and require it to stay inside153`realpath(root)`; for writes also `lstat` the final component and refuse to write through a symlink that points outside.154155## Browser API (server; all under `/api/v1`; JSON; errors use `ApiError`)156157Rate limits (per minute): credential checks (`/auth/login`, `/auth/setup`, `/auth/totp`, passkey login options/verify)158share a strict per-IP bucket (20); `GET /auth/status` has its own per-IP bucket (240) and counts in the session's159bucket for an authenticated caller; `GET /health` has its own per-IP bucket (600); everything else 600 per session.160161Auth: opaque session token in cookie `__Host-cr_sid` (HttpOnly, Secure, SameSite=Strict, Path=/), stored hashed (SHA-256)162in `auth_sessions`. Idle timeout 12 h, absolute 30 d. Mutating requests require `X-CSRF-Token` (per-session token from163`GET /auth/status`) **and** an `Origin` equal to `CR_PUBLIC_ORIGIN`. WebSocket upgrades require the cookie, a matching164`Origin`, and are re-authorised on every (re)connection; a refused upgrade is a plain HTTP answer, never a socket165(401/403, 404 unknown session or task, 410 session not running). Everything except `/health`, `/auth/*` login/setup endpoints and166static assets requires an authenticated session with a completed second factor.167168| Area | Endpoints |169|---|---|170| health | `GET /health` (public: `{ok, version}` only) · `GET /system/status` → `SystemStatus` · `GET /system/audit?limit=&before=` |171| 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|verify`, `POST /auth/passkey/login/options|verify` · TOTP: `POST /auth/totp/enroll/start|finish`, `DELETE /auth/totp` · `GET /auth/devices` · `DELETE /auth/devices/:id` |172| projects | `GET/POST /projects` · `GET/PATCH/DELETE /projects/:id` (DELETE unregisters only; never deletes files) · `GET /workspace/roots` |173| 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 |174| 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}` |175| 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` |176| cluster | `GET /cluster/nodes` → `ClusterOverview` · `GET /cluster/nodes/:id` · `GET /cluster/metrics` · `GET /cluster/models` → `LocalModel[]` · `POST /cluster/models/:id/test` |177| 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` |178| misc | `GET /notifications` · `POST /notifications/read` · `GET/PUT /settings` (`UserSettings`) · WS `/events` (`ServerEvent`) · `GET /openapi.json` |179180## Non-negotiables181182Strict TypeScript, no `any` without justification · zod on every untrusted input · structured logs (pino) that never183contain secrets, cookies, terminal input or output · tests for every state machine and security boundary ·184no fake data anywhere in operational UI · never run `claude` with permission bypass · do not modify anything outside185this repository (in particular not `~/.claude` of the development laptop: tests must point `HOME`/`CLAUDE_CONFIG_DIR`186at a temp dir whenever they would write Claude configuration) · do not start paid Claude conversations in tests187(`claude --version` and launching the interactive UI without sending a prompt are fine).188
Files main
Menu

Theme