/** * 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 { readFileSync } from 'node:fs' import express from 'express' import type { NextFunction, Request, 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, SESSION_ID_RE } from './protocol.js' import { isOriginAllowed } from './http/origin.js' import { buildSetCookie, constantTimeEqual, cookieIsAuthed, isAuthEnabled, isHttpsRequest, } from './http/auth.js' import { parseHookEvent } from './http/hook.js' import { deriveApprovalPreview } from './http/approval-preview.js' import { listSessions } from './http/history.js' import { buildProjects, buildProjectDetail } from './http/projects.js' import { openInEditor, openFileInEditor } from './http/editor.js' import { getDiff, isPlausibleRev } from './http/diff.js' import { getGitLog } from './http/git-log.js' import { buildDigest, clampSince } from './http/digest.js' import { getPrStatus } from './http/gh.js' import { parseStatusLine } from './http/statusline.js' import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js' import { groupSessionsByRepo } from './http/session-groups.js' import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.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 { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js' import { initFcm, normalizeFcmToken } from './push/fcm.js' import type { ApprovalPreview, Config, NotifyService, 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 const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound) const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard) /** Loopback-only Claude Code hook ingest paths the auth gate lets through from a * loopback peer with no cookie (the token is about REMOTE access; hooks run on * the host). Each handler independently re-checks isLoopback (defense in depth). */ const LOOPBACK_INGEST_PATHS: ReadonlySet = new Set([ '/hook', '/hook/permission', '/hook/status', ]) /** The four permission modes the WS approve relay accepts (B4); else undefined. */ const PERMISSION_MODES: ReadonlySet = new Set([ '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 = { 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 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() 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 { 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): 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 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 } { // 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 webPushService = createPushService(cfg, subStore) // T-iOS-20: optional APNs sender — enabled only when the APNS_* env group is // complete (else null + one log line, never a crash). It fans out beside // web-push through the SAME NotifyService seam, so every existing hook-event // call site below stays untouched: `pushService` is the combined fan-out. const apns = initApns(cfg, process.env as Record) // A33: optional FCM sender (Android client) — enabled only when the FCM_* env // group is complete (else null + one log line, never a crash). It fans out // beside web-push and APNs through the SAME NotifyService seam. const fcm = initFcm(cfg, process.env as Record) const extraNotifyServices: NotifyService[] = [ ...(apns !== null ? [apns.service] : []), ...(fcm !== null ? [fcm.service] : []), ] const pushService: NotifyService = extraNotifyServices.length === 0 ? webPushService : combineNotifyServices(webPushService, ...extraNotifyServices) 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) const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token // W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook // schedules a debounced drain of one queue entry after cfg.queueSettleMs, so // the injected text lands once the prompt is ready. Cleared on shutdown; the // fired callback re-checks idle + a stable lastOutputAt cursor before draining. const drainTimers = new Map>() /** * W2: schedule (or reschedule) a single idle-drain for `sessionId`. Debounced * per session — each Stop clears any pending timer and restarts the window, so * flapping Stop events fire at most one drain. On fire we drain ONE entry only * if the session is still idle, unexited, and its lastOutputAt has not moved * since scheduling (no output mid-settle = Claude really finished, not * mid-render). One entry per idle → natural pacing (the injected prompt makes * Claude work again, and its next Stop drains the next entry). */ function scheduleDrain(sessionId: string): void { if (!cfg.queueEnabled) return const session = manager.get(sessionId) if (session === undefined || session.exitedAt !== null) return if (session.queue.length === 0) return const existing = drainTimers.get(sessionId) if (existing !== undefined) clearTimeout(existing) const outputCursor = session.lastOutputAt const timer = setTimeout(() => { drainTimers.delete(sessionId) const s = manager.get(sessionId) if (s === undefined || s.exitedAt !== null) return // Settle guard: new output since scheduling → Claude is still active; skip // (a later genuine Stop reschedules). Also require the idle status to hold. if (s.lastOutputAt !== outputCursor) return if (s.claudeStatus !== 'idle') return manager.drainOne(sessionId) }, cfg.queueSettleMs) // Don't let a pending drain keep the process alive; a stale fire is harmless. timer.unref() drainTimers.set(sessionId, timer) } // 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 token: string expiresAt: number gate: PermissionGate /** W1: bounded command/diff preview of the held tool, re-sent to late joiners * exactly like `gate`. Undefined for non-previewable tools (e.g. ExitPlanMode). */ preview?: ApprovalPreview } 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() }) const publicDir = path.join(__dirname, '..', 'public') // ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the // Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒ // every request falls straight through `next()` (LAN zero-config unchanged). // // HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws:// // the cookie/token travel in cleartext and are replayable by a LAN sniffer; // this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See // src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw"). // Startup-cached login page (served by GET /login BEFORE the gate, so it is // always reachable while unauthed). Fallback keeps the server alive if the // asset is missing rather than crashing at boot. const loginHtml = ((): string => { try { return readFileSync(path.join(publicDir, 'login.html'), 'utf8') } catch { return 'Sign in
' } })() /** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */ function acceptsHtml(req: Request): boolean { const accept = req.headers['accept'] return typeof accept === 'string' && accept.includes('text/html') } /** Redirect target for the `?token=` bootstrap: same path with `token` stripped * so no secret is left in browser history / Referer. */ function pathWithoutToken(req: Request): string { const u = new URL(req.originalUrl, 'http://localhost') u.searchParams.delete('token') const qs = u.searchParams.toString() return u.pathname + (qs ? `?${qs}` : '') } // GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the // CSP is `script-src 'self'`, so the toggle is a server-side class swap). app.get('/login', (req, res) => { const html = req.query['e'] === '1' ? loginHtml.replace('ERRSTATE', 'show-error') : loginHtml.replace('ERRSTATE', '') res.type('html').send(html) }) // POST /auth — always reachable, rate-limited. Accepts urlencoded (native form) // AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒ // 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429. app.post( '/auth', express.urlencoded({ extended: false, limit: '1kb' }), express.json({ limit: '1kb' }), (req, res) => { const ip = req.socket.remoteAddress ?? 'unknown' if (!authLimiter(ip, Date.now())) { res.status(429).end() return } // Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie. if (!isAuthEnabled(cfg)) { res.status(204).end() return } const body = (req.body ?? {}) as Record const candidate = typeof body['token'] === 'string' ? body['token'] : '' const isForm = acceptsHtml(req) if (constantTimeEqual(candidate, cfg.webtermToken)) { res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) })) if (isForm) res.redirect(302, '/') else res.status(204).end() return } if (isForm) res.redirect(302, '/login?e=1') else res.status(401).json({ error: 'invalid token' }) }, ) // The global auth gate. Runs after the security-headers middleware and BEFORE // express.static + every API route below, so ONE central policy point covers // the whole app (the origin.ts idiom). Allow-list, in order: function authGate(req: Request, res: Response, next: NextFunction): void { // 1. Disabled → everything open (zero-config LAN unchanged). if (!isAuthEnabled(cfg)) { next() return } // 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission, // /hook/status) has no cookie and must keep working — the token is about // REMOTE access, not the host's own hook side-channel. DEVIATION from the // plan's blanket `isLoopback → next()`: scoped to these three ingest paths // so the gate still protects every OTHER route even from a loopback peer // (a token-enabled deploy then also gates the local browser — strictly // more secure, and testable from a 127.0.0.1 harness). if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) { next() return } // 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie + // redirect to the same path with the token stripped, else → /login. if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') { const ip = req.socket.remoteAddress ?? 'unknown' if (!authLimiter(ip, Date.now())) { res.status(429).end() return } if (constantTimeEqual(req.query['token'], cfg.webtermToken)) { res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) })) res.redirect(302, pathWithoutToken(req)) } else { res.redirect(302, '/login') } return } // 4. Valid cookie → allow. if (cookieIsAuthed(cfg, req.headers['cookie'])) { next() return } // 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON. if (acceptsHtml(req)) res.redirect(302, '/login') else res.status(401).json({ error: 'authentication required' }) } app.use(authGate) // 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/. 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()) }) // ── W5 fan-out board: running sessions clustered by repo (read-only) ────── // Pure string grouping over manager.list() (no git/FS work); same threat model // as /live-sessions and /digest → no Origin guard. Registered BEFORE the // /live-sessions/:id routes so "grouped" is never captured as an :id. app.get('/live-sessions/grouped', (_req, res) => { res.json(groupSessionsByRepo(manager.list())) }) // ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ── // A pure read-side aggregate over manager.list() + in-memory telemetry/status; // no Origin guard (same threat model as /live-sessions). `?since=` is // the client's last-seen watermark (bad/absent → 0 = "everything is new"). app.get('/digest', (req, res) => { const since = clampSince(req.query['since']) res.json(buildDigest(manager.list(), since)) }) // 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), }) }) // ── W2 inject queue: enqueue a follow-up prompt fired on next idle ──────── // State-changing (causes shell input on idle) → Origin guard (CSRF) + per-IP // rate limit. Body: { text: string, appendEnter?: boolean }. Bytes are stored // and later injected VERBATIM (byte-shuttle) — bounded in size and count, never // parsed as a shell command. `text` + optional trailing \r is capped by // queueItemMaxBytes; the queue depth is capped by queueMaxItems. app.post('/live-sessions/:id/queue', express.json({ limit: '16kb' }), (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.queueEnabled) { res.status(503).json({ error: 'queue disabled' }) return } if (!queueLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).end() return } const id = req.params.id if (!SESSION_ID_RE.test(id)) { res.status(400).json({ error: 'invalid session id' }) return } const body = (req.body ?? {}) as Record const rawText = body['text'] if (typeof rawText !== 'string' || rawText.length === 0) { res.status(400).json({ error: 'text must be a non-empty string' }) return } // FE decides Enter (mirrors quick-reply's appendEnter): materialize the exact // bytes to inject here so the stored entry is byte-identical to a keystroke. const text = body['appendEnter'] === true ? rawText + '\r' : rawText if (Buffer.byteLength(text, 'utf8') > cfg.queueItemMaxBytes) { res.status(413).json({ error: 'text too large' }) return } const result = manager.enqueueFollowup(id, text) if (!result.ok) { // full → 409 (never silently drop); unknown/exited → 404. res.status(result.reason === 'full' ? 409 : 404).json({ error: result.reason }) return } res.json({ length: result.length }) }) // Read-only queue view (depth + the user's own queued text). No Origin guard — // same threat model as GET /live-sessions. app.get('/live-sessions/:id/queue', (req, res) => { const session = manager.get(req.params.id) if (session === undefined) { res.status(404).end() return } res.json({ length: session.queue.length, items: [...session.queue] }) }) // Cancel all pending entries (escape hatch). State-changing → Origin guard. app.delete('/live-sessions/:id/queue', (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!manager.clearQueue(req.params.id)) { res.status(404).end() return } res.json({ length: 0 }) }) // 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 / openFileInEditor. // // W1: two modes on one route. `file` present ⇒ open that FILE at `line` (a // clicked terminal path like `src/app.ts:42`); else the original directory mode // (`path`). Discriminated by body shape so the Projects panel is unchanged. app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return const body = (req.body ?? {}) as Record const result = body['file'] !== undefined ? await openFileInEditor(cfg, body['file'], body['line']) : 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 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') // W2: Claude just went idle — schedule a debounced drain of the next queued // follow-up (fires after the settle delay iff still idle + output stable). scheduleDrain(ev.sessionId) } 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 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 // A web-push subscription OR a registered APNs device token counts as a // reachable "walked-away" device (T-iOS-20). const hasPushTarget = (webPushService.isEnabled() && subStore.list().length > 0) || (apns !== null && apns.store.list().length > 0) || (fcm !== null && fcm.store.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' // W1: derive a bounded, sanitized command/diff preview from the untrusted // tool_input so the approve/reject bar shows WHAT will run (never blind). // Non-previewable tools / malformed input → undefined (name-only bar). const preview = deriveApprovalPreview(tool, body['tool_input']) ?? undefined 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, ...(preview !== undefined ? { preview } : {}), }) // 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 (+ W1 preview) on every attached client. manager.handleHookEvent(sessionId, 'waiting', tool, true, gate, undefined, undefined, preview) }) // ── 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 (!webPushService.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) 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 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() }) // ── T-iOS-20: APNs device-token registry (iOS client) ───────────────────── // Mounted only when the APNS_* env group is complete (disabled ⇒ 404). // Frozen wire shape: POST/DELETE /push/apns-token {token: 64–160 hex chars, // lowercase-normalized} → 204 idempotent upsert/remove; 400 invalid; Origin // guard 403; 5/min/IP 429; body ≤ 8kb — mirrors /push/subscribe above. if (apns !== null) { const apnsStore = apns.store const apnsTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) app.post('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).end() return } const token = normalizeApnsToken(((req.body ?? {}) as Record)['token']) if (token === null) { res.status(400).end() return } try { apnsStore.add({ token, createdAt: Date.now() }) await apnsStore.persist() } catch { res.status(400).end() return } res.status(204).end() }) app.delete('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).end() return } const token = normalizeApnsToken(((req.body ?? {}) as Record)['token']) if (token === null) { res.status(400).end() return } apnsStore.remove(token) // idempotent: unknown token still 204 await apnsStore.persist() res.status(204).end() }) } // ── A33: FCM device-token registry (Android client) ─────────────────────── // Mounted only when the FCM_* env group is complete (disabled ⇒ 404). Frozen // wire shape: POST/DELETE /push/fcm-token {token: loose base64url+':' string, // ≤ bound} → 204 idempotent upsert/remove; 400 invalid; Origin guard 403; // 5/min/IP 429; body ≤ 8kb — mirrors /push/apns-token above. if (fcm !== null) { const fcmStore = fcm.store const fcmTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) app.post('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).end() return } const token = normalizeFcmToken(((req.body ?? {}) as Record)['token']) if (token === null) { res.status(400).end() return } try { fcmStore.add({ token, createdAt: Date.now() }) await fcmStore.persist() } catch { res.status(400).end() return } res.status(204).end() }) app.delete('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).end() return } const token = normalizeFcmToken(((req.body ?? {}) as Record)['token']) if (token === null) { res.status(400).end() return } fcmStore.remove(token) // idempotent: unknown token still 204 await fcmStore.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 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 } // FR-B1.9: optional ?base= — diff HEAD against a base commit-ish. The // rev-parse allow-list (in getDiff) is the real defense; isPlausibleRev // rejects flag-injection/junk fast with a 400 before any git call. const rawBase = req.query['base'] const base = typeof rawBase === 'string' && rawBase !== '' ? rawBase : undefined if (base !== undefined && !isPlausibleRev(base)) { res.status(400).json({ error: 'invalid base revision' }) return } try { const staged = req.query['staged'] === '1' res.json(await getDiff(target, { staged, base, 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' }) } }) // ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ────── // Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff. // `?n=` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing → // 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200. app.get('/projects/log', 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 } const rawN = req.query['n'] const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined try { res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs })) } catch (err) { console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err)) res.status(500).json({ error: 'failed to read git log' }) } }) // ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─ // Out-of-band side-channel: spawns the host's `gh` CLI to read the current // branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes // a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this // route NEVER accepts or forwards a token, only triggers gh's own auth, and // GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every // degrade (gh missing / unauthed / no PR / disabled) lives in the response body // (PrStatus.availability), not the HTTP status, so the FE renders one chip. app.get('/projects/pr', 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 { res.json(await getPrStatus(target, cfg)) } catch (err) { console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err)) res.status(500).json({ error: 'failed to read PR status' }) } }) // ── 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 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.' }) }) // ── W4 remove a git worktree (destructive → Origin guard, same as create) ──── app.delete('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.worktreeEnabled) { res.status(403).json({ error: 'Worktree management is disabled.' }) return } const body = (req.body ?? {}) as Record const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined const worktreePath = typeof body['worktreePath'] === 'string' ? body['worktreePath'] : undefined const force = body['force'] === true if (repoPath === undefined || worktreePath === undefined) { res.status(400).json({ error: 'path and worktreePath are required' }) return } // Audit (destructive): who/what, sanitized + truncated (never raw control chars). console.error( `[server] worktree remove: path=${sanitizeForLog(repoPath)} worktree=${sanitizeForLog(worktreePath)} force=${force}`, ) const result = await removeWorktree(repoPath, worktreePath, { force, timeoutMs: cfg.worktreeTimeoutMs, }) if (result.ok) { res.status(200).json({ ok: true, path: result.path }) return } res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to remove the worktree.' }) }) // ── W4 prune stale worktrees (destructive → Origin guard) ──────────────────── app.post('/projects/worktree/prune', express.json({ limit: '4kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.worktreeEnabled) { res.status(403).json({ error: 'Worktree management is disabled.' }) return } const body = (req.body ?? {}) as Record const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined if (repoPath === undefined) { res.status(400).json({ error: 'path is required' }) return } console.error(`[server] worktree prune: path=${sanitizeForLog(repoPath)}`) const result = await pruneWorktrees(repoPath, { timeoutMs: cfg.worktreeTimeoutMs }) if (result.ok) { res.status(200).json({ ok: true, pruned: result.pruned ?? [] }) return } res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' }) }) // ── W4 git write: stage / commit / push (the HIGHEST-risk channel) ─────────── // Every route: requireAllowedOrigin (CSRF — this is a WRITE channel, unlike the // read-only /projects/diff) → gitOpsEnabled kill-switch (403) → per-IP rate // limit (429) → isValidGitDir three-prong (404). The delegates in git-ops.ts // re-validate and realpath-CONTAIN every untrusted path/message (defense in // depth) and return SAFE, classified errors only (never raw git stderr). // Stage / unstage specific files. `stage` (default true) → git add; false → // git restore --staged. `files[]` is capped + realpath-contained in git-ops. app.post('/projects/git/stage', express.json({ limit: '64kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.gitOpsEnabled) { res.status(403).json({ error: 'Git operations are disabled.' }) return } if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).json({ error: 'Too many requests.' }) return } const body = (req.body ?? {}) as Record const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined const files = Array.isArray(body['files']) ? (body['files'] as unknown[]) : undefined const stage = body['stage'] === undefined ? true : body['stage'] === true if (repoPath === undefined || files === undefined) { res.status(400).json({ error: 'path and files are required' }) return } if (!(await isValidGitDir(repoPath))) { res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong return } const result = await stageFiles(repoPath, files, stage, { timeoutMs: cfg.gitOpsTimeoutMs, maxFiles: cfg.diffMaxFiles, }) if (result.ok) { res.status(200).json({ ok: true, staged: result.staged, count: result.count }) return } res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' }) }) // Commit the STAGED changes. Message is length-capped + non-empty checked and // passed as a single `-m ` argv (never a shell string, never a pathspec). app.post('/projects/git/commit', express.json({ limit: '16kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.gitOpsEnabled) { res.status(403).json({ error: 'Git operations are disabled.' }) return } if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).json({ error: 'Too many requests.' }) return } const body = (req.body ?? {}) as Record const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined const message = typeof body['message'] === 'string' ? body['message'] : '' if (repoPath === undefined) { res.status(400).json({ error: 'path is required' }) return } if (!(await isValidGitDir(repoPath))) { res.status(404).json({ error: 'project not found' }) return } // Audit (write): who/what, sanitized + truncated (never raw control chars). console.error(`[server] git commit: path=${sanitizeForLog(repoPath)}`) const result = await gitCommit(repoPath, message, { timeoutMs: cfg.gitOpsTimeoutMs, maxLen: cfg.commitMsgMaxLen, }) if (result.ok) { res.status(200).json({ ok: true, commit: result.commit }) return } res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' }) }) // Push the CURRENT branch to its existing upstream, or `-u // ` if none. Remote/branch are derived server-side; never a force-push. // Tighter per-IP rate limit than stage/commit (network-bound). app.post('/projects/git/push', express.json({ limit: '4kb' }), async (req, res) => { if (!requireAllowedOrigin(req, res)) return if (!cfg.gitOpsEnabled) { res.status(403).json({ error: 'Git operations are disabled.' }) return } if (!gitPushLimiter(req.socket.remoteAddress ?? '', Date.now())) { res.status(429).json({ error: 'Too many requests.' }) return } const body = (req.body ?? {}) as Record const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined if (repoPath === undefined) { res.status(400).json({ error: 'path is required' }) return } if (!(await isValidGitDir(repoPath))) { res.status(404).json({ error: 'project not found' }) return } console.error(`[server] git push: path=${sanitizeForLog(repoPath)}`) const result = await gitPush(repoPath, { timeoutMs: cfg.gitPushTimeoutMs }) if (result.ok) { res.status(200).json({ ok: true, branch: result.branch, remote: result.remote }) return } res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' }) }) // ── GET /config/ui (review #4) — client-readable UI config (read-only) ──── app.get('/config/ui', (_req, res) => { const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode, // W3(b): expose the cost budget only when set (>0) so the FE can derive // cost-overage warn styling; a non-secret number, safe over /config/ui. ...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}), // W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES. maxFanoutLanes: cfg.maxFanoutLanes, } 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 } // 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin // check (never weakens it) and BEFORE the handshake. The browser // auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no // frontend change is needed. Disabled (no token) → this is a no-op. if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) { 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) { // W1: re-send the bounded preview alongside gate so a late-joining // device sees WHAT is held, not just that something is. JSON.stringify // drops `preview` when undefined (non-previewable tools). safeSend( ws, serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate, ...(heldApproval.preview !== undefined ? { preview: heldApproval.preview } : {}), }), ) } 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) // W2: cancel any pending idle-drain timers so they can't fire post-shutdown. for (const t of drainTimers.values()) clearTimeout(t) drainTimers.clear() 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) }