Files
web-terminal/src/server.ts
Yaojia Wang 4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00

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