/** * T15 — Integration / E2E tests for src/server.ts (startServer). * * Drives the real HTTP + WS server (no mocks) via a true `ws` WebSocket client. * Config is built with loadConfig so all paths are code-real. * * ── Sandbox-runnable tests (must all PASS in CI / sandbox) ───────────────── * ① Bad Origin → WS handshake rejected with 401 * ② Wrong path (not cfg.wsPath) → socket destroyed (connection refused/closed) * ③ Oversized WS frame (> maxPayload) → server closes connection with code 1009 * ④ Spawn failure → server sends {type:'exit',code:-1,reason} and closes the WS * (shell path set to '/no/such/shell'; node-pty throws synchronously → M4 path) * * ── Require real PTY — sandbox-off (posix_spawn is blocked in sandbox) ───── * ⑤ attach → attached → output sequence (real shell produces prompt output) * ⑥ Reconnect replay (F5/F6): send echo command, disconnect, reconnect with same * sessionId, assert replayed output contains the marker; ANSI/CJK must not corrupt * * Architecture references: * M4 (spawn failure → exit(-1) + close connection only) * L3 (noServer:true; wrong path → destroy) * L5 (maxPayload cap; over-size frame → 1009) * F9 (bad Origin → 401) * ARCHITECTURE §3.6 / §7 */ import net from 'node:net' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' import * as nodePty from 'node-pty' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' import type { Config } from '../../src/types.js' /** * Detect whether real PTY spawn works here. The Bash sandbox blocks posix_spawn, * so the real-PTY E2E cases (⑤⑥) auto-skip there but RUN on any normal machine/CI. */ const PTY_AVAILABLE = (() => { try { const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) p.kill() return true } catch { return false } })() const itPty = PTY_AVAILABLE ? it : it.skip /** H1 tmux tests need real PTY + the tmux binary. */ const TMUX_OK = PTY_AVAILABLE && (() => { try { execFileSync('tmux', ['-V'], { stdio: 'ignore' }) return true } catch { return false } })() const itTmux = TMUX_OK ? it : it.skip // ── Helpers ────────────────────────────────────────────────────────────────── /** Pick a free port on 127.0.0.1 by briefly binding to port 0. */ function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (!addr || typeof addr === 'string') { srv.close() reject(new Error('unexpected address type')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } /** * Build a minimal Config that starts the server on 127.0.0.1: with * the given shellPath and registers http://127.0.0.1: as an allowed origin. * * maxPayloadBytes is set small (512 bytes) for the oversized-frame test so we * don't have to send megabytes of data. */ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 * 1024): Config { return loadConfig({ PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: shellPath, // Inject the loopback origin explicitly so the WS handshake is accepted. ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, // Keep scroll-back small to reduce memory overhead in tests. SCROLLBACK_BYTES: String(64 * 1024), // Keep idle TTL tiny so stray sessions don't linger. IDLE_TTL: '86400', MAX_PAYLOAD_BYTES: String(maxPayloadBytes), USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions // W1: /open-in-editor spawns EDITOR_CMD. Use the POSIX no-op so tests never // pop a real editor (and it's a non-goto basename → bare-file argv). EDITOR_CMD: 'true', }) } /** * Wait until a WebSocket is in OPEN state (or rejects on error/close before open). * Times out after `timeoutMs`. */ function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs) ws.once('open', () => { clearTimeout(timer) resolve() }) ws.once('error', (err) => { clearTimeout(timer) reject(err) }) ws.once('close', () => { clearTimeout(timer) reject(new Error('WS closed before open')) }) }) } /** * Collect the next N JSON messages from the WebSocket. Rejects on close or error * before N messages have arrived, or after `timeoutMs`. */ function collectMessages(ws: WebSocket, count: number, timeoutMs = 5_000): Promise { return new Promise((resolve, reject) => { const msgs: unknown[] = [] const timer = setTimeout( () => reject(new Error(`timeout waiting for ${count} messages; got ${msgs.length}`)), timeoutMs, ) const onMsg = (raw: WebSocket.RawData): void => { try { msgs.push(JSON.parse(raw.toString('utf8'))) } catch { msgs.push(raw.toString('utf8')) } if (msgs.length >= count) { clearTimeout(timer) ws.off('message', onMsg) ws.off('close', onClose) ws.off('error', onError) resolve(msgs) } } const onClose = (): void => { clearTimeout(timer) ws.off('message', onMsg) ws.off('error', onError) reject(new Error(`WS closed before receiving ${count} messages; got ${msgs.length}`)) } const onError = (err: Error): void => { clearTimeout(timer) ws.off('message', onMsg) ws.off('close', onClose) reject(err) } ws.on('message', onMsg) ws.on('close', onClose) ws.on('error', onError) }) } /** * Wait for the WebSocket to close, returning { code, reason }. */ function waitForClose(ws: WebSocket, timeoutMs = 5_000): Promise<{ code: number; reason: string }> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs) ws.once('close', (code, reason) => { clearTimeout(timer) resolve({ code, reason: reason.toString('utf8') }) }) ws.once('error', (err) => { clearTimeout(timer) reject(err) }) }) } /** * Wait for at least one message matching `predicate`, collecting all messages * up to `timeoutMs`. Returns the first matching message. */ function waitForMessage( ws: WebSocket, predicate: (msg: unknown) => boolean, timeoutMs = 5_000, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { ws.off('message', onMsg) reject(new Error('timeout waiting for matching message')) }, timeoutMs) const onMsg = (raw: WebSocket.RawData): void => { let msg: unknown try { msg = JSON.parse(raw.toString('utf8')) } catch { msg = raw.toString('utf8') } if (predicate(msg)) { clearTimeout(timer) ws.off('message', onMsg) resolve(msg) } } ws.on('message', onMsg) }) } // ── Test suite ──────────────────────────────────────────────────────────────── describe('startServer — integration', () => { // NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix // (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners // it registered, so repeated startServer()/close() cycles don't accumulate. let port: number let cfg: Config let serverHandle: { close(): Promise } // Each test gets its own port to avoid cross-test interference. beforeEach(async () => { port = await getFreePort() // Default cfg uses the real shell (process.env.SHELL / /bin/zsh). cfg = makeTestConfig(port, process.env['SHELL'] ?? '/bin/zsh') serverHandle = startServer(cfg) // Small delay to let the server start listening before tests connect. await new Promise((r) => setTimeout(r, 80)) }) afterEach(async () => { await serverHandle.close() // Extra delay to let the OS release the port before the next test. await new Promise((r) => setTimeout(r, 50)) }) // ── ① Bad Origin → 401 ───────────────────────────────────────────────────── it('① rejects a WS handshake from a bad Origin with 401', async () => { // ARCHITECTURE §3.3 / TECH_DOC §7: CSWSH defence — foreign origins get 401. const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: 'http://evil.com' }, }) // The server writes HTTP/1.1 401 and destroys the socket. // The `ws` client surfaces this as an 'unexpected server response 401' error. await expect(waitForOpen(ws, 3_000)).rejects.toThrow() // Ensure we don't leave a dangling socket. ws.terminate() }) // ── ② Wrong path → connection destroyed ──────────────────────────────────── it('② destroys the socket for an upgrade request to a non-wsPath URL (L3)', async () => { // Server calls socket.destroy() for any upgrade to a path other than cfg.wsPath. const ws = new WebSocket(`ws://127.0.0.1:${port}/not-a-terminal`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) // The socket destruction surfaces as an error or abrupt close before open. await expect(waitForOpen(ws, 3_000)).rejects.toThrow() ws.terminate() }) // ── ③ Oversized frame → 1009 ─────────────────────────────────────────────── it('③ closes the connection with code 1009 when a frame exceeds maxPayloadBytes (L5)', async () => { // Use a config with a very small maxPayload so we can test cheaply. const smallMaxPort = await getFreePort() const smallMaxCfg = makeTestConfig(smallMaxPort, process.env['SHELL'] ?? '/bin/zsh', 256) const smallServer = startServer(smallMaxCfg) await new Promise((r) => setTimeout(r, 80)) try { const ws = new WebSocket(`ws://127.0.0.1:${smallMaxPort}${smallMaxCfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${smallMaxPort}` }, }) await waitForOpen(ws, 3_000) // Send a frame that exceeds the 256-byte maxPayload. // (The `ws` library sends the raw buffer; the server's WebSocketServer // will reject it and close with code 1009 Message Too Big.) const oversize = Buffer.alloc(512, 'x') ws.send(oversize) const closed = await waitForClose(ws, 5_000) // RFC 6455 §7.4.1: status code 1009 = Message Too Big. expect(closed.code).toBe(1009) ws.terminate() } finally { await smallServer.close() await new Promise((r) => setTimeout(r, 50)) } }) // ── ④ Spawn failure → exit(-1) + connection closed (M4) ────────────────── // // Sandbox behaviour (posix_spawn blocked): // node-pty.spawn() throws synchronously → server.ts M4 catch → sends // {type:'exit', code:-1, reason} → ws.close(). NO 'attached' frame is sent. // // Real-system behaviour (posix_spawn available): // node-pty spawns the helper process; the shell binary doesn't exist so // exec() fails and the helper fires onExit with exitCode=1. Server sends // {type:'attached'} first, then {type:'exit', code:1} via the onExit handler. // The WS stays open (correct: server doesn't auto-close on shell exit). // // Both paths confirm the server correctly handles a nonexistent shell path. // The strict assertions (code=-1, WS close) are the SANDBOX path (M4); the // lenient assertions (any exit frame) cover the real-system path. it('④ handles a nonexistent shell path: sends exit frame and (in sandbox) closes the WS (M4)', async () => { const badPort = await getFreePort() const badCfg = makeTestConfig(badPort, '/no/such/shell/does/not/exist') const badServer = startServer(badCfg) await new Promise((r) => setTimeout(r, 80)) try { const ws = new WebSocket(`ws://127.0.0.1:${badPort}${badCfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${badPort}` }, }) await waitForOpen(ws, 3_000) // Accumulate all messages and the first close event concurrently. const receivedMsgs: Record[] = [] let wsClosedCode: number | null = null const allDonePromise = new Promise((resolve) => { // Resolve after 2 s max so the test doesn't hang on real systems. const timer = setTimeout(resolve, 2_000) ws.on('message', (raw) => { try { receivedMsgs.push(JSON.parse(raw.toString('utf8')) as Record) } catch { // ignore parse errors } }) ws.once('close', (code) => { clearTimeout(timer) wsClosedCode = code resolve() }) }) // Trigger the attach. In sandbox this causes a synchronous spawn throw // (M4). On real systems the shell fails to exec and fires onExit async. ws.send(JSON.stringify({ type: 'attach', sessionId: null })) await allDonePromise ws.terminate() // ── Assertions common to both paths ──────────────────────────────────── // At least one message must have been received. expect(receivedMsgs.length).toBeGreaterThan(0) // There must be an exit frame somewhere in the received messages. const exitFrame = receivedMsgs.find((m) => m['type'] === 'exit') expect(exitFrame).toBeDefined() expect(typeof (exitFrame as Record)['code']).toBe('number') // ── Sandbox-specific (M4) assertions ─────────────────────────────────── // When posix_spawn is blocked (sandbox), node-pty throws synchronously. // In that case the server must send ONLY {type:'exit', code:-1, reason} // (no 'attached' frame) and must close the WS connection. if (wsClosedCode !== null) { // WS was closed — this is the sandbox (M4) path. const m4ExitFrame = receivedMsgs.find((m) => m['type'] === 'exit') as | Record | undefined expect(m4ExitFrame).toBeDefined() expect(m4ExitFrame!['code']).toBe(-1) expect(typeof m4ExitFrame!['reason']).toBe('string') expect((m4ExitFrame!['reason'] as string).length).toBeGreaterThan(0) // The WS must be closed with a clean status code. expect(wsClosedCode).toBeLessThanOrEqual(1001) // No 'attached' frame should have been sent on the M4 path. const hasAttached = receivedMsgs.some((m) => m['type'] === 'attached') expect(hasAttached).toBe(false) } // (If wsClosedCode is null, we are on a real system: assertions above are sufficient.) } finally { await badServer.close() await new Promise((r) => setTimeout(r, 50)) } }, 15_000) // ── W1: POST /open-in-editor file mode + CSRF guard ──────────────────────── // EDITOR_CMD is 'true' (see makeTestConfig) so no real editor is launched. // These don't need a PTY — they exercise the plain HTTP route + Origin guard. it('W1: opens a FILE at a line with a valid Origin (204)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-file-')) const file = path.join(dir, 'x.ts') await fs.writeFile(file, 'hi') try { const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, { method: 'POST', headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` }, body: JSON.stringify({ file, line: 3 }), }) expect(res.status).toBe(204) } finally { await fs.rm(dir, { recursive: true, force: true }) } }) it('W1: rejects file mode from a foreign Origin (403 — CSRF guard covers the new branch)', async () => { const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, { method: 'POST', headers: { 'content-type': 'application/json', Origin: 'http://evil.com' }, body: JSON.stringify({ file: '/tmp/x.ts', line: 1 }), }) expect(res.status).toBe(403) }) it('W1: still supports directory mode (regression — Projects panel intact)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-dir-')) try { const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, { method: 'POST', headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` }, body: JSON.stringify({ path: dir }), }) expect(res.status).toBe(204) } finally { await fs.rm(dir, { recursive: true, force: true }) } }) it('W1: 404 for a file that does not exist (validated server-side)', async () => { const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, { method: 'POST', headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` }, body: JSON.stringify({ file: '/no/such/file/here.ts', line: 1 }), }) expect(res.status).toBe(404) }) // ── ⑤ attach → attached → output (needs real PTY — sandbox-off) ────────── // // NOTE (sandbox-off): This test spawns a real shell process via node-pty. // posix_spawn is blocked in the sandbox environment. This test is intentionally // skipped in sandbox CI; it MUST pass when run with --sandbox=off (real PTY // available). // // To run locally: npx vitest run integration // To skip in CI: (mark with .skip or filter via env var below) itPty( '⑤ [needs real PTY (sandbox-off)] attach → attached → shell prompt output', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await waitForOpen(ws, 3_000) // Send first frame: attach with sessionId=null (new session). ws.send(JSON.stringify({ type: 'attach', sessionId: null })) // 1. Expect 'attached' confirming a new session was created. const attached = await waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', 5_000, ) as Record expect(attached['type']).toBe('attached') expect(typeof attached['sessionId']).toBe('string') // sessionId must be a UUID v4. expect(attached['sessionId']).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ) // 2. Expect at least one 'output' message (shell prompt). const output = await waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'output', 5_000, ) as Record expect(output['type']).toBe('output') expect(typeof output['data']).toBe('string') ws.close() await waitForClose(ws, 3_000).catch(() => undefined) }, ) // ── ⑥ Reconnect replay / F5+F6 (needs real PTY — sandbox-off) ───────────── // // NOTE (sandbox-off): Requires a live shell to produce output containing // __MARK__. posix_spawn is blocked in the sandbox. Run locally without sandbox. // // Verifies: // - Client sends `echo __MARK__\r`, then disconnects. // - While disconnected, the PTY keeps running (invariant #2 — WS close ≠ PTY kill). // - Client reconnects with the same sessionId. // - Server replays the ring buffer; the reconnecting client sees __MARK__ in the output. // - The replay must not corrupt ANSI sequences or multi-byte UTF-8 (Chinese characters). itPty( '⑥ [needs real PTY (sandbox-off)] reconnect replays ring buffer; ANSI/CJK intact (F5/F6)', async () => { // ── Step A: First connection — attach and send a marker command ────────── const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await waitForOpen(ws1, 3_000) ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) // Wait for 'attached' to capture the sessionId. const attached = await waitForMessage( ws1, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', 5_000, ) as Record const sessionId = attached['sessionId'] as string expect(typeof sessionId).toBe('string') // Wait for at least one output (shell ready). await waitForMessage( ws1, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'output', 5_000, ) // Send a marker command that will appear in the ring buffer. // Also include a CJK character to verify UTF-8 replay integrity (M2). const MARKER = '__MARK_测试__' ws1.send(JSON.stringify({ type: 'input', data: `echo ${MARKER}\r` })) // Give the shell time to process the command and write output. await new Promise((r) => setTimeout(r, 500)) // ── Step B: Disconnect (must NOT kill PTY — invariant #2) ─────────────── ws1.close() await waitForClose(ws1, 3_000).catch(() => undefined) // Small pause to ensure the server processes the WS close → detach. await new Promise((r) => setTimeout(r, 100)) // ── Step C: Reconnect with the same sessionId ──────────────────────────── const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await waitForOpen(ws2, 3_000) ws2.send(JSON.stringify({ type: 'attach', sessionId })) // ── Step D: Collect replay output and assert marker is present ─────────── // Collect messages for up to 3 seconds; concatenate all 'output' data. const allOutput: string[] = [] const replayTimeout = 3_000 await new Promise((resolve) => { const timer = setTimeout(resolve, replayTimeout) ws2.on('message', (raw) => { try { const msg = JSON.parse(raw.toString('utf8')) as Record if (msg['type'] === 'output' && typeof msg['data'] === 'string') { allOutput.push(msg['data']) } } catch { // ignore parse errors } }) ws2.once('close', () => { clearTimeout(timer) resolve() }) }) const combined = allOutput.join('') // The marker must appear in the replayed output. expect(combined).toContain(MARKER) // The CJK portion of the marker must appear verbatim (no UTF-8 corruption). expect(combined).toContain('测试') // Basic ANSI sanity: if any ANSI CSI sequence is present, it must be well-formed // (starts with ESC + '[', ends with a letter). Broken sequences would appear as // partial ESC bytes or malformed numbers — check that we have no lone ESC chars // that are not followed by '[' or ']' (simplified check for cut sequences). // A truly broken buffer would produce something like "\x1b[1;3" with no terminator. // We verify that every ESC in the output is followed by at least one more byte. const escPositions: number[] = [] for (let i = 0; i < combined.length; i++) { if (combined[i] === '\x1b') escPositions.push(i) } for (const pos of escPositions) { expect(combined.length).toBeGreaterThan(pos) } ws2.close() await waitForClose(ws2, 3_000).catch(() => undefined) }, ) // ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ─── itPty( '⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await waitForOpen(ws, 3_000) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = (await waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', 5_000, )) as Record const sessionId = attached['sessionId'] as string // Arm the status listener BEFORE firing the hook — the server pushes the // status frame synchronously (before the POST returns 204), so attaching // the listener after the fetch would race past it. const statusPromise = waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'status', 5_000, ) // Simulate a Claude Code PermissionRequest hook firing for this session. const res = await fetch(`http://127.0.0.1:${port}/hook`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), }) expect(res.status).toBe(204) const status = (await statusPromise) as Record expect(status['status']).toBe('waiting') expect(status['detail']).toBe('Bash') ws.close() await waitForClose(ws, 3_000).catch(() => undefined) }, ) // ── ⑧ Held PermissionRequest resolves on approve (H3, real PTY — sandbox-off) ─ itPty( '⑧ [needs real PTY (sandbox-off)] POST /hook/permission is held until approve → allow decision', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await waitForOpen(ws, 3_000) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = (await waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', 5_000, )) as Record const sessionId = attached['sessionId'] as string // Arm the pending-status listener before firing (push is synchronous). const pendingPromise = waitForMessage( ws, (m) => typeof m === 'object' && m !== null && (m as Record)['pending'] === true, 5_000, ) // Fire the PermissionRequest hook — this POST is HELD by the server until // we approve. Do NOT await it yet. const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ tool_name: 'Bash' }), }) const pending = (await pendingPromise) as Record expect(pending['status']).toBe('waiting') expect(pending['pending']).toBe(true) // Approve over the ws → the held POST should resolve with an allow decision. ws.send(JSON.stringify({ type: 'approve' })) const decision = (await (await permPromise).json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') ws.close() await waitForClose(ws, 3_000).catch(() => undefined) }, ) // ── H1: tmux keepalive — a shell survives a full server restart ──────────── itTmux( 'H1 [needs tmux + real PTY] shell state survives a server restart', async () => { const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) const isAttached = (m: unknown): boolean => typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached' const tport = await getFreePort() const tcfg = loadConfig({ PORT: String(tport), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`, IDLE_TTL: '86400', USE_TMUX: '1', }) let sid = '' let srv = startServer(tcfg) await delay(150) try { // Attach and set a shell variable inside the tmux shell. const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { headers: { Origin: `http://127.0.0.1:${tport}` }, }) await waitForOpen(ws1, 3_000) ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record sid = att['sessionId'] as string await delay(500) ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' })) await delay(500) ws1.close() await waitForClose(ws1, 3_000).catch(() => undefined) // Restart the server — shutdown must keep the tmux session alive (H1). await srv.close() srv = startServer(tcfg) await delay(150) // Reconnect with the SAME sessionId → re-attach to the surviving shell. const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { headers: { Origin: `http://127.0.0.1:${tport}` }, }) await waitForOpen(ws2, 3_000) const out: string[] = [] ws2.on('message', (raw) => { try { const m = JSON.parse(raw.toString('utf8')) as Record if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data']) } catch { /* ignore */ } }) ws2.send(JSON.stringify({ type: 'attach', sessionId: sid })) await delay(400) ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' })) await delay(900) // The variable set before the restart is still set → same shell survived. expect(out.join('')).toContain('MARK-tmuxlives') ws2.close() await waitForClose(ws2, 3_000).catch(() => undefined) } finally { await srv.close() if (sid !== '') { try { execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' }) } catch { /* already gone */ } } } }, 20_000, ) // ── Signal-handler leak fix (CQ H1) ──────────────────────────────────────── it('does not leak SIGINT listeners across startServer()/close() cycles', async () => { const before = process.listenerCount('SIGINT') for (let i = 0; i < 3; i++) { const p = await getFreePort() const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh')) await new Promise((r) => setTimeout(r, 30)) await h.close() } expect(process.listenerCount('SIGINT')).toBe(before) expect(process.listenerCount('SIGTERM')).toBe(before) }) // ── GET /live-sessions (route, no PTY needed when empty) ──────────────────── it('GET /live-sessions returns an array (empty when none running)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(res.status).toBe(200) const body = (await res.json()) as unknown expect(Array.isArray(body)).toBe(true) }) // ── GET /live-sessions/:id/preview — unknown id → 404 ─────────────────────── it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`) expect(res.status).toBe(404) }) // ── Security headers present on responses ─────────────────────────────────── it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(res.headers.get('x-frame-options')).toBe('DENY') expect(res.headers.get('x-content-type-options')).toBe('nosniff') expect(res.headers.get('content-security-policy')).toContain("default-src 'self'") }) // ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ─────────────────── it('DELETE /live-sessions → 403 with a foreign Origin', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: 'http://evil.example' }, }) expect(res.status).toBe(403) }) it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' }) expect(res.status).toBe(403) }) it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` }, }) expect(res.status).toBe(200) const body = (await res.json()) as { killed: number } expect(typeof body.killed).toBe('number') }) it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => { const res = await fetch( `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`, { method: 'DELETE', headers: { Origin: 'http://evil.example' } }, ) expect(res.status).toBe(403) }) it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => { const res = await fetch( `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`, { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } }, ) expect(res.status).toBe(404) }) // ── Hook side-channel endpoints (route-level, no PTY needed) ──────────────── it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', async () => { const res = await fetch(`http://127.0.0.1:${port}/hook`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' }, body: JSON.stringify({ hook_event_name: 'Stop' }), }) expect(res.status).toBe(204) }) it('POST /hook with an unparseable event body → 400', async () => { const res = await fetch(`http://127.0.0.1:${port}/hook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // no session header / no event name body: JSON.stringify({}), }) expect(res.status).toBe(400) }) it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => { const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' }, body: JSON.stringify({ tool_name: 'Bash' }), }) expect(res.status).toBe(200) expect(await res.json()).toEqual({}) }) // ── Per-connection WS rate limit (Sec M3) ─────────────────────────────────── // Fires BEFORE attach, so we can flood pre-attach frames without a real PTY. // A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a // flood (frames are dropped, not closed). it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => { const rlPort = await getFreePort() const rlCfg = loadConfig({ PORT: String(rlPort), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`, IDLE_TTL: '86400', USE_TMUX: '0', MAX_MSGS_PER_SEC: '5', }) const rlServer = startServer(rlCfg) await new Promise((r) => setTimeout(r, 80)) try { const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, { headers: { Origin: `http://127.0.0.1:${rlPort}` }, }) await waitForOpen(ws, 3_000) // Flood 50 invalid frames in one tick — far over the 5/s cap. for (let i = 0; i < 50; i++) ws.send('not-json') // The connection must NOT be closed by the rate limiter. let closed = false ws.once('close', () => { closed = true }) await new Promise((r) => setTimeout(r, 200)) expect(closed).toBe(false) expect(ws.readyState).toBe(WebSocket.OPEN) ws.close() await waitForClose(ws, 3_000).catch(() => undefined) } finally { await rlServer.close() await new Promise((r) => setTimeout(r, 50)) } }) })