Files
web-terminal/docs/ARCHITECTURE.md
Yaojia Wang d22dcd24f7 fix: address review report across security, architecture, quality, tests
Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
2026-06-20 18:27:45 +02:00

22 KiB
Raw Permalink Blame History

Web Terminal 架构文档(开发指引)

版本: v0.1 · 日期: 2026-06-12 定位: 开发指引 —— 把 TECH_DOC.md 的设计落到模块边界、接口契约、函数签名和实现顺序。 写代码时对照本文档逐模块填充;设计意图与取舍请回看 TECH_DOC。


0. 与 TECH_DOC 的差异(先读)

TECH_DOC 本文档(采纳) 原因
语言 .js TypeScript(.ts) 强类型表达模块契约,IDE 可校验协议消息形状
构建 直接 node tsc 编译到 dist/,或 tsx 直跑 TS 需要编译步骤

其余设计(byte-shuttle 原则、attach/detach 会话模型、协议、安全)完全遵循 TECH_DOC。


1. 分层与依赖方向

依赖只能从上往下,不允许反向或跨层:

┌─ 入口层 ──────────────────────────────────────────┐
│  server.ts        Express 静态服务 + HTTP + WS 升级 │
└───────┬───────────────────────────┬───────────────┘
        │ 创建/路由                   │ Origin 校验
┌───────▼──────────┐        ┌────────▼──────────┐
│ session/         │        │ http/origin.ts    │  无状态纯函数
│  manager.ts      │        └───────────────────┘
│  session.ts      │
└───────┬──────────┘
        │ 编解码消息
┌───────▼──────────┐        ┌───────────────────┐
│ protocol.ts      │        │ config.ts         │  常量,被所有层读取
│ (纯函数, 无副作用) │        └───────────────────┘
└──────────────────┘

核心原则(继承自 TECH_DOC §2.1):服务端是字节搬运工,不解析终端语义protocol.ts 只认 JSON 信封;PTY 输出的 ANSI 字节对服务端是不透明的 blob。


2. 目录结构

web-terminal/
├── docs/
│   ├── TECH_DOC.md            # 设计与取舍(why)
│   └── ARCHITECTURE.md        # 本文档(how)
├── src/
│   ├── server.ts              # 入口:HTTP + WS 升级 + Origin 校验接线 (<80 行)
│   ├── config.ts              # 环境变量读取 + 常量 (<60 行)
│   ├── protocol.ts            # 消息类型 + parse/serialize + 校验 (纯函数)
│   ├── http/
│   │   └── origin.ts          # Origin 白名单校验 (纯函数)
│   └── session/
│       ├── manager.ts         # 会话表:create/attach/detach/reap
│       └── session.ts         # 单会话:PTY 句柄 + ring buffer + ws 绑定
├── public/
│   ├── index.html
│   ├── main.ts                # xterm 初始化 + WS 客户端 + 重连
│   ├── keybar.ts              # 移动触摸键栏
│   └── style.css
├── test/
│   ├── protocol.test.ts       # 协议编解码 + 校验(TDD 起点)
│   └── manager.test.ts        # 会话生命周期(PTY 用 mock)
├── tsconfig.json
└── package.json

文件遵循小文件原则,单一职责,均 < 200 行。


3. 模块契约(函数签名级)

3.1 config.ts

环境变量在启动时一次性读取并冻结,杜绝运行时硬编码(继承 TECH_DOC §10)。

export interface Config {
  readonly port: number;          // PORT,默认 3000
  readonly bindHost: string;      // BIND_HOST,默认 '0.0.0.0'
  readonly shellPath: string;     // SHELL_PATH,默认 process.env.SHELL ?? '/bin/zsh'
  readonly homeDir: string;       // PTY cwd,默认 os.homedir()
  readonly idleTtlMs: number;     // IDLE_TTL,默认 24h
  readonly scrollbackBytes: number; // ring buffer 容量,默认 2MB
  readonly maxPayloadBytes: number; // 单条 WS 帧上限,默认 1MB(远超正常键盘输入,防巨帧打爆内存)
  readonly wsPath: string;          // WS 升级路径,默认 '/term'(L3;不变量 8:路径入 config 不硬编码)
  readonly allowedOrigins: readonly string[]; // 见下方推导规则
}

