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

@@ -8,86 +8,30 @@
*
* Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do
* NOT open a WS / attach, so they don't inflate watcher counts or keep sessions
* alive.
* alive. The card + preview plumbing is shared with the launcher via
* public/preview-grid.ts (DRY).
*/
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
interface LiveSession {
id: string
createdAt: number
clientCount: number
status: 'working' | 'waiting' | 'idle' | 'unknown'
exited: boolean
cwd: string | null
cols: number
rows: number
}
interface Preview {
id: string
cols: number
rows: number
data: string
}
import type { LiveSessionInfo } from '../src/types.js'
import {
el,
relTime,
makePreviewCard,
updatePreviewCard,
loadPreviewInto,
fitThumb,
fetchLiveSessions,
type PreviewCard,
} from './preview-grid.js'
const REFRESH_MS = 4000
const THUMB_W = 360 // card thumbnail width in px
const THUMB_MAX_H = 220
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail
const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
interface Card {
el: HTMLElement
term: Terminal
inner: HTMLElement
thumb: HTMLElement
meta: HTMLElement
watch: HTMLElement
status: HTMLElement
}
const cards = new Map<string, Card>()
const cards = new Map<string, PreviewCard>()
let busy = false
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
function statusText(s: LiveSession['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
function name(s: LiveSession): string {
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
}
async function getJSON<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url)
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
async function killOne(id: string): Promise<void> {
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
void render()
@@ -100,73 +44,20 @@ async function killBulk(detachedOnly: boolean): Promise<void> {
void render()
}
/** Scale the rendered xterm down so the full screen fits the thumbnail width. */
function fitThumb(card: Card): void {
const w = card.inner.offsetWidth
const h = card.inner.offsetHeight
if (w === 0 || h === 0) return
const scale = Math.min(1, THUMB_W / w)
card.inner.style.transform = `scale(${scale})`
card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px`
}
function makeCard(s: LiveSession): Card {
const el0 = el('div', 'mg-card')
const head = el('div', 'mg-card-head')
const title = el('span', 'mg-name', name(s))
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
head.append(title, status, watch)
const thumb = el('div', 'mg-thumb')
const inner = el('div', 'mg-thumb-inner')
thumb.append(inner)
// Click the preview to open the session full.
thumb.addEventListener('click', () => {
location.href = `/?join=${s.id}`
})
const term = new Terminal({
cols: Math.max(2, s.cols),
rows: Math.max(2, s.rows),
disableStdin: true,
cursorBlink: false,
fontFamily: 'Menlo, Consolas, monospace',
fontSize: 12,
scrollback: 0,
theme: PREVIEW_THEME,
})
term.open(inner)
const meta = el('div', 'mg-meta')
const actions = el('div', 'mg-actions')
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
open.href = `/?join=${s.id}`
function makeCard(s: LiveSessionInfo): PreviewCard {
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.addEventListener('click', () => void killOne(s.id))
actions.append(open, kill)
el0.append(head, thumb, meta, actions)
return { el: el0, term, inner, thumb, meta, watch, status }
return makePreviewCard(s, {
onOpen: (id) => {
location.href = `/?join=${id}`
},
openHref: (id) => `/?join=${id}`,
extraActions: () => [kill],
})
}
async function refreshPreview(id: string, card: Card): Promise<void> {
const p = await getJSON<Preview>(`/live-sessions/${id}/preview`)
if (!p) return
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
}
card.term.reset()
card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card)))
}
function updateCard(card: Card, s: LiveSession): void {
card.status.className = `mg-status mg-${s.status}`
card.status.textContent = statusText(s.status)
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
card.watch.textContent = `👁 ${s.clientCount}`
function updateCard(card: PreviewCard, s: LiveSessionInfo): void {
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`
}
@@ -204,7 +95,7 @@ async function render(): Promise<void> {
busy = true
ensureChrome()
const sessions = (await getJSON<LiveSession[]>('/live-sessions')) ?? []
const sessions = await fetchLiveSessions()
if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host`
const seen = new Set<string>()
@@ -217,7 +108,7 @@ async function render(): Promise<void> {
grid!.append(card.el)
}
updateCard(card, s)
void refreshPreview(s.id, card)
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
// Drop cards for sessions that are gone.
for (const [id, card] of cards) {
@@ -240,5 +131,5 @@ void render()
setInterval(() => void render(), REFRESH_MS)
// Re-scale thumbnails if the window resizes.
window.addEventListener('resize', () => {
for (const card of cards.values()) fitThumb(card)
for (const card of cards.values()) fitThumb(card, THUMB_W, THUMB_MAX_H)
})