@cr/server
Fastify server of Claude Remote: authentication, REST + WebSocket API under /api/v1, static web
app, Postgres. It never spawns processes and never touches project files: everything goes to
apps/runtime over its unix socket. See docs/CONTRACT.md (binding) for the API table.
Environment
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development | production enforces the four requirements marked * |
CR_PORT / CR_HOST |
8620 / 127.0.0.1 | |
CR_PUBLIC_ORIGIN * |
http://localhost:8620 | exact origin; https in production. Cookie scope, Origin check, WebAuthn RP ID (= hostname) |
DATABASE_URL * |
postgres://localhost/claude_remote_dev | |
CR_SECRET_KEY * |
dev: generated into $CR_DATA_DIR/dev-secret-key |
64 hex chars; AES-256-GCM key for TOTP secrets |
CR_RUNTIME_TOKEN * |
– | ≥ 32 chars, same value as the runtime |
CR_RUNTIME_SOCKET |
$CR_DATA_DIR/run/runtime.sock |
|
CR_DATA_DIR |
~/claude-remote-data |
holds setup-token (0600) until the administrator exists |
CR_WORKSPACE_ROOTS |
~/workspace |
label=/abs or /abs, comma separated; must match the runtime |
CR_TRUSTED_PROXIES |
127.0.0.1,::1,10.67.0.1 |
IPs/CIDRs whose X-Forwarded-For is honoured (tunnel gateway) |
CR_EXEC_HOSTS |
empty | node aliases allowed as task host (besides local) |
CR_TERM_BUFFER_BYTES |
4 MiB | WebSocket backpressure high-water mark |
CR_AGENTD_URL / CR_AGENTD_TOKEN |
– | cluster telemetry; unset ⇒ host-only overview, health UNKNOWN |
CR_NODE_ALIAS |
runtime hostname (short) | alias of this host in agentd (isHost, edges) |
CR_LOCAL_MODELS |
– | JSON array {id,name,host,engine,endpoint}; unset ⇒ empty list |
CR_SKILLS_REGISTRY_URL |
– | e.g. https://skills.spboucher.ai; unset ⇒ {connected:false} |
CR_WEB_DIR |
../web/out |
static export; missing ⇒ API only |
CR_LOG_LEVEL |
info | pino JSON |
Runtime integration notes
- Runtime errors with status 400/403/404/409/413/415/422/429/507 are forwarded to the browser with
their
code,messageanddetails(e.g.PUT /projects/:id/file→ 409conflictwithdetails: {sha256, mtimeMs}of the file on disk; upload → 409exists). PUT /projects/:id/fileanswers a fullFileContent(the server addscontent).POST /projects/:id/upload?path=&overwrite=1— withoutoverwrite=1an existing destination is a 409exists; parent directories are created.GET /projects/:id/git/diff?path=&staged=1→{diff, truncated};GET /projects/:id/searchacceptspath=.GET /mcplists the configuration only (statusunknown, fast).?live=1adds live statuses (claude mcp list, up to 45 s cold, cached 60 s by the runtime);&refresh=1bypasses the cache.POST /skills/op:install={projectId, path, replace?}(copied into the user's skills);enable|disable|remove={name, kind, scope, projectId?}(projectIdrequired for project scope).session.exitwithout an exit code (tmux session vanished) is resolved against the runtime: gone ⇒ RECOVERABLE (STOPPING ⇒ STOPPED), exactly like the reconciler.
Run
pnpm --filter @cr/shared build && pnpm --filter @cr/server build
DATABASE_URL=… node dist/migrate.js # applies migrations/*.sql (idempotent, advisory-locked)
node dist/main.js # also migrates on startFirst run: read the one-time token on the host (cat $CR_DATA_DIR/setup-token; it is never
logged), open the site, create the administrator, then enrol TOTP or a passkey. A second factor
is mandatory: until one exists the session can only reach the enrolment endpoints.
Security model in one page
- Route auth levels (
config.auth, defaultfull):public(GET /health only),preauth(auth/status, setup, login, passkey login),restricted(valid session, second factor pending: status, logout, TOTP step, enrolment),full. One globalonRequestguard (src/security/guard.ts) enforces them, also for WebSocket upgrades.http.test.tsiterates the live route table and fails if a route is reachable unauthenticated without being listed. - Mutations:
X-CSRF-Token(fromGET /auth/status) +Origin=CR_PUBLIC_ORIGIN. Pre-auth POSTs: Origin +application/json. - Sessions: random 32-byte cookie token (
__Host-cr_sid;cr_sidonly outside production on http://localhost), sha256 stored, idle 12 h, absolute 30 d, 15 min before the second factor, rotated on upgrade. - Request rate limits (per minute,
config.rateLimit, route optionconfig.rate): credential checks (/auth/login,/auth/setup,/auth/totp, passkey login options/verify) share a strict per-IP bucket (20); anonymousGET /auth/statushas its own per-IP bucket (240) and counts in the caller's session bucket when the cookie is a valid session;GET /healthhas its own per-IP bucket (600); everything else is per session cookie (600).rate-limit.test.ts. - Throttling in
login_attempts: user 5, IP 10, global 100 failures / 15 min; lock 60 s doubling per extra failure (cap 15 min). A correct password alone never clears the counter. - WebAuthn challenges are server-side and single-use (5 min): in
auth_sessions.pendingwith a session, otherwise inpreauth_challengeskeyed by the hash of the__Host-cr_precookie. - Lockout responses: HTTP 429,
Retry-After, body{error:{code:'locked',details:{retryAfterSec}}, ok:false, next:'done', retryAfterSec}. - Not implemented in this milestone: Web Push (setting stored only), installing from the skills registry (501).
Tests
npx vitest run # needs local Postgres with createdb/dropdb on PATHEach test file creates its own database cr_test_<random>, migrates it and drops it afterwards;
src/integration/real-runtime.test.ts starts the REAL runtime (child process, own tmux socket,
needs tmux and claude on PATH; never sends a prompt, never touches ~/.claude); for the other files
the runtime is src/testkit/fake-runtime.ts (Fastify on a temp unix socket: echo PTY attach,
/events, in-memory files). src/testkit/soft-authenticator.ts is a software passkey (ES256,
real signatures) used to exercise WebAuthn verification.