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:
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
153
src/server.ts
153
src/server.ts
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
14
src/types.ts
14
src/types.ts
@@ -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) ─────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user