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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user