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

@@ -26,10 +26,16 @@ const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
const DEFAULT_MAX_SESSIONS = 50
const DEFAULT_MAX_MSGS_PER_SEC = 2000
const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min
const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
// ── helpers ───────────────────────────────────────────────────────────────────
function parsePositiveInt(
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
function parseNonNegativeInt(
raw: string | undefined,
label: string,
fallback: number,
@@ -77,6 +83,16 @@ function parseIdleTtl(raw: string | undefined): number {
* - Deduplicate.
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
*/
/** True iff `value` parses as a URL with an http: or https: scheme (M1). */
function isHttpOrigin(value: string): boolean {
try {
const u = new URL(value)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
const set = new Set<string>()
@@ -104,11 +120,14 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado
}
}
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings).
// Reject anything that is not a well-formed http(s) origin so an exotic
// scheme (file:, data:, javascript:) can't slip into the whitelist (M1).
if (extraEnv) {
for (const raw of extraEnv.split(',')) {
const trimmed = raw.trim()
if (trimmed) set.add(trimmed)
if (trimmed === '') continue
if (isHttpOrigin(trimmed)) set.add(trimmed)
}
}
@@ -136,13 +155,13 @@ export function loadConfig(env: EnvLike): Config {
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
const scrollbackBytes = parsePositiveInt(
const scrollbackBytes = parseNonNegativeInt(
env['SCROLLBACK_BYTES'],
'SCROLLBACK_BYTES',
DEFAULT_SCROLLBACK_BYTES,
)
const maxPayloadBytes = parsePositiveInt(
const maxPayloadBytes = parseNonNegativeInt(
env['MAX_PAYLOAD_BYTES'],
'MAX_PAYLOAD_BYTES',
DEFAULT_MAX_PAYLOAD_BYTES,
@@ -150,6 +169,28 @@ export function loadConfig(env: EnvLike): Config {
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS)
const maxMsgsPerSec = parseNonNegativeInt(
env['MAX_MSGS_PER_SEC'],
'MAX_MSGS_PER_SEC',
DEFAULT_MAX_MSGS_PER_SEC,
)
const permTimeoutMs = parseNonNegativeInt(
env['PERM_TIMEOUT_MS'],
'PERM_TIMEOUT_MS',
DEFAULT_PERM_TIMEOUT_MS,
)
const reapIntervalMs = parseNonNegativeInt(
env['REAP_INTERVAL_MS'],
'REAP_INTERVAL_MS',
DEFAULT_REAP_INTERVAL_MS,
)
const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES)
const useTmux = resolveUseTmux(env['USE_TMUX'])
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
@@ -163,6 +204,11 @@ export function loadConfig(env: EnvLike): Config {
scrollbackBytes,
maxPayloadBytes,
wsPath,
maxSessions,
maxMsgsPerSec,
permTimeoutMs,
reapIntervalMs,
previewBytes,
useTmux,
allowedOrigins,
} satisfies Config)

View File

@@ -6,7 +6,7 @@
* can `claude --resume <id>` in the right place). Read-only; best-effort.
*/
import fs from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -61,27 +61,27 @@ export function parseSessionMeta(jsonlText: string): { cwd: string | null; previ
return { cwd, preview: preview.replace(/\s+/g, ' ').trim().slice(0, 120) };
}
function readHead(file: string, max: number): string {
/** Read up to `max` bytes from the head of a file (async, best-effort). */
async function readHead(file: string, max: number): Promise<string> {
let fh: fs.FileHandle | undefined;
try {
const fd = fs.openSync(file, 'r');
try {
const buf = Buffer.alloc(max);
const n = fs.readSync(fd, buf, 0, max, 0);
return buf.subarray(0, n).toString('utf8');
} finally {
fs.closeSync(fd);
}
fh = await fs.open(file, 'r');
const buf = Buffer.alloc(max);
const { bytesRead } = await fh.read(buf, 0, max, 0);
return buf.subarray(0, bytesRead).toString('utf8');
} catch {
return '';
} finally {
await fh?.close().catch(() => undefined);
}
}
/** Most-recently-modified Claude Code sessions across all projects. */
export function listSessions(limit = 50): HistorySession[] {
/** Most-recently-modified Claude Code sessions across all projects (async). */
export async function listSessions(limit = 50): Promise<HistorySession[]> {
const root = path.join(os.homedir(), '.claude', 'projects');
let dirs: string[];
try {
dirs = fs.readdirSync(root);
dirs = await fs.readdir(root);
} catch {
return [];
}
@@ -91,14 +91,14 @@ export function listSessions(limit = 50): HistorySession[] {
const dirPath = path.join(root, dir);
let names: string[];
try {
names = fs.readdirSync(dirPath);
names = await fs.readdir(dirPath);
} catch {
continue;
}
for (const name of names) {
if (!name.endsWith('.jsonl')) continue;
try {
const st = fs.statSync(path.join(dirPath, name));
const st = await fs.stat(path.join(dirPath, name));
files.push({
id: name.slice(0, -'.jsonl'.length),
file: path.join(dirPath, name),
@@ -112,10 +112,13 @@ export function listSessions(limit = 50): HistorySession[] {
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
return files.slice(0, limit).map((f) => {
const meta = parseSessionMeta(readHead(f.file, 256 * 1024));
const cwd = meta.cwd ?? '';
const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown';
return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview };
});
const top = files.slice(0, limit);
return Promise.all(
top.map(async (f) => {
const meta = parseSessionMeta(await readHead(f.file, 256 * 1024));
const cwd = meta.cwd ?? '';
const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown';
return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview };
}),
);
}

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'blur', 'approve', 'reject'])
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
// ─── parseClientMessage ───────────────────────────────────────────────────────
@@ -73,11 +73,6 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj)
}
// blur (v0.4) carries no payload — withdraw this client's size vote.
if (type === 'blur') {
return { ok: true, message: { type: 'blur' } }
}
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }
@@ -139,6 +134,11 @@ function validateAttach(obj: Record<string, unknown>): ParseResult {
}
// Optional cwd (M6): must be an absolute path string if present.
// NOTE (Sec L2): the only check here is a leading '/'. Any further path
// normalisation (e.g. path.resolve) stays on the backend (session.ts), because
// this module is shared with the browser bundle and must NOT import node:path.
// Within the current threat model this is informational — the caller already
// has a full shell, so a crafted cwd grants nothing beyond what they can type.
const rawCwd = obj['cwd']
let cwd: string | undefined
if (rawCwd !== undefined) {

View File

@@ -23,6 +23,7 @@
*/
import { createServer } from 'node:http'
import net from 'node:net'
import { URL } from 'node:url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -39,7 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import type { Config } from './types.js'
import { WS_OPEN } from './types.js'
@@ -48,22 +49,38 @@ import { WS_OPEN } from './types.js'
const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24
// How many bytes of recent scrollback the manage-page preview renders (enough to
// contain a full-screen TUI repaint, so the thumbnail shows the current screen).
const PREVIEW_BYTES = 24 * 1024
// Width of the leaky-bucket window for per-connection WS rate limiting (10).
const RATE_WINDOW_MS = 1000
// H3: how long the server holds a PermissionRequest before falling back to
// Claude's own interactive prompt (so it never hangs if nobody responds).
const PERM_TIMEOUT_MS = 5 * 60_000
// Max chars of a user-controlled string written to a log line (M2 log injection).
const LOG_FIELD_MAX = 200
/** The decision JSON a PermissionRequest command hook writes to stdout. */
function permDecision(behavior: 'allow' | 'deny'): unknown {
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
}
/** True for loopback peers (hooks always run on the host). */
/**
* True for loopback peers (hooks always run on the host). Accepts the whole
* 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
* address behind a local proxy / alternate loopback alias still passes.
*/
function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
if (ip === '::1') return true
// Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
if (net.isIPv4(v4)) {
return v4.startsWith('127.')
}
return false
}
/** Strip control chars and truncate a user-controlled value before logging (M2). */
function sanitizeForLog(value: unknown): string {
return String(value)
.slice(0, LOG_FIELD_MAX)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f]/g, '?')
}
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -105,14 +122,34 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// ── Express static hosting ────────────────────────────────────────────────
const app = express()
// ── Security headers (Sec H2) ─────────────────────────────────────────────
// Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
// 'unsafe-inline' on style-src is required because xterm.js injects inline
// styles for the terminal viewport (verified: dropping it breaks rendering).
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
)
next()
})
// Serve the entire public/ directory (including public/build/ esbuild output).
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
app.get('/sessions', (_req, res) => {
res.json(listSessions(50))
// SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
// first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
// any LAN device. That is consistent with the app's threat model — this whole
// app hands a full shell to anyone who can reach the port (no auth, LAN-only,
// never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
app.get('/sessions', async (_req, res) => {
res.json(await listSessions(50))
})
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
@@ -133,12 +170,26 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
id: session.meta.id,
cols: session.pty.cols,
rows: session.pty.rows,
data: session.buffer.tail(PREVIEW_BYTES),
data: session.buffer.tail(cfg.previewBytes),
})
})
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
// Origin whitelist; reject missing/foreign Origin with 403.
function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
const origin = req.headers['origin'] as string | undefined
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
res.status(403).end()
return false
}
return true
}
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
app.delete('/live-sessions', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const onlyDetached = req.query['detached'] === '1'
let killed = 0
for (const s of manager.list()) {
@@ -150,6 +201,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Kill one session by id — manage page per-row action.
app.delete('/live-sessions/:id', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const ok = manager.killById(req.params.id)
res.status(ok ? 204 : 404).end()
})
@@ -194,7 +246,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, PERM_TIMEOUT_MS)
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer })
// Show the approve/reject affordance on the client.
@@ -213,10 +265,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
// ── Idle reaper ───────────────────────────────────────────────────────────
const REAP_INTERVAL_MS = 60_000 // check every minute
const reapTimer = setInterval(() => {
manager.reapIdle(Date.now())
}, REAP_INTERVAL_MS)
}, cfg.reapIntervalMs)
// Don't let this timer prevent the process from exiting.
reapTimer.unref()
@@ -250,14 +301,43 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// State: session id bound after the first 'attach' frame.
let boundSessionId: string | null = null
// Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame
// SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
// CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
// legitimate burst); the warn log is throttled to once per window.
let windowStart = Date.now()
let msgsThisWindow = 0
let droppedThisWindow = 0
let warnedThisWindow = false
// Per-connection message handler.
const onMessage = (raw: Buffer | string): void => {
// ── Rate limit (leaky bucket over a 1s window) ────────────────────────
const now = Date.now()
if (now - windowStart >= RATE_WINDOW_MS) {
windowStart = now
msgsThisWindow = 0
droppedThisWindow = 0
warnedThisWindow = false
}
msgsThisWindow += 1
if (msgsThisWindow > cfg.maxMsgsPerSec) {
droppedThisWindow += 1
if (!warnedThisWindow) {
warnedThisWindow = true
console.error(
`[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
)
}
return
}
const text = typeof raw === 'string' ? raw : raw.toString('utf8')
const result = parseClientMessage(text)
if (!result.ok) {
// Discard invalid frames; log without noisy stack traces (avoid noise per spec).
console.error('[server] invalid client message discarded:', result.error)
console.error('[server] invalid client message discarded:', sanitizeForLog(result.error))
return
}
@@ -305,11 +385,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
if (msg.type === 'input') {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
// Per-client dims; the PTY tracks the min across actively-viewing devices.
// Latest-writer-wins: this device's dims drive the shared PTY size.
setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'blur') {
// Tab hidden on this device — withdraw its size vote (still mirrors output).
clearClientDims(session, ws)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
@@ -329,15 +406,23 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
ws.on('close', () => {
if (boundSessionId === null) return
// H3: release any held approval so the hook isn't stuck for the full timeout.
resolvePending(boundSessionId, {})
const session = manager.get(boundSessionId)
if (session === undefined) return
if (session === undefined) {
// Session already gone — make sure no held approval dangles.
resolvePending(boundSessionId, {})
return
}
// Invariant #2: never kill the PTY on WS close — detach THIS client only
// (the PTY stays alive for other devices and for reconnect).
detachWs(session, ws, Date.now())
// H3: only release a held approval when the LAST viewer leaves. With
// multi-device sharing (v0.4), another device may still be looking at the
// same pending approval — closing one mirror must not cancel it for them.
if (session.clients.size === 0) {
resolvePending(boundSessionId, {})
}
})
// ── WS error ─────────────────────────────────────────────────────────────
@@ -361,16 +446,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
// ── uncaughtException: log and exit for truly unexpected errors ───────────
// (spawn failures / send failures are handled above and must NOT reach here)
process.on('uncaughtException', (err: Error) => {
const onUncaught = (err: Error): void => {
console.error('[server] uncaughtException — exiting:', err)
doShutdown()
process.exit(1)
})
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
process.on('uncaughtException', onUncaught)
// Remove the process-level listeners this server registered, so repeated
// startServer()/close() cycles (tests, library use) don't leak handlers.
function removeProcessListeners(): void {
process.off('SIGINT', onSignal)
process.off('SIGTERM', onSignal)
process.off('uncaughtException', onUncaught)
}
// ── Start listening ───────────────────────────────────────────────────────
httpServer.listen(cfg.port, cfg.bindHost, () => {
@@ -384,6 +478,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
return {
close(): Promise<void> {
return new Promise<void>((resolve) => {
removeProcessListeners()
doShutdown()
httpServer.close(() => resolve())
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the

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

View File

@@ -27,6 +27,11 @@ export interface Config {
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
readonly maxSessions: number; // cap on concurrent PTY sessions (DoS guard), default 50
readonly maxMsgsPerSec: number; // per-connection WS message rate cap (DoS guard), default 2000
readonly permTimeoutMs: number; // H3: how long a held PermissionRequest waits before fallback
readonly reapIntervalMs: number; // idle-reaper sweep interval
readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
}
@@ -45,9 +50,6 @@ export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }
// v0.4: this client stopped actively viewing (tab hidden) — withdraw its size
// vote so a background mirror doesn't clamp the shared PTY (min-dims).
| { type: 'blur' }
| { type: 'approve' }
| { type: 'reject' };
@@ -150,9 +152,6 @@ export interface Session {
* detached but PTY still alive (vibe-coding core). Output/exit/status are
* broadcast to every client; any client can send input (shared control). */
readonly clients: Set<WebSocketLike>;
/** Per-client requested terminal dims; the PTY uses the MIN cols/rows across
* all clients (tmux-style) so every device sees content without overflow. */
readonly clientDims: Map<WebSocketLike, Dims>;
/** Time the LAST client left (clients became empty); null while ≥1 attached. */
detachedAt: number | null;
/** last pty.onData timestamp; reapIdle liveness proxy (M3). */
@@ -176,8 +175,7 @@ export interface Session {
// attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes)
// detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
// writeInput(session: Session, data: string): void // no-op after exit (L4)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit)
// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = latest-writer-wins (L4 no-op after exit)
// kill(session: Session): void
/* ──────────────────────── manager (§3.5) ─────────────────────── */