/**
 * 读取并校验环境变量;缺失用默认值,非法值(端口越界等)直接抛错 fail-fast。
 *
 * allowedOrigins 推导(关键,M1):**不能**从 bindHost 推导 ——
 * 默认 bindHost='0.0.0.0' 是监听通配地址,浏览器永远不会以 http://0.0.0.0:3000 为 Origin。
 * 正确来源 = 枚举本机各网卡 IPv4(os.networkInterfaces(),非内部地址)
 *           + 'localhost' / '127.0.0.1'
 *           + 可选主机名(<host>.local)
 *           → 对每个生成 http://<host>:<port>(和 https:// 同形,见 M6)
 *           + ALLOWED_ORIGINS 环境变量追加项。
 * isOriginAllowed 应同时比对 host 与 port。
 * 否则 F4(局域网任意设备访问)会被 F9(Origin 校验)误判 401 —— 二者直接打架。
 */
export function loadConfig(env: EnvLike): Config;  // EnvLike = Readonly<Record<string,string|undefined>>

T2 已冻结的实现以 src/types.ts 为准。其中三处可移植性精化:loadConfigEnvLike 替代 NodeJS.ProcessEnv(process.env 可赋值);Session.attachedWsWebSocketLike 替代 wsWebSocket (真实 ws 结构兼容,并导出 WS_OPEN=1 供 M5 守卫);Config.wsPath 新增。原因:types.ts 同时被前后端 import, 必须无 ws/NodeJS/DOM 外部类型依赖。

3.2 protocol.ts(纯函数,先用 TDD 实现)

消息形状即 TECH_DOC §4。用可辨识联合(discriminated union)表达:

// 客户端 → 服务端
export type ClientMessage =
  | { type: 'attach'; sessionId: string | null }
  | { type: 'input';  data: string }
  | { type: 'resize'; cols: number; rows: number };

// 服务端 → 客户端
export type ServerMessage =
  | { type: 'attached'; sessionId: string }
  | { type: 'output';   data: string }
  | { type: 'exit';     code: number; reason?: string };
  // exit.code: shell 正常退出码;spawn 从未成功用 -1(见 §3.6 / §4.4)。
  // exit.reason: 正常退出可省;**spawn 失败 / 异常退出时必填**,向用户说明原因。

/** sessionId 格式常量,放 protocol.ts(分层上 protocol 不依赖 session 模块)。 */
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // UUID v4

export type ParseResult =
  | { ok: true;  message: ClientMessage }
  | { ok: false; error: string };       // 非法消息丢弃 + 记日志,不抛

/** 解析 + 校验外部输入(系统边界,TECH_DOC §5.3)。永不抛异常,错误走 ParseResult。 */
export function parseClientMessage(raw: string): ParseResult;

/** 序列化服务端消息为 WS 文本帧。内部可信数据,直接 JSON.stringify。 */
export function serialize(msg: ServerMessage): string;

校验规则(必须在 parseClientMessage 内实现,见 TECH_DOC §5.3):

  • 入参 raw 在送入 parseClientMessage 前,调用方先按 cfg.maxPayloadBytes 拒收超大帧 (并把 wsmaxPayload 设为同值,L5:防单条数百 MB 巨帧打爆内存)。
  • 合法 JSON 且 type 在白名单内,否则 { ok: false }
  • resize: cols/rows 为 11000 的整数(Number.isInteger + 范围)。
  • input.data: 必须是 string,原样透传不过滤(它是键盘字节;服务端不解释字节, 透传本身不引入注入风险)。
  • attach.sessionId: string | null;若为 string,必须匹配 SESSION_ID_RE(UUID v4), 否则 { ok: false }。注意 TECH_DOC §4 示例里的 "abc123" 仅为示意,真实值是 crypto.randomUUID()

3.3 http/origin.ts(纯函数,安全关键)

