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.
1396 lines
60 KiB
TypeScript
1396 lines
60 KiB
TypeScript
/**
|
||
* src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
|
||
*
|
||
* Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
|
||
* path → destroy; bad Origin → 401, L3); WS connection (first frame must be
|
||
* 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
|
||
* (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
|
||
* guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
|
||
*
|
||
* v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
|
||
* stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
|
||
* A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
|
||
* statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
|
||
* GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
|
||
* per-IP rate limits; loopback-only ingest keeps isLoopback.
|
||
*
|
||
* Style: immutable data, explicit error handling, no silent swallowing.
|
||
*/
|
||
|
||
import { createServer } from 'node:http'
|
||
import net from 'node:net'
|
||
import { URL } from 'node:url'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { stat } from 'node:fs/promises'
|
||
|
||
import express from 'express'
|
||
import type { Response } from 'express'
|
||
import { WebSocketServer } from 'ws'
|
||
import type { WebSocket as WsWebSocket } from 'ws'
|
||
import type { IncomingMessage } from 'node:http'
|
||
|
||
import { loadConfig } from './config.js'
|
||
import { parseClientMessage, serialize, 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: 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<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)
|
||
}
|