Files
web-terminal/test/integration/server.test.ts
Yaojia Wang 822364d12b
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
test(server): prove the H1 precondition instead of assuming it
`H1 shell state survives a server restart` failed in the full parallel suite
and passed in isolation, which reads like a timing flake in the assertion. It
was not. Polling for the marker instead of sleeping a fixed 900ms showed the
shell replying promptly with:

    echo MARK-$WEBTERM_MARK
    MARK-

An EMPTY variable. The post-restart shell was fine; the variable had never been
set in the PRE-restart shell, because a fixed 500ms is not enough for the shell
to become ready and run the assignment when 81 test files are competing for the
machine. The symptom then appears at the far end of the test and reads as "the
tmux session did not survive the restart", sending whoever debugs it after
entirely the wrong bug.

So the setup now proves itself: it echoes the variable back and waits for
`SET-tmuxlives` before restarting, and fails there with "the variable was never
set in the pre-restart shell" if it did not take. The final assertion likewise
waits for its marker with a bounded timeout rather than guessing a duration.

Neither wait can mask a genuine failure: if the tmux session really did not
survive, the shell is fresh, the variable is empty, and the marker never appears
no matter how long we wait.

Added a `waitUntil` helper for this. It carries its own sleep because the
`delay` the cases use is a local const inside a test body, not module scope.

Verified: full `npx vitest run test/` -> 81 files, 2243 tests, 0 failures.

NOT fixed, and not mine to guess at: `server.test.ts` has at least one more
real-PTY case that flakes under the same parallel load (⑤ attach → attached →
shell prompt output times out in `waitForMessage`, then its afterEach hook
times out too). It passes in isolation. Same class of fixed-delay assumption,
different case.
2026-07-30 16:27:32 +02:00

