fix: address review report across security, architecture, quality, tests

Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:45 +02:00
parent 97d57326fd
commit d22dcd24f7
30 changed files with 2900 additions and 400 deletions

View File

@@ -18,7 +18,8 @@
* - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves).
* - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device
* mirror sharing); a new attach JOINS instead of kicking. Output/exit/status
* broadcast to all; the PTY size is the min across clients (tmux-style).
* broadcast to all; the PTY size is latest-writer-wins (the most recently
* focused/fitted device drives the shared size).
* - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
*
* Coding style: immutable-update preference, no console.log, errors explicit.
@@ -80,6 +81,17 @@ export function createSessionManager(cfg: Config): SessionManager {
// Otherwise: detached-then-exit (L1) — leave in table for reconnect replay.
}
/**
* DoS guard: refuse to spawn a brand-new session once the table is at the
* configured cap. Throwing here propagates to server.ts (M4 path), which
* sends exit(-1, reason) and closes the offending connection.
*/
function assertUnderSessionCap(): void {
if (sessions.size >= cfg.maxSessions) {
throw new Error(`session limit reached (max ${cfg.maxSessions})`);
}
}
function handleAttach(
ws: WebSocketLike,
sessionId: string | null,
@@ -89,6 +101,9 @@ export function createSessionManager(cfg: Config): SessionManager {
): Session {
// ── Case 1: null → always create a new session ──────────────────────────
if (sessionId === null) {
// DoS guard: cap concurrent sessions. Throwing reuses the M4 path —
// server.ts catches it and sends exit(-1, reason) + closes this connection.
assertUnderSessionCap();
// M4: createSession may throw (spawn failure). Do NOT catch here.
// M6: cwd (if given) is the spawn directory for "new tab here".
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
@@ -128,6 +143,8 @@ export function createSessionManager(cfg: Config): SessionManager {
}
// ── Case 4: session not found → create a new one ─────────────────────────
// DoS guard (see Case 1): cap concurrent sessions before spawning.
assertUnderSessionCap();
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
attachWs(session, ws);

View File

@@ -108,7 +108,6 @@ export function createSession(
meta,
buffer: createRingBuffer(cfg.scrollbackBytes),
clients: new Set(),
clientDims: new Map(),
detachedAt: null,
lastOutputAt: now,
exitedAt: null,
@@ -161,7 +160,6 @@ export function attachWs(session: Session, ws: WebSocketLike): void {
*/
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
session.clients.delete(ws);
session.clientDims.delete(ws);
// A client leaving does NOT resize the PTY — whatever device is still actively
// viewing keeps its size. Start the idle clock only when the last client goes.
if (session.clients.size === 0) {
@@ -175,36 +173,26 @@ export function writeInput(session: Session, data: string): void {
session.pty.write(data);
}
/**
* Record one client's requested dims and resize the PTY to the new min across
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
*/
/**
* Latest-writer-wins: the device that most recently fit/focused drives the PTY
* size, so whichever device you are actively using is full-screen. (A shared
* PTY can only be one size; min-sizing letterboxed the bigger screen.) The
* frontend re-sends dims when a pane is shown or its window regains focus, so
* switching devices reclaims full size. No-op after exit (L4) / when unchanged.
*
* The `ws` argument identifies the requesting client; the size is applied
* directly to the PTY (no per-client dims map — latest writer wins).
*/
export function setClientDims(
session: Session,
ws: WebSocketLike,
_ws: WebSocketLike,
cols: number,
rows: number,
): void {
if (session.exitedAt !== null) return;
session.clientDims.set(ws, { cols, rows });
applyDims(session, cols, rows);
}
/**
* Forget a client's size when its tab goes hidden — but do NOT resize: the
* device still actively viewing keeps its size (no letterboxing of a mirror).
*/
export function clearClientDims(session: Session, ws: WebSocketLike): void {
session.clientDims.delete(ws);
}
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux