Files
web-terminal/public/terminal-session.ts
Yaojia Wang d22dcd24f7 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
2026-06-20 18:27:45 +02:00

412 lines
14 KiB
TypeScript

/**
* public/terminal-session.ts — one independent terminal session.
*
* Encapsulates a single xterm Terminal + its own WebSocket + reconnect state.
* One per tab (1 WS ↔ 1 server session; the backend manager already supports
* many concurrent sessions, so tabs need no protocol/backend change).
*
* No console.log — status is written as ANSI to the terminal.
*/
import { Terminal } from '@xterm/xterm'
import type { ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
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'
const RED = '\x1b[31m'
const CYAN = '\x1b[36m'
function statusLine(msg: string): string {
return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n`
}
function buildMessage(msg: ClientMessage): string {
return JSON.stringify(msg)
}
/** M6: scheme follows page protocol so HTTPS/Tailscale deploys use wss. */
function buildWsUrl(): string {
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
return `${scheme}://${location.host}/term`
}
/** Connection state, surfaced as a colored status dot on the tab. */
export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited'
export interface TerminalSessionOpts {
/** Existing session to re-attach to, or null to spawn a fresh one. */
sessionId: string | null
/** Called whenever the server assigns/confirms this session's id (persist it). */
onSessionId: (id: string) => void
/** Optional: fired on output to an inactive tab (for an unread indicator). */
onActivity?: () => void
/** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */
onTitle?: (title: string) => void
/** Optional: fired when connection status changes (for the tab status dot). */
onStatus?: (status: SessionStatus) => void
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */
initialInput?: string
}
export class TerminalSession {
readonly el: HTMLDivElement
private readonly term: Terminal
private readonly fitAddon: FitAddon
private readonly searchAddon: SearchAddon
private readonly resizeObserver: ResizeObserver
private readonly onSessionId: (id: string) => void
private readonly onActivity: (() => void) | undefined
private readonly onTitle: ((title: string) => void) | undefined
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
private initialSent = false
private statusValue: SessionStatus = 'connecting'
private claudeStatusValue: ClaudeStatus = 'unknown'
private cwdValue: string | null = null
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private ws: WebSocket | null = null
private sessionId: string | null
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
private lastCols = 0
private lastRows = 0
constructor(opts: TerminalSessionOpts) {
this.sessionId = opts.sessionId
this.onSessionId = opts.onSessionId
this.onActivity = opts.onActivity
this.onTitle = opts.onTitle
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.el.className = 'term-pane'
this.el.style.display = 'none'
this.term = new Terminal({
scrollback: 5000,
fontFamily: 'Menlo, Consolas, monospace',
theme: { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#7c8cff' },
})
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
this.searchAddon = new SearchAddon()
this.term.loadAddon(this.searchAddon)
this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it
this.term.open(this.el)
this.term.onData((data) => this.send(data))
// Tab title = current folder, derived from the shell's OSC title.
this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title))
// OSC 7 reports the full cwd (M6) — used for "new tab here".
this.term.parser.registerOscHandler(7, (payload) => {
const c = cwdFromOsc7(payload)
if (c !== null) this.cwdValue = c
return true
})
this.resizeObserver = new ResizeObserver(() => this.scheduleResize())
this.resizeObserver.observe(this.el)
}
/** Current session id (null until the server assigns one). */
get id(): string | null {
return this.sessionId
}
/** Current connection status (for the tab status dot). */
get status(): SessionStatus {
return this.statusValue
}
/** Current Claude Code activity (from hooks, H2). */
get claudeStatus(): ClaudeStatus {
return this.claudeStatusValue
}
/** Current working directory reported by the shell via OSC 7 (M6), or null. */
get cwd(): string | null {
return this.cwdValue
}
/** Whether a tool approval is held server-side and can be resolved (H3). */
get pendingApproval(): boolean {
return this.pendingApprovalValue
}
get pendingTool(): string | undefined {
return this.pendingToolValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
}
/** fit() only when the pane has real dimensions (display:none → NaN, §9). */
private safefit(): { cols: number; rows: number } | null {
try {
this.fitAddon.fit()
const { cols, rows } = this.term
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
return null
}
return { cols, rows }
} catch {
return null
}
}
private sendResize(cols: number, rows: number): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
if (cols === this.lastCols && rows === this.lastRows) return
this.lastCols = cols
this.lastRows = rows
this.ws.send(buildMessage({ type: 'resize', cols, rows }))
}
private scheduleResize(): void {
if (this.el.style.display === 'none') return // hidden tab: skip (fit→NaN)
if (this.resizeTimer !== null) clearTimeout(this.resizeTimer)
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
}, 100)
}
connect(): void {
if (this.disposed || this.isConnecting) return
this.isConnecting = true
this.setStatus('connecting') // connection state shows on the tab dot, not in the terminal
const socket = new WebSocket(buildWsUrl())
this.ws = socket
socket.addEventListener('open', () => {
this.reconnectDelay = 1000
this.setStatus('connected') // tab dot turns green; no in-terminal "Connected" line
// Send cwd only on a fresh session (M6 "new tab here"); on reconnect the
// sessionId is already set and the server ignores cwd.
const attachMsg: ClientMessage =
this.sessionId === null && this.spawnCwd !== undefined
? { type: 'attach', sessionId: null, cwd: this.spawnCwd }
: { type: 'attach', sessionId: this.sessionId }
socket.send(buildMessage(attachMsg))
})
socket.addEventListener('message', (event: MessageEvent<string>) => {
let msg: ServerMessage
try {
msg = JSON.parse(event.data) as ServerMessage
} catch {
return
}
this.handle(msg, socket)
})
socket.addEventListener('close', () => {
this.isConnecting = false
this.ws = null
if (!this.disposed) this.scheduleReconnect()
})
socket.addEventListener('error', () => {
// Do NOT reset isConnecting here — the 'close' event always follows 'error'
// and owns the cleanup (resetting it twice could race a reconnect).
})
}
private handle(msg: ServerMessage, socket: WebSocket): void {
switch (msg.type) {
case 'attached': {
this.sessionId = msg.sessionId
this.onSessionId(msg.sessionId)
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
// O2: once the shell is up, type the resume command (only on first attach).
if (this.initialInput !== undefined && !this.initialSent) {
this.initialSent = true
const cmd = this.initialInput
this.initialInputTimer = setTimeout(() => {
this.initialInputTimer = null
if (this.disposed) return
this.send(cmd)
}, INITIAL_INPUT_DELAY_MS)
}
break
}
case 'output': {
this.term.write(msg.data)
if (this.el.style.display === 'none') this.onActivity?.()
break
}
case 'status': {
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''
this.term.write(
statusLine(
`${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` +
`\r\n${DIM}Press Enter to reconnect…${RESET}`,
),
)
if (this.exitListener) this.exitListener.dispose()
this.exitListener = this.term.onData((data) => {
if (data === '\r') {
this.exitListener?.dispose()
this.exitListener = null
this.sessionId = null // spawn a fresh session on reconnect
socket.close()
this.connect()
}
})
break
}
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return
this.setStatus('reconnecting') // amber tab dot signals it; no in-terminal line
const delay = this.reconnectDelay
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
private sendMsg(msg: ClientMessage): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage(msg))
}
/** Send raw bytes as input (used by term.onData and the key bar). */
send(data: string): void {
this.sendMsg({ type: 'input', data })
}
/** Resolve a held PermissionRequest (H3). */
approve(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
}
reject(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'reject' })
}
/** Scrollback search (M1). */
findNext(query: string): void {
this.searchAddon.findNext(query)
}
findPrevious(query: string): void {
this.searchAddon.findPrevious(query)
}
clearSearch(): void {
this.searchAddon.clearDecorations()
}
/** Apply a theme + font size (M3) and re-fit if visible. */
applyTheme(theme: ITheme, fontSize: number): void {
this.term.options.theme = theme
this.term.options.fontSize = fontSize
if (this.el.style.display !== 'none') {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
}
}
/** Make this pane visible, fit it, and focus it. */
show(): void {
this.el.style.display = 'block'
requestAnimationFrame(() => {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
this.term.focus()
})
}
/**
* Re-fit and FORCE-resend dims (latest-writer-wins): when this device regains
* focus, re-assert its size so the shared PTY snaps to this screen even if
* another device had resized it. No-op while hidden.
*/
refit(): void {
if (this.el.style.display === 'none') return
this.lastCols = -1 // force sendResize to fire even if our dims are unchanged
this.lastRows = -1
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
}
hide(): void {
this.el.style.display = 'none'
// 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
}
/** Tear down: closing the WS detaches — the server PTY keeps running. */
dispose(): void {
this.disposed = true
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.resizeTimer !== null) {
clearTimeout(this.resizeTimer)
this.resizeTimer = null
}
if (this.initialInputTimer !== null) {
clearTimeout(this.initialInputTimer)
this.initialInputTimer = null
}
this.resizeObserver.disconnect()
if (this.ws !== null) {
try {
this.ws.close()
} catch {
// ignore
}
this.ws = null
}
this.term.dispose()
this.el.remove()
}
}