1083 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
/**
* How long a real-PTY case waits for something that MUST arrive (handshake,
* attached frame, shell output). These are the bounds that actually catch a hang;
* the per-test ceiling above them is only a backstop. They were 35 s, which is
* fine for this file alone but not while ~8 workers hammer the machine — a real
* shell simply takes longer to boot and echo then, and the case failed with
* "timeout waiting for matching message" rather than any real defect.
* The deliberate "must be rejected" waits in ①② keep their short bounds.
*/
const PTY_WAIT_MS = 20_000
/** 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<number> {
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:<port> with
* the given shellPath and registers http://127.0.0.1:<port> 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<void> {
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<unknown[]> {
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)
})
}
/**
* Poll `predicate` until it is true, or give up after `timeoutMs`.
*
* Prefer this over a fixed `delay()` whenever the thing being waited for is a
* real event (a shell replying, a file appearing) rather than a deliberate
* pause. A fixed sleep encodes a guess about machine speed, and the guess is
* wrong exactly when the suite is busiest — which is why it shows up as a
* "flaky" test that only fails in the full parallel run.
*
* Returns whether the predicate became true, rather than throwing, so the caller
* can assert with its own message and dump the state it actually observed.
*/
async function waitUntil(predicate: () => boolean, timeoutMs: number, stepMs = 50): Promise<boolean> {
const deadline = Date.now() + timeoutMs
// Its own sleep on purpose: the `delay` the cases use is a local const inside a
// test body, so a module-level helper cannot see it.
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
while (Date.now() < deadline) {
if (predicate()) return true
await sleep(stepMs)
}
return predicate()
}
/**
* 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<unknown> {
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 ────────────────────────────────────────────────────────────────
// These cases boot a real server, spawn a real shell and wait for prompt output —
// and H1 restarts the server and waits for tmux to hand the session back. None of
// that fits vitest's 5 s default, so under a full parallel run they were failing as
// `Test timed out in 5000ms`. The `[needs sandbox-off]` labels are about whether
// PTY_AVAILABLE lets them RUN, which is a separate axis from how long they need.
describe('startServer — integration', { timeout: 60_000 }, () => {
// 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<void> }
// 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<void>((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<void>((r) => setTimeout(r, 50))
// Shutting a server down after a real-PTY case can exceed vitest's 10 s hook
// default under a loaded full-suite run; the close itself is what we wait on.
}, 30_000)
// ── ① 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<void>((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<void>((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<void>((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<string, unknown>[] = []
let wsClosedCode: number | null = null
const allDonePromise = new Promise<void>((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<string, unknown>)
} 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<string, unknown>)['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<string, unknown>
| 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<void>((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, PTY_WAIT_MS)
// 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<string, unknown>)['type'] === 'attached',
5_000,
) as Record<string, unknown>
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<string, unknown>)['type'] === 'output',
5_000,
) as Record<string, unknown>
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, PTY_WAIT_MS)
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<string, unknown>)['type'] === 'attached',
5_000,
) as Record<string, unknown>
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<string, unknown>)['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<void>((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<void>((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, PTY_WAIT_MS)
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<void>((resolve) => {
const timer = setTimeout(resolve, replayTimeout)
ws2.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
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, PTY_WAIT_MS)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
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<string, unknown>)['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<string, unknown>
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, PTY_WAIT_MS)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
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<string, unknown>)['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<string, unknown>
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)
},
)
// ── W1: /hook/permission carries a bounded preview + re-sends to late joiners ─
itPty(
'W1 [needs real PTY (sandbox-off)] /hook/permission broadcasts a preview and re-sends it to a late joiner',
async () => {
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws1, PTY_WAIT_MS)
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
const sessionId = attached['sessionId'] as string
// Arm the pending-status listener on ws1 before firing (push is synchronous).
const pendingPromise = waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
// Fire a Bash PermissionRequest carrying tool_input — HELD until approve.
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', tool_input: { command: 'echo hi' } }),
})
const pending = (await pendingPromise) as Record<string, unknown>
expect(pending['status']).toBe('waiting')
const preview = pending['preview'] as Record<string, unknown> | undefined
expect(preview).toBeDefined()
expect(preview!['kind']).toBe('command')
expect(String(preview!['text'])).toContain('echo hi')
// Late joiner: a SECOND ws attaching to the same session while the approval
// is held must receive the preview on attach (re-sent like `gate`).
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws2, PTY_WAIT_MS)
const joinPending = waitForMessage(
ws2,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
const joined = (await joinPending) as Record<string, unknown>
const joinedPreview = joined['preview'] as Record<string, unknown> | undefined
expect(joinedPreview).toBeDefined()
expect(joinedPreview!['kind']).toBe('command')
expect(String(joinedPreview!['text'])).toContain('echo hi')
// Approve over ws1 to release the held POST, then clean up.
ws1.send(JSON.stringify({ type: 'approve' }))
await permPromise.then((r) => r.json()).catch(() => undefined)
ws1.close()
ws2.close()
await waitForClose(ws1, 3_000).catch(() => undefined)
await waitForClose(ws2, 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<void> => new Promise((r) => setTimeout(r, ms))
const isAttached = (m: unknown): boolean =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['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, PTY_WAIT_MS)
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
const att = (await waitForMessage(ws1, isAttached, PTY_WAIT_MS)) as Record<string, unknown>
sid = att['sessionId'] as string
const pre: string[] = []
ws1.on('message', (raw) => {
try {
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
if (m['type'] === 'output' && typeof m['data'] === 'string') pre.push(m['data'])
} catch {
/* ignore */
}
})
await delay(500)
ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
// PROVE the precondition instead of assuming it. Under the full parallel
// suite a fixed sleep is not enough for the shell to be ready and run the
// assignment before the restart, and when that happens the post-restart
// shell reports an EMPTY variable — which looks exactly like "the tmux
// session did not survive" and sends the reader hunting the wrong bug.
// Echo it back and wait for proof, so a setup failure fails HERE, saying so.
ws1.send(JSON.stringify({ type: 'input', data: 'echo SET-$WEBTERM_MARK\r' }))
const markSet = await waitUntil(() => pre.join('').includes('SET-tmuxlives'), 8_000)
expect(markSet, 'the variable was never set in the pre-restart shell').toBe(true)
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, PTY_WAIT_MS)
const out: string[] = []
ws2.on('message', (raw) => {
try {
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
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' }))
// POLL, don't sleep a fixed 900ms. The shell's reply is the only thing we
// are waiting for, and how long it takes depends on machine load: under the
// full 81-file parallel suite this reliably overran the old budget, so the
// assertion fired while `out` still held only the echoed input. Waiting for
// the condition cannot hide a genuine failure — if the tmux session did NOT
// survive, the shell is fresh, `$WEBTERM_MARK` is empty, and the marker never
// appears no matter how long we wait; the expect below then fails exactly as
// it did before, just with a bounded wait instead of a guessed one.
const markerSeen = await waitUntil(() => out.join('').includes('MARK-tmuxlives'), 8_000)
// The variable set before the restart is still set → same shell survived.
expect(markerSeen, `shell reply after re-attach: ${JSON.stringify(out.join(''))}`).toBe(true)
expect(out.join('')).toContain('MARK-tmuxlives')
ws2.close()
await waitForClose(ws2, 3_000).catch(() => undefined)
} finally {
// Bound the shutdown. An unbounded close() here swallows whatever really
// went wrong in the body: the inner waits throw, control jumps to this
// finally, close() blocks, and the case reports "timed out" instead of
// the actual error. The test must fail with its own diagnosis.
await Promise.race([srv.close(), delay(5_000)])
if (sid !== '') {
try {
execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
} catch {
/* already gone */
}
}
}
},
// The inner waits are already individually bounded (3+5+3+3+3 s) and the fixed
// delays add ~2.6 s, so the worst case is ~19.6 s — the old 20 s ceiling had
// no headroom at all and tripped whenever the full suite ran in parallel.
// The real guards against a hang are those inner bounds, not this number.
60_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<void>((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<void>((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, PTY_WAIT_MS)
// 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<void>((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<void>((r) => setTimeout(r, 50))
}
})
})