/** CSWSH 防线(TECH_DOC §7)。Origin 不在白名单 → false → 握手返回 401。 */
export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean;

约定:undefined Origin(非浏览器客户端)的处理策略在此集中决定。默认拒绝; 如需放行本机 curl 调试,通过 config 显式开关,不要散落 if 判断

3.4 session/session.ts —— 单会话

会话状态按 TECH_DOC §5.2 拆成不可变快照 + 可变运行时句柄:

/** 创建时固定,永不修改(immutable)。 */
export interface SessionMeta {
  readonly id: string;          // crypto.randomUUID()
  readonly createdAt: number;   // 由调用方传入时间戳(便于测试注入)
  readonly shellPath: string;
}

export interface Session {
  readonly meta: SessionMeta;
  /** 输出环形缓冲(2MB),重连回放用。淘汰语义见下方 RingBuffer。 */
  readonly buffer: RingBuffer;
  /** 当前附着的 ws,null = 已 detach 但 PTY 仍活(vibe coding 核心)。
   *  类型为 WebSocketLike(types.ts;真实 ws.WebSocket 结构兼容)。 */
  attachedWs: WebSocketLike | null;
  detachedAt: number | null;    // detach 时刻
  /** 最近一次 pty.onData 的时间戳。孤儿回收用它近似"是否仍在跑前台作业"(M3)。 */
  lastOutputAt: number;
  /** PTY 退出时刻;null = 仍存活。非 null 后:writeInput/resize 静默忽略(L4),
   *  attach 命中时回放 buffer 后立即补发 exit(L1)。 */
  exitedAt: number | null;
  exitCode: number | null;
  readonly pty: IPty;           // node-pty 句柄
}

/**
 * spawn PTY,接好 onData→buffer→(转发 ws) 与 onExit。不持有 ws。
 *
 * onData:append buffer、刷新 lastOutputAt、若 attachedWs 存在则转发。
 * onExit(M4/L1/L2):置 exitedAt/exitCode;若 attachedWs 存在则发 exit;
 *   再调用注入的 onExit 回调,把"从会话表移除"的职责交回 manager
 *   (session 不持有 manager,靠回调跨层,见 §3.5)。
 *
 * 失败契约(M4):node-pty 在 shell 路径不存在等情况下会**同步抛错**。
 * createSession **不**吞掉它 —— 让它抛出,由 manager.handleAttach 透传给 server.ts 捕获,
 * server.ts serialize 一条 { type:'exit', code:-1, reason } 后 close 该连接(只关连接,不 fail-fast)。
 */
export function createSession(
  cfg: Config,
  dims: { cols: number; rows: number },
  now: number,
  onExit: (session: Session) => void,   // manager 注入:用于从会话表移除(L2)
): Session;

/** 把 ws 绑定到会话:先回放 buffer,再接续实时流。返回踢掉的旧 ws(若有)。 */
export function attachWs(session: Session, ws: WebSocket): WebSocket | null;

/** 解绑 ws,标记 detachedAt;**不杀 PTY**。 */
export function detachWs(session: Session, now: number): void;

// writeInput/resize:exitedAt!=null 时**直接 return**(L4:对已退出 PTY 调 write/resize 会抛错)。
export function writeInput(session: Session, data: string): void;          // pty.write
// resize 另做幂等:cols/rows 与当前相同则跳过,天然抑制高频 resize(SIGWINCH 抖动)。
export function resize(session: Session, cols: number, rows: number): void; // pty.resize → SIGWINCH
export function kill(session: Session): void;                               // pty.kill,进程退出/回收时调用

RingBuffer 是独立小工具(可放 session/ring-buffer.ts):

export interface RingBuffer {
  append(chunk: string): void;
  snapshot(): string;   // 当前缓冲全量,attach 回放用
}
export function createRingBuffer(maxBytes: number): RingBuffer;

