/** * src/server.ts (T14) — HTTP + WebSocket wiring layer. * * Responsibilities (ARCHITECTURE §3.6): * 1. Express static hosting of public/ (including public/build/ esbuild output). * 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade. * 3. HTTP 'upgrade' event (single entry point): * - Wrong path → socket.destroy() * - Origin not allowed → 401 + destroy() * - Passes → wss.handleUpgrade → wss.emit('connection') * 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached' * catch (spawn failure, M4) → send exit(-1) + close (only this connection). * 5. WS 'message': parse → ok:false discard+log; route input/resize. * 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2). * 7. SIGINT/SIGTERM → manager.shutdown() + http server close. * * M5: every ws.send guarded by readyState === WS_OPEN. * L3: noServer:true — no double upgrade handling. * L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options. * No hardcoded strings: wsPath comes from config (invariant 8). * * 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 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 } from './http/projects.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' import type { Config } 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 /** The decision JSON a PermissionRequest command hook writes to stdout. */ function permDecision(behavior: 'allow' | 'deny'): unknown { return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } } /** * 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 } { const manager = createSessionManager(cfg) // H3: held PermissionRequest responses, keyed by sessionId. The express `res` // is parked here until the client approves/rejects (or it times out). const pendingApprovals = new Map }>() 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([]) } }) // 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() }) // ── 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 ev = parseHookEvent(req.header('x-webterm-session'), req.body) if (ev === null) { res.status(400).end() return } manager.handleHookEvent(ev.sessionId, ev.status, ev.detail) res.status(204).end() }) // H3: 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 const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined // Nobody watching this tab (no session / no clients) → let Claude prompt itself. if (sessionId === undefined || session === undefined || session.clients.size === 0) { res.json({}) return } resolvePending(sessionId, {}) // clear any stale hold for this session 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 }) // Show the approve/reject affordance on the client. manager.handleHookEvent(sessionId, 'waiting', tool, true) }) // ── 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 ─────────────────────────────────────────────────────────── const reapTimer = setInterval(() => { manager.reapIdle(Date.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 })) 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: resolve the held PermissionRequest with allow. resolvePending(boundSessionId, permDecision('allow')) 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 { return new Promise((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) startServer(cfg) }