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

@@ -16,6 +16,10 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
// Delay after the shell is ready before typing a session's initial command
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
const INITIAL_INPUT_DELAY_MS = 700
const RESET = '\x1b[0m'
const BOLD = '\x1b[1m'
const DIM = '\x1b[2m'
@@ -84,6 +88,7 @@ export class TerminalSession {
private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private resizeTimer: ReturnType<typeof setTimeout> | null = null
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
private exitListener: { dispose(): void } | null = null
private isConnecting = false
private disposed = false
@@ -232,7 +237,8 @@ export class TerminalSession {
})
socket.addEventListener('error', () => {
this.isConnecting = false
// Do NOT reset isConnecting here — the 'close' event always follows 'error'
// and owns the cleanup (resetting it twice could race a reconnect).
})
}
@@ -247,7 +253,11 @@ export class TerminalSession {
if (this.initialInput !== undefined && !this.initialSent) {
this.initialSent = true
const cmd = this.initialInput
setTimeout(() => this.send(cmd), 700)
this.initialInputTimer = setTimeout(() => {
this.initialInputTimer = null
if (this.disposed) return
this.send(cmd)
}, INITIAL_INPUT_DELAY_MS)
}
break
}
@@ -364,12 +374,9 @@ export class TerminalSession {
hide(): void {
this.el.style.display = 'none'
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
// shared PTY to this device's size. Output still streams (background mirror).
if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buildMessage({ type: 'blur' }))
}
// Force show() to re-cast our dims even if unchanged.
// v0.4 (latest-writer-wins): hiding does NOT resize the shared PTY — the
// device still actively viewing keeps its size. Output still streams
// (background mirror). Force show()/refit() to re-cast our dims on return.
this.lastCols = -1
this.lastRows = -1
}
@@ -385,6 +392,10 @@ export class TerminalSession {
clearTimeout(this.resizeTimer)
this.resizeTimer = null
}
if (this.initialInputTimer !== null) {
clearTimeout(this.initialInputTimer)
this.initialInputTimer = null
}
this.resizeObserver.disconnect()
if (this.ws !== null) {
try {