裁剪语义(M2,易踩):容量单位是真实字节(scrollbackBytes),但 PTY 输出是 ANSI 字节流, 不能在任意字节处切断 —— 否则会把一条 ANSI 转义序列(如 ESC[1;32m)或一个多字节 UTF-8 码点 切成两半,重连回放第一屏花屏/乱码,而这正是 vibe coding 重连看 Claude TUI 的主场景。约定:

  • 内部以 Buffer('utf8')按真实字节计量,使容量与 scrollbackBytes 语义一致 (string.length 是 UTF-16 码元数 ≠ 字节数,不可直接当字节用);
  • 按 chunk(append 整块)边界整体淘汰最旧块,保证不切碎序列(代价是容量略浮动);
  • 兜底:snapshot() 开头补一个软复位 ESC[0m,即使最旧块边界仍残留半截属性也能止血。
  • 这与不变量 #1"字节是不透明 blob"不冲突:不解析语义 ≠ 可在任意字节处切割。 ring-buffer.test.ts 须覆盖"淘汰不切碎多字节码点 / 不切碎转义序列"。

3.5 session/manager.ts —— 会话表

export interface SessionManager {
  /**
   * attach 入口:
   * - sessionId=null → 新建。
   * - 命中存活会话 → attachWs(回放 + 续流)。
   * - 命中**已退出**会话(exitedAt!=null,L1)→ 回放最后 buffer 后立即补发 exit,再移除;不静默新建。
   * - 查不到(含格式非法已被 §3.2 拦掉)→ 新建并返回新 id。
   * createSession 抛错(spawn 失败,M4)不在此吞掉,向上抛给 server.ts。
   */
  handleAttach(ws: WebSocket, sessionId: string | null, dims: Dims, now: number): Session;
  get(id: string): Session | undefined;
  /**
   * 孤儿回收(M3)。判据 = detach 超过 idleTtl **且** 自 detach 起无新输出
   * (now - max(detachedAt, lastOutputAt) > idleTtl)。
   * 不依赖"PTY 前台子进程"——node-pty 不跨平台暴露前台进程组,无法可靠判定;
   * 用 lastOutputAt 近似"仍在跑任务",且该字段可被 mock IPty 驱动、可测。
   * 定时器周期调用。返回回收数量。
   */
  reapIdle(now: number): number;
  /** 服务端退出时统一清理(TECH_DOC §5.2)。 */
  shutdown(): void;
}

export function createSessionManager(cfg: Config): SessionManager;

约束(逐条对应 TECH_DOC §5.2):

  • WS close → detachWs, kill PTY。
  • 同一会话同时只允许一个 ws,后到踢前者(attachWs 返回旧 ws,调用方先把 attachedWs 指向新 ws、 再 close 旧 ws,确保转发逻辑永不向被踢/关闭中的 ws 写)。
  • PTY 退出:createSession 内部 onExit → 置 exitedAt → 有 ws 则发 exit → 调 manager 注入的回调从表移除 (L2:session 不持有 manager,退出事件靠注入回调跨层冒泡)。
  • detach 后才退出(无 ws 可通知,L1):不立即丢弃会话,保留 buffer + exitedAt, 等用户带旧 id 回来 attach 时回放 + 补发 exit;若一直无人 attach,由 reapIdle 兜底回收。

3.6 server.ts —— 接线层(薄)

只做接线,无业务逻辑:

export function startServer(cfg: Config): { close(): Promise<void> };

职责:

  1. Express 托管 public/(及编译后的前端)。
  2. WS 用 new WebSocketServer({ noServer: true }) 创建(L3:不传 server,否则它会自挂 upgrade 监听,与下面手写的 upgrade 校验双重处理同一 socket)。
  3. HTTP server upgrade 事件(单一升级入口):
    • pathname !== '/term'(路径常量入 config,不散落硬编码)→ socket.destroy();
    • isOriginAllowed 不通过 → socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + destroy();
    • 全部通过 → wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req))
  4. WS connection:等首条 attachtry { manager.handleAttach() }attached; catch(spawn 失败,M4)→ send({type:'exit', code:-1, reason}) 后 close,只关该连接,不 fail-fast
  5. WS message:parseClientMessage,ok:false 丢弃+记日志,否则路由到 writeInput/resize
  6. WS close:manager detach( kill PTY)。
  7. process.on('SIGINT'/'SIGTERM')manager.shutdown();uncaughtException 记录后退出 (fail-fast 仅用于真正未预期错误;spawn 失败、send 失败等已知路径不得走到这里,TECH_DOC §5.4)。

