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

432 lines
22 KiB
Markdown
Raw Permalink 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.

# Web Terminal 架构文档(开发指引)
> 版本: v0.1 · 日期: 2026-06-12
> 定位: **开发指引** —— 把 [TECH_DOC.md](./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)。
```ts
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`](../src/types.ts) 为准**。其中三处可移植性精化:`loadConfig` 用 `EnvLike`
> 替代 `NodeJS.ProcessEnv`(process.env 可赋值);`Session.attachedWs` 用 `WebSocketLike` 替代 `ws` 的 `WebSocket`
> (真实 ws 结构兼容,并导出 `WS_OPEN=1` 供 M5 守卫);`Config.wsPath` 新增。原因:types.ts 同时被前后端 import,
> 必须无 `ws`/`NodeJS`/DOM 外部类型依赖。
### 3.2 `protocol.ts`(纯函数,先用 TDD 实现)
消息形状即 TECH_DOC §4用可辨识联合(discriminated union)表达:
```ts
// 客户端 → 服务端
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` 拒收超大帧
(并把 `ws` `maxPayload` 设为同值,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`(纯函数,安全关键)
```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 拆成**不可变快照** + **可变运行时句柄**:
```ts
/** 创建时固定,永不修改(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`):
```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` —— 会话表
```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` —— 接线层(薄)
只做接线,无业务逻辑:
```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`:等首条 `attach` `try { 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.ts` `npm run build:web` `public/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.ts`**mock 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)、`detachedAt``lastOutputAt``exitedAt`/`exitCode``claudeStatus``cwd` 是每会话的可变运行时状态(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)。
```