Files
web-terminal/src/server.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

1396 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, SESSION_ID_RE } from './protocol.js'
import { isOriginAllowed } from './http/origin.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)
/** 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 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<string, string | undefined>)
// 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<string, string | undefined>)
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
// 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<string, ReturnType<typeof setTimeout>>()
/**
* 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<typeof setTimeout>
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<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())
})
// ── 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=<epochMs>` 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<string, unknown>
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<string, unknown>
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<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')
// 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<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
// 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<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()
})
// ── 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: 64160 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<string, unknown>)['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<string, unknown>)['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<string, unknown>)['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<string, unknown>)['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<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
}
// FR-B1.9: optional ?base=<rev> — 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=<int>` 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<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.' })
})
// ── 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<string, unknown>
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<string, unknown>
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<string, unknown>
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 <msg>` 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<string, unknown>
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 <sole-remote>
// <branch>` 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<string, unknown>
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
}
// 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<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)
}