转发统一守卫(M5):任何 ws.send 前必须 if (ws.readyState === WebSocket.OPEN);转发只对 session.attachedWs 发送。send 失败的处理是只 detach 该 ws、绝不 kill PTY(否则违反不变量 #2)—— 注意区别于 TECH_DOC §5.4 笼统的"清理会话"措辞。


4. 关键流程时序

4.1 新建会话

ws 连接 → 等待首帧
client → {attach, sessionId:null}
  manager.handleAttach(null) → createSession(spawn pty) → attachWs
server → {attached, sessionId:"<uuid>"}
  pty.onData → buffer.append → ws.send {output}

4.2 重连回放(F5/F6,vibe coding 命脉)

新 ws 连接(旧 ws 可能还活着)
client → {attach, sessionId:"<uuid>"}
  manager.get(id) 命中
  attachWs: 踢掉旧 ws → buffer.snapshot() 一次性 send → 之后接实时流
server → {attached} → {output: <回放>} → {output: <实时>}...

关键:断开期间 pty.onData 仍写入 buffer(因为 detach 不停 PTY),所以离线输出不丢。

4.3 resize

client → {resize, cols, rows}  // 独立消息,不混进 input
  exitedAt? → 忽略;cols/rows 未变? → 忽略(幂等)
  pty.resize(cols, rows) → ioctl(TIOCSWINSZ) → shell 收 SIGWINCH → 全屏程序重绘

4.4 PTY 退出 / spawn 失败(L1 / M4)

[在 detach 状态下退出]
  pty.onExit → 置 exitedAt/exitCode → attachedWs 为 null,exit 暂不投递
  会话保留(buffer + exitedAt)
  client(旧 id 回来) → {attach, sessionId}
    命中已退出会话 → server → {output:<回放>} → {exit, code}  // 用户仍看到末屏与退出码
  之后由 reapIdle 兜底回收

[spawn 失败]
  client → {attach, sessionId:null}
    handleAttach → createSession 同步抛错 → server.ts catch
  server → {exit, code:-1, reason:"spawn failed: ..."} → close(仅此连接)

5. 前端架构(public/)

构建:打包器 = esbuild(T1 定)。入口 public/main.tsnpm run build:webpublic/build/main.js(+ main.css); index.html<script type="module" src="./build/main.js"> 加载;server 静态托管 public/tsconfig.web.json 仅类型检查。

文件 职责 关键点
main.ts xterm + FitAddon 初始化、WS 客户端、重连 WS URL 同源,scheme 随页面协议:(location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/term'(M6:HTTPS 页面下硬写 ws:// 会被浏览器 mixed-content 拦截,而 Tailscale/TLS 部署正是 HTTPS);term.onData → ws.send {input};onmessage → term.write(output)
keybar.ts 移动触摸键栏 touchstart 直接 ws.send {input,data},绕过 xterm 避免弹软键盘;>768px 隐藏

重连状态机(TECH_DOC §6.2):指数退避 1s/2s/4s…上限 30s,携带 localStorage 的 sessionId; fit() 必须在容器有真实尺寸后调用(display:none 会得到 NaN,TECH_DOC §9 坑位)。

按键字节表(keybar):Esc \x1b / Shift+Tab \x1b[Z / 方向 \x1b[A..D / Enter \r / Ctrl+C \x03 / Tab \t。 注意回车是 \r(0x0D)不是 \n


6. 实现顺序(对应 TECH_DOC §9,TDD)

步骤 模块 验证 依赖
S1 脚手架 + package.json + tsconfig + node-pty 编译验证 npm install 通过
S2 config.ts 单测:默认值 + 非法值抛错
S3 protocol.ts + ring-buffer.ts 先写测试(RED→GREEN),覆盖校验分支
S4 http/origin.ts 单测:白名单/非法/undefined config
S5 session.ts + manager.ts(PTY 用 mock) manager.test.ts:attach/detach 不杀 PTY、踢旧 ws、reap S2S3
S6 server.ts 接线 手动:浏览器连通 S2S5
S7 public/(main + keybar) 手动:vim/top/resize/手机 S6
S8 联调验收 F1F9 TECH_DOC §8 验收表 全部

先做 S3:协议与缓冲是纯函数,最适合 TDD 起步且无外部依赖,能在没接 PTY/WS 时就锁定边界行为。


7. 测试策略

  • 单元(test/):
    • protocol:编解码 + 全部校验分支(含 SESSION_ID_RE 接受 UUID v4 / 拒绝 abc123、超 maxPayloadBytes 拒收)。
    • config:allowedOrigins 由网卡 IP 推导(注入假 networkInterfaces),不含 0.0.0.0(M1)。
    • origin:白名单/非法/undefined;host+port 双匹配。
    • ring-buffer:淘汰不切碎多字节码点 / 不切碎 ANSI 序列、按字节计量(M2)。
  • 会话生命周期:manager.test.tsmock IPty({ write, resize, kill, onData, onExit },可手动触发 onData/onExit)验证:"WS 断开不杀 PTY""后到踢前者""reapIdle 用 lastOutputAt 判据(M3):有新输出不回收"" detach 后 onExit 保留会话、attach 回放+补发 exit(L1)""对已退出 PTY 的 write/resize 被忽略(L4)"。
  • 集成/E2E:server 起真 WS,断言 attach→attached→output 时序;重连回放覆盖 F5/F6; 覆盖 spawn 失败 → exit(-1)+close 仅关连接(M4)、非 /term 路径与坏 Origin 被拒(L3/F9)。
  • 覆盖率目标见全局 testing 规范;PTY/xterm 真实交互部分以手动验收(§6 S7/S8)补足。

8. 不变量清单(代码评审对照)

  1. 服务端不解析 ANSI/终端语义,PTY 字节是不透明 blob(但 RingBuffer 淘汰须按 chunk/码点边界,不可任意字节切割,M2)。
  2. WS 断开 永不 kill PTY —— 只 detach;ws.send 失败也只 detach 该 ws,不动 PTY(M5)。
  3. parseClientMessage 永不抛异常,非法输入走 ParseResult.ok=false
  4. 会话 meta(SessionMeta:id/createdAt/shellPath)全 readonly 不可变;运行时句柄字段是明确的例外,允许就地变更——clients(Set)、detachedAtlastOutputAtexitedAt/exitCodeclaudeStatuscwd 是每会话的可变运行时状态(H6)。这是对全局"绝不 mutate"风格的有意例外:不可变快照与可变句柄被刻意分离持有,在每次按键时复制一个 Set 是纯粹的浪费。新增运行时状态时,把它放在 Session(可变区)而非 SessionMeta
  5. (v0.4 放宽)一个会话可有多个并发附着 ws(多设备镜像共享):新 attach JOIN(不踢);output/exit/status 广播给所有 client;任意 client 可输入(共享控制);PTY 尺寸 latest-writer-wins(最近 fit/focus 的设备驱动)。原"同一时刻最多一个 ws"已不再成立。
  6. 任何 ws.send 前必须 readyState === OPEN(M5)。
  7. Origin 校验在 upgrade 阶段完成(noServer + handleUpgrade),失败 401,不进入 WS 逻辑;路径限 /term(L3)。状态变更的 HTTP 路由(DELETE /live-sessions[/:id])也复用同一 Origin 白名单做 CSRF 守卫(异源/缺失 → 403)。
  8. 无硬编码:所有可变参数走 config.ts(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins、maxSessions、maxMsgsPerSec、permTimeoutMs、reapIntervalMs、previewBytes)。
  9. input.data 原样透传,不做内容过滤。
  10. PTY 退出后 writeInput/resize 静默忽略;exitedAt 会话保留至被 attach 回放或 reapIdle 回收(L1/L4)。
  11. 服务端不假设前端已防抖:resize 在服务端做幂等(值未变则跳过);WS 帧受 maxPayload 上限约束(L5)。