Files
web-terminal/src/server.ts
Yaojia Wang 03612323c0 feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
2026-07-01 04:25:57 +02:00

878 lines
37 KiB
TypeScript

/**
* src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
*
* Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
* path → destroy; bad Origin → 401, L3); WS connection (first frame must be
* 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
* (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
* guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
*
* v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
* stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
* A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
* statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
* GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
* per-IP rate limits; loopback-only ingest keeps isLoopback.
*
* Style: immutable data, explicit error handling, no silent swallowing.
*/
import { createServer } from 'node:http'
import net from 'node:net'
import { URL } from 'node:url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import express from 'express'
import type { Response } from 'express'
import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'
import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor } from './http/editor.js'
import { getDiff } from './http/diff.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js'
import type {
Config,
PermissionGate,
PermissionMode,
PushSubscriptionRecord,
UiConfig,
} from './types.js'
import { WS_OPEN } from './types.js'
// ── defaults ──────────────────────────────────────────────────────────────────
const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24
// Width of the leaky-bucket window for per-connection WS rate limiting (10).
const RATE_WINDOW_MS = 1000
// Max chars of a user-controlled string written to a log line (M2 log injection).
const LOG_FIELD_MAX = 200
// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
// review #9). Fixed security policy — not user config.
const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
'default',
'acceptEdits',
'plan',
'auto',
])
/** Decision JSON a PermissionRequest hook writes to stdout (H3). `mode` (B4)
* carries the updated permission mode back to Claude on a plan-gate approval. */
function permDecision(behavior: 'allow' | 'deny', mode?: PermissionMode): unknown {
const decision: Record<string, unknown> = { behavior }
if (mode !== undefined) decision['mode'] = mode
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
}
/** Whitelist-validate an optional `approve.mode` from a raw WS frame (B4). The
* shared browser-safe protocol parser cannot surface it (must stay DOM-free),
* so the WS wiring layer validates it here against PermissionMode. Never throws. */
function parseApproveMode(raw: string): PermissionMode | undefined {
try {
const obj = JSON.parse(raw) as Record<string, unknown>
const m = obj?.['mode']
return typeof m === 'string' && PERMISSION_MODES.has(m) ? (m as PermissionMode) : undefined
} catch {
return undefined
}
}
/** Per-IP sliding-window limiter (in-memory). true (and records) when under the
* cap for the window, false when over. Immutable per-IP arrays (filter → new). */
function createRateLimiter(maxPerWindow: number, windowMs: number): (ip: string, now: number) => boolean {
const hits = new Map<string, number[]>()
return (ip, now): boolean => {
const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs)
if (recent.length >= maxPerWindow) {
hits.set(ip, recent)
return false
}
hits.set(ip, [...recent, now])
return true
}
}
/** Three-prong read-only diff path check (SEC-H7): absolute + dir + has .git. */
async function isValidGitDir(target: string): Promise<boolean> {
if (typeof target !== 'string' || !path.isAbsolute(target)) return false
try {
const s = await stat(target)
if (!s.isDirectory()) return false
await stat(path.join(target, '.git'))
return true
} catch {
return false
}
}
/** Build a PushSubscriptionRecord from an untrusted POST body, or null. */
function toSubscriptionRecord(body: Record<string, unknown>): PushSubscriptionRecord | null {
const endpoint = body['endpoint']
const keys = body['keys']
if (typeof endpoint !== 'string' || endpoint.length === 0) return null
if (keys === null || typeof keys !== 'object') return null
const k = keys as Record<string, unknown>
if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return null
return { endpoint, keys: { p256dh: k['p256dh'], auth: k['auth'] }, createdAt: Date.now() }
}
/**
* True for loopback peers (hooks always run on the host). Accepts the whole
* 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
* address behind a local proxy / alternate loopback alias still passes.
*/
function isLoopback(ip: string): boolean {
if (ip === '::1') return true
// Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
if (net.isIPv4(v4)) {
return v4.startsWith('127.')
}
return false
}
/** Strip control chars and truncate a user-controlled value before logging (M2). */
function sanitizeForLog(value: unknown): string {
return String(value)
.slice(0, LOG_FIELD_MAX)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f]/g, '?')
}
// ── helpers ───────────────────────────────────────────────────────────────────
/** Derive __dirname equivalent in ESM. */
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
/** Guard a ws.send so we never call send on a non-OPEN socket (M5). */
function safeSend(ws: WsWebSocket, data: string): void {
if (ws.readyState === WS_OPEN) {
ws.send(data)
}
}
// ── startServer ───────────────────────────────────────────────────────────────
/**
* Start the HTTP + WS server.
*
* Returns an object with a `close()` method that gracefully shuts down
* both the HTTP server and the session manager.
*/
export function startServer(cfg: Config): { close(): Promise<void> } {
// A1: persistent push-subscription store + VAPID-signed push sender, injected
// into the manager (DI, M5) so the manager never imports push-service directly.
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
const pushService = createPushService(cfg, subStore)
const manager = createSessionManager(cfg, pushService)
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
const prefsStore = loadPrefsStore(cfg.prefsStorePath)
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
// capability a remote device presents at /hook/decision (SEC-C1/M1); `gate`
// (B4) lets a late-joining client re-render the right affordance (M3).
interface PendingApproval {
res: Response
timer: ReturnType<typeof setTimeout>
token: string
expiresAt: number
gate: PermissionGate
}
const pendingApprovals = new Map<string, PendingApproval>()
function resolvePending(sessionId: string, decision: unknown): void {
const p = pendingApprovals.get(sessionId)
if (p === undefined) return
clearTimeout(p.timer)
pendingApprovals.delete(sessionId)
p.res.json(decision)
}
// ── Express static hosting ────────────────────────────────────────────────
const app = express()
// ── Security headers (Sec H2) ─────────────────────────────────────────────
// Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
// 'unsafe-inline' on style-src is required because xterm.js injects inline
// styles for the terminal viewport (verified: dropping it breaks rendering).
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
)
next()
})
// Serve the entire public/ directory (including public/build/ esbuild output).
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
// SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
// first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
// any LAN device. That is consistent with the app's threat model — this whole
// app hands a full shell to anyone who can reach the port (no auth, LAN-only,
// never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
app.get('/sessions', async (_req, res) => {
res.json(await listSessions(50))
})
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
// Any LAN device opening the app fetches this to show the host's sessions as tabs.
app.get('/live-sessions', (_req, res) => {
res.json(manager.list())
})
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
app.get('/projects', async (_req, res) => {
try {
res.json(await buildProjects(cfg, manager.list()))
} catch (err) {
console.error('[server] /projects failed:', err instanceof Error ? err.message : String(err))
res.json([])
}
})
// Projects UI prefs (v0.6) — favourites + group collapse-state, cross-device.
// GET is read-only (no secrets, just paths/booleans) → no Origin guard, like /projects.
app.get('/prefs', (_req, res) => {
res.json(prefsStore.get())
})
// PUT replaces the whole prefs blob. State-changing → Origin guard (CSRF, like
// /open-in-editor). Body is sanitized inside prefsStore.set (never trust it).
app.put('/prefs', express.json({ limit: '64kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
try {
prefsStore.set(req.body)
await prefsStore.persist()
res.json(prefsStore.get())
} catch (err) {
console.error('[server] PUT /prefs failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to save prefs' })
}
})
// Project detail (v0.6) — branch/worktrees + running sessions for one repo.
// Read-only (git branch/status/worktree-list); same threat model as /projects.
// ?path must be an absolute existing directory (validated in buildProjectDetail).
app.get('/projects/detail', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
try {
const detail = await buildProjectDetail(cfg, target, manager.list())
if (detail === null) {
res.status(404).json({ error: 'project not found' })
return
}
res.json(detail)
} catch (err) {
console.error('[server] /projects/detail failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read project detail' })
}
})
// Preview (v0.4 manage page) — the tail of a session's scrollback so the grid
// can render a live read-only thumbnail of its current screen. No client/attach.
app.get('/live-sessions/:id/preview', (req, res) => {
const session = manager.get(req.params.id)
if (session === undefined) {
res.status(404).end()
return
}
res.json({
id: session.meta.id,
cols: session.pty.cols,
rows: session.pty.rows,
data: session.buffer.tail(cfg.previewBytes),
})
})
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
// Origin whitelist; reject missing/foreign Origin with 403.
function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
const origin = req.headers['origin'] as string | undefined
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
res.status(403).end()
return false
}
return true
}
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
app.delete('/live-sessions', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const onlyDetached = req.query['detached'] === '1'
let killed = 0
for (const s of manager.list()) {
if (onlyDetached && s.clientCount > 0) continue
if (manager.killById(s.id)) killed += 1
}
res.json({ killed })
})
// Kill one session by id — manage page per-row action.
app.delete('/live-sessions/:id', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const ok = manager.killById(req.params.id)
res.status(ok ? 204 : 404).end()
})
// Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
// logo). State-changing (spawns a GUI process), so it carries the same Origin
// guard as the DELETE routes. The path is validated + passed via execFile (no
// shell) in openInEditor.
app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const body = (req.body ?? {}) as Record<string, unknown>
const result = await openInEditor(cfg, body['path'])
if (result.ok) {
res.status(204).end()
return
}
res.status(result.status).json({ error: result.error })
})
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
// Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
if (ev === null) {
res.status(400).end()
return
}
// A4: pass event class + tool name so the manager appends a sanitized
// timeline entry. Not a held gate → pending/gate stay undefined.
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool)
// A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE).
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
const session = manager.get(ev.sessionId)
if (session !== undefined) void pushService.notify(session, 'done')
}
res.status(204).end()
})
// H3/A1: PermissionRequest hook — held until the client approves/rejects. The
// hook's curl writes our response (the decision JSON) to stdout for Claude.
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const sessionId = req.header('x-webterm-session')
const body = (req.body ?? {}) as Record<string, unknown>
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
if (sessionId === undefined || session === undefined) {
res.json({}) // no such session → let Claude prompt itself
return
}
// C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
// approval); otherwise fall through to Claude's own prompt.
const hasWatcher = session.clients.size > 0
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
if (!hasWatcher && !hasPushTarget) {
res.json({})
return
}
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
resolvePending(sessionId, {}) // clear any stale hold for this session
const token = randomUUID()
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
const timer = setTimeout(() => {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
// A1: push the lock-screen approval (carrying the capability token) to every
// subscribed device. Best-effort — failures are logged inside push-service.
void pushService.notify(session, 'needs-input', token)
// Show the approve/reject affordance on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
})
// ── A1 push subscription + lock-screen decision routes ────────────────────
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
app.get('/push/vapid-key', (_req, res) => {
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
res.status(503).end()
return
}
res.json({ publicKey: cfg.vapidPublicKey })
})
// Store a browser PushSubscription (state-changing → Origin guard + rate limit).
app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const record = toSubscriptionRecord((req.body ?? {}) as Record<string, unknown>)
if (record === null) {
res.status(400).end()
return
}
try {
subStore.add(record)
await subStore.persist()
} catch {
res.status(400).end()
return
}
res.status(204).end()
})
// Remove a stored subscription (state-changing → Origin guard + rate limit).
app.delete('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const endpoint = (req.body ?? {}) as Record<string, unknown>
const value = endpoint['endpoint']
if (typeof value !== 'string' || value.length === 0) {
res.status(400).end()
return
}
subStore.remove(value)
await subStore.persist()
res.status(204).end()
})
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
// loopback) → guard with Origin + a per-decision capability token (stale/
// mismatch → 403).
app.post('/hook/decision', express.json({ limit: '4kb' }), (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!decisionLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const sessionId = typeof body['sessionId'] === 'string' ? body['sessionId'] : undefined
const decision = body['decision']
const token = typeof body['token'] === 'string' ? body['token'] : undefined
if (sessionId === undefined || token === undefined || (decision !== 'allow' && decision !== 'deny')) {
res.status(400).end()
return
}
const pending = pendingApprovals.get(sessionId)
if (pending === undefined || pending.token !== token || Date.now() > pending.expiresAt) {
res.status(403).end() // SEC-C1/M1: missing / mismatched / stale token
return
}
resolvePending(sessionId, permDecision(decision))
manager.handleHookEvent(sessionId, 'working', undefined, false)
res.status(204).end() // SW does not read the body (review #14)
})
// ── A4 timeline (read-only discovery, no Origin guard) ────────────────────
app.get('/live-sessions/:id/events', (req, res) => {
if (!cfg.timelineEnabled) {
res.json([]) // AC-A4.6: capture/serve disabled → empty
return
}
const session = manager.get(req.params.id)
if (session === undefined) {
res.status(404).end()
return
}
res.json(session.timeline)
})
// ── B1 read-only git diff (no Origin guard; same threat model as /projects) ─
app.get('/projects/diff', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
if (!(await isValidGitDir(target))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
try {
const staged = req.query['staged'] === '1'
res.json(await getDiff(target, { staged, cfg }))
} catch (err) {
console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read diff' })
}
})
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const telemetry = parseStatusLine(req.body)
if (telemetry === null) {
res.status(400).end()
return
}
const sessionId = req.header('x-webterm-session')
if (typeof sessionId === 'string' && sessionId.length > 0) {
manager.handleStatusLine(sessionId, telemetry)
}
res.status(204).end()
})
// ── B3 create a git worktree (the only write-to-disk feature → Origin guard) ─
app.post('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree creation is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const branch = typeof body['branch'] === 'string' ? body['branch'] : undefined
const base = typeof body['base'] === 'string' && body['base'] !== '' ? body['base'] : undefined
if (repoPath === undefined || branch === undefined) {
res.status(400).json({ error: 'path and branch are required' })
return
}
// Audit (B3.4): who/what, sanitized + truncated (never raw control chars).
console.error(`[server] worktree create: branch=${sanitizeForLog(branch)} path=${sanitizeForLog(repoPath)}`)
const result = await createWorktree(repoPath, branch, {
base,
worktreeRoot: cfg.worktreeRoot,
timeoutMs: cfg.worktreeTimeoutMs,
})
if (result.ok) {
res.status(200).json({ ok: true, path: result.path, branch: result.branch })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
res.json(uiConfig)
})
// ── HTTP server ───────────────────────────────────────────────────────────
const httpServer = createServer(app)
// ── WebSocket server (noServer: true — L3) ────────────────────────────────
// noServer prevents the wss from auto-attaching its own 'upgrade' listener,
// which would double-handle the socket before our Origin check runs.
const wss = new WebSocketServer({
noServer: true,
maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
})
// ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
const reapTimer = setInterval(() => {
const now = Date.now()
manager.reapIdle(now)
manager.sweepStuck(now)
}, cfg.reapIntervalMs)
// Don't let this timer prevent the process from exiting.
reapTimer.unref()
// ── HTTP upgrade (single entry point — ARCHITECTURE §3.6, L3) ────────────
httpServer.on('upgrade', (req: IncomingMessage, socket, head) => {
// Parse the path. Use a dummy base so URL() accepts a path-only string.
const { pathname } = new URL(req.url ?? '/', 'http://localhost')
// 1. Wrong path → destroy
if (pathname !== cfg.wsPath) {
socket.destroy()
return
}
// 2. Origin check (CSWSH defence, ARCHITECTURE §3.3 / TECH_DOC §7)
const origin = req.headers['origin'] as string | undefined
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
// 3. Hand off to wss — it completes the handshake and emits 'connection'.
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
// ── WS connection handler ─────────────────────────────────────────────────
wss.on('connection', (ws: WsWebSocket) => {
// State: session id bound after the first 'attach' frame.
let boundSessionId: string | null = null
// Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame
// SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
// CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
// legitimate burst); the warn log is throttled to once per window.
let windowStart = Date.now()
let msgsThisWindow = 0
let droppedThisWindow = 0
let warnedThisWindow = false
// Per-connection message handler.
const onMessage = (raw: Buffer | string): void => {
// ── Rate limit (leaky bucket over a 1s window) ────────────────────────
const now = Date.now()
if (now - windowStart >= RATE_WINDOW_MS) {
windowStart = now
msgsThisWindow = 0
droppedThisWindow = 0
warnedThisWindow = false
}
msgsThisWindow += 1
if (msgsThisWindow > cfg.maxMsgsPerSec) {
droppedThisWindow += 1
if (!warnedThisWindow) {
warnedThisWindow = true
console.error(
`[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
)
}
return
}
const text = typeof raw === 'string' ? raw : raw.toString('utf8')
const result = parseClientMessage(text)
if (!result.ok) {
// Discard invalid frames; log without noisy stack traces (avoid noise per spec).
console.error('[server] invalid client message discarded:', sanitizeForLog(result.error))
return
}
const msg = result.message
// ── First frame must be 'attach' ──────────────────────────────────────
if (boundSessionId === null) {
if (msg.type !== 'attach') {
// Protocol violation: first message was not attach. Ignore and wait.
console.error('[server] expected attach as first message, got:', msg.type)
return
}
const dims = {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
}
let session
try {
session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd)
} catch (err: unknown) {
// M4: spawn failure — send exit(-1) and close only this connection.
const reason =
err instanceof Error ? err.message : String(err)
safeSend(ws, serialize({ type: 'exit', code: -1, reason }))
ws.close()
return
}
boundSessionId = session.meta.id
// Confirm the attach to the client.
safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id }))
// M3 / AC-A1.3: a late-joining device immediately sees a still-held
// approval (the manager re-sent telemetry + status; the pending/gate
// portion lives here because the server owns pendingApprovals).
const heldApproval = pendingApprovals.get(boundSessionId)
if (heldApproval !== undefined) {
safeSend(
ws,
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
)
}
return
}
// ── Subsequent frames: route input / resize ───────────────────────────
const session = manager.get(boundSessionId)
if (session === undefined) {
// Session was reaped between messages — nothing to route to.
return
}
if (msg.type === 'input') {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
// Latest-writer-wins: this device's dims drive the shared PTY size.
setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'approve') {
// H3/B4: resolve the held PermissionRequest with allow, carrying the
// optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode
// to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it).
let mode = parseApproveMode(text)
if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default'
resolvePending(boundSessionId, permDecision('allow', mode))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'reject') {
resolvePending(boundSessionId, permDecision('deny'))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'attach') {
// Re-attach after first bind is not expected; log and ignore.
console.error('[server] unexpected re-attach on established connection')
}
}
ws.on('message', onMessage)
// ── WS close ─────────────────────────────────────────────────────────────
ws.on('close', () => {
if (boundSessionId === null) return
const session = manager.get(boundSessionId)
if (session === undefined) {
// Session already gone — make sure no held approval dangles.
resolvePending(boundSessionId, {})
return
}
// Invariant #2: never kill the PTY on WS close — detach THIS client only
// (the PTY stays alive for other devices and for reconnect).
detachWs(session, ws, Date.now())
// H3: only release a held approval when the LAST viewer leaves. With
// multi-device sharing (v0.4), another device may still be looking at the
// same pending approval — closing one mirror must not cancel it for them.
if (session.clients.size === 0) {
resolvePending(boundSessionId, {})
}
})
// ── WS error ─────────────────────────────────────────────────────────────
ws.on('error', (err: Error) => {
// Log and let the 'close' event (which always follows 'error') handle cleanup.
console.error('[server] ws error:', err.message)
})
})
// ── Graceful shutdown helper ──────────────────────────────────────────────
function doShutdown(): void {
clearInterval(reapTimer)
manager.shutdown()
}
// ── Signal handlers (SIGINT / SIGTERM) ────────────────────────────────────
const onSignal = (): void => {
console.error('[server] shutdown signal received — cleaning up')
doShutdown()
httpServer.close(() => process.exit(0))
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
}
// ── uncaughtException: log and exit for truly unexpected errors ───────────
// (spawn failures / send failures are handled above and must NOT reach here)
const onUncaught = (err: Error): void => {
console.error('[server] uncaughtException — exiting:', err)
doShutdown()
process.exit(1)
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
process.on('uncaughtException', onUncaught)
// Remove the process-level listeners this server registered, so repeated
// startServer()/close() cycles (tests, library use) don't leak handlers.
function removeProcessListeners(): void {
process.off('SIGINT', onSignal)
process.off('SIGTERM', onSignal)
process.off('uncaughtException', onUncaught)
}
// ── Start listening ───────────────────────────────────────────────────────
httpServer.listen(cfg.port, cfg.bindHost, () => {
console.error(
`[server] listening on http://${cfg.bindHost}:${cfg.port} WS → ${cfg.wsPath}`,
)
console.error('[server] note: run `npm run build:web` before connecting a browser')
})
// ── Return handle for tests / orchestrator ────────────────────────────────
return {
close(): Promise<void> {
return new Promise<void>((resolve) => {
removeProcessListeners()
doShutdown()
httpServer.close(() => resolve())
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the
// close callback can fire instead of waiting for the keep-alive timeout.
httpServer.closeIdleConnections()
})
},
}
}
// ── Entrypoint (npm start → tsx src/server.ts) ───────────────────────────────
// Detect whether this module is the main entry point in both CJS and ESM.
const isMain =
// ESM: import.meta.url matches the invoked file URL
process.argv[1] !== undefined &&
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
if (isMain) {
const cfg = loadConfig(process.env as Record<string, string | undefined>)
startServer(cfg)
}