Files
web-terminal/docs/TECH_DOC.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

357 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.2(草案) · 日期: 2026-06-12
> 定位: 快速原型 / 学习项目 · 主用途: **vibe coding(在任何设备上驾驶 Claude Code)**
> 形态: 浏览器访问部署机的本地 Shell,同一局域网内其他设备也可访问
---
## 1. 项目概述
### 1.1 目标
在浏览器中提供一个功能完整的终端,连接到服务器(部署机)的本地 Shell。
任何局域网内的设备(其他电脑、平板、手机)打开 `http://<主机IP>:3000` 即可获得一个交互式终端。
### 1.2 非目标(本期不做)
- 用户注册 / 登录 / 多用户隔离
- SSH 跳板到其他远程主机
- 容器 / K8s 后端
- 公网部署(无认证,**严禁暴露公网**)
---
## 2. 总体架构
```
┌──────────────── 浏览器(局域网任意设备) ────────────────┐
│ ┌─────────────────────────────────────────────┐ │
│ │ xterm.js ── 终端渲染、键盘输入、光标、ANSI 转义 │ │
│ │ fit addon ── 自适应窗口大小 │ │
│ └───────────────┬─────────────────────────────┘ │
│ │ WebSocket(双向字节流 + 控制消息) │
└──────────────────┼─────────────────────────────────────┘
┌──────────────────▼───────────── Node.js 服务端 ────────┐
│ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Express │ │ ws │ │ node-pty │ │
│ │ 静态资源服务 │ │ WebSocket 网关 │──▶│ PTY 进程管理 │ │
│ └────────────┘ └──────────────┘ └──────┬──────┘ │
│ │ fork │
│ ┌──────▼──────┐ │
│ │ zsh / bash │ │
│ │ (伪终端从端) │ │
│ └─────────────┘ │
└────────────────────────────────────────────────────────┘
```
### 2.1 核心原理:PTY(伪终端)
直接 `spawn('zsh')` 得到的是普通管道,shell 会认为自己**不在终端里**:
没有颜色、没有行编辑、vim/top 等全屏程序无法工作。
PTY(pseudo-terminal)是内核提供的一对设备(master/slave):
- **slave 端**接给 shell,shell 以为自己连着真终端(isatty() = true)
- **master 端**由 Node 进程持有,读写原始字节流(含 ANSI 转义序列)
`node-pty` 封装了 `forkpty()` 系统调用。**xterm.js 负责解释这些转义序列并渲染**,
服务端不解析任何终端语义,只做字节搬运 —— 这是整个架构最重要的简化。
### 2.2 数据流
```
按键 ──▶ xterm.js onData ──▶ WS 发送 ──▶ pty.write() ──▶ shell stdin
shell stdout/stderr ──▶ pty onData ──▶ WS 发送 ──▶ xterm.write() ──▶ 屏幕
```
每个 WebSocket 连接对应一个独立的 PTY + shell 进程(1:1 会话模型)。
---
## 3. 技术选型
| 组件 | 选择 | 理由 |
|------|------|------|
| 终端前端 | **@xterm/xterm 5.x** | 事实标准(VS Code、Hyper、JupyterLab 同款),ANSI 支持最完整 |
| 自适应布局 | @xterm/addon-fit | 官方插件,窗口缩放时重算行列数 |
| 伪终端 | **node-pty** | 微软维护,VS Code 同款,跨平台(macOS/Linux/Windows ConPTY) |
| Web 框架 | Express 4 | 只用来托管静态页面,最熟悉、最简单 |
| WebSocket | **ws** | 最轻量的标准实现,无额外协议封装,利于学习原始机制 |
| 运行时 | Node.js ≥ 18 | node-pty 预编译二进制覆盖范围内 |
**为什么不用 Socket.IO**:它自带重连、房间、降级轮询,但封装掉了 WebSocket 原始细节;
学习场景下用裸 `ws` 更能看清协议本质。后续需要自动重连时可以手写(也是学习点)。
**现成替代品(知晓即可)**:ttyd(C)、wetty(Node)、GoTTY(Go)。
本项目目的是理解原理,所以自己实现;遇到设计问题可参考 wetty 源码。
---
## 4. WebSocket 消息协议
单 WebSocket 连接,JSON 文本帧。自定义极简协议:
```jsonc
// 客户端 → 服务端
{ "type": "attach", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 首条消息:附着已有会话;为 null 则新建
{ "type": "input", "data": "ls -la\r" } // 键盘输入(原始字节)
{ "type": "resize", "cols": 120, "rows": 40 } // 终端尺寸变化
// 服务端 → 客户端
{ "type": "attached", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } // 附着成功,随后先回放缓冲再接实时流
{ "type": "output", "data": "..." } // PTY 输出(含 ANSI 序列)
{ "type": "exit", "code": 0, "reason": "..." } // shell 退出;reason 仅异常/spawn 失败时填,正常退出可省
```
> sessionId 是 `crypto.randomUUID()`(UUID v4),服务端按该格式校验(见 ARCHITECTURE §3.2 `SESSION_ID_RE`)。
设计说明:
- **attach 是首条消息**:客户端把 sessionId 存在 localStorage,刷新/换设备时带上,
服务端先回放该会话的环形缓冲,再接续实时流 —— 这就是"刷新页面 Claude 会话不丢"的机制。
- **resize 必须是独立消息**:PTY 尺寸要通过 `ioctl(TIOCSWINSZ)` 设置,
shell 收到 SIGWINCH 后全屏程序(vim/top)才能正确重绘。
- 原型期用 JSON 足够;若追求吞吐,可升级为二进制帧(首字节做 opcode)。
---
## 5. 服务端设计
### 5.1 模块划分
```
web-terminal/
├── docs/
│ └── TECH_DOC.md # 本文档
├── src/
│ ├── server.js # 入口:Express + HTTP + WS 升级,~60 行
│ ├── config.js # 端口、shell 路径、scrollback 等常量
│ ├── session.js # 会话:1 WS ↔ 1 PTY 的生命周期管理
│ └── protocol.js # 消息编解码 + 校验(parse/serialize)
├── public/
│ ├── index.html # 页面骨架
│ ├── main.js # xterm 初始化 + WS 客户端逻辑
│ └── style.css # 全屏终端布局
├── test/
│ └── protocol.test.js # 协议编解码单元测试
└── package.json
```
遵循小文件原则:每个文件单一职责,均 < 200
### 5.2 会话生命周期(session.js)—— attach/detach 模型
会话与连接解耦:**PTY 的生命周期 WebSocket 的生命周期**(vibe coding 核心需求)。
```
attach(sessionId=null)
└─▶ 新建:pty.spawn(SHELL, [], { cwd: HOME, cols, rows, env })
├─ pty.onData ──▶ 写入环形缓冲(2MB) ──▶ 若有附着的 ws 则转发
├─ ws 'message' ──▶ 校验 ──▶ pty.write() / pty.resize()
├─ ws 'close' ──▶ detach(会话标记为"无人观看",PTY 继续运行!)
└─ pty.onExit ──▶ 通知附着的 ws ──▶ 从会话表移除
attach(sessionId="abc123")
└─▶ 查会话表 ──▶ 回放环形缓冲 ──▶ 接管实时流(旧 ws 若还在则踢掉)
```
关键约束:
- **WS 断开不杀 PTY** —— 这是与普通 web terminal 最大的区别;
里面跑着的 Claude Code 任务必须继续执行
- **孤儿会话回收**:detach 超过 `IDLE_TTL`(默认 24h)**且自 detach 起无新输出**时回收
("PTY 内无前台子进程"判据落地不了——node-pty 不跨平台暴露前台进程组;改用"最近输出时间戳"
近似"是否仍在跑任务",可测, ARCHITECTURE §3.5。)
服务端进程退出时统一 `pty.kill()` 所有会话(v0.1 不做跨重启保活,v0.2 tmux 解决)。
- 一个会话同一时刻只允许一个附着的 WS(后到者踢前者),避免双端输入交错
- 会话状态以不可变快照记录(创建时固定 id/启动时间),运行时句柄(pty/ws)单独持有
### 5.3 输入校验(系统边界)
WS 消息是外部输入,逐条校验:
- WS 帧大小设上限(`maxPayload`,默认 1MB,远超正常键盘输入),超限帧丢弃——防单条巨帧打爆内存
- 必须是合法 JSON,`type` 在白名单内,否则丢弃并记日志
- `resize` cols/rows 必须是 11000 的整数(防御恶意/异常值打挂 ioctl);服务端再做幂等(值未变跳过),
**不假设前端已防抖**
- `input.data` 必须是 string(原样透传给 PTY,不做内容过滤——它就是键盘字节;服务端不解释字节,不引入注入)
### 5.4 错误处理
- node-pty spawn 失败(shell 路径不存在等)→ `createSession` 同步抛错, `server.ts` 捕获
向客户端发 `exit`(code:-1 + reason)+ 关闭**该连接**(注意:只关连接,**** `uncaughtException` 杀进程)
- 转发 `ws.send` 前必须查 `readyState === OPEN`;send 失败 **只 detach 该 ws,绝不 kill PTY**
(笼统说"清理会话"会误杀正在跑 Claude PTY,违反保活)
- **已退出** PTY `write`/`resize` 一律忽略(否则 node-pty 抛错)
- 输出侧背压:转发前看 `ws.bufferedAmount`,超阈值暂缓(终端输出可丢,环形缓冲仍留作回放)
- 服务端监听 `uncaughtException` 仅记录后退出(fail fast),仅用于真正未预期错误;上述已知路径不得走到这里
---
## 6. 前端设计
### 6.1 初始化流程(main.js)
1. `new Terminal({ scrollback: 5000, fontFamily: 'Menlo, monospace', theme })`
2. 加载 FitAddon,`term.open(el)` `fit()`
3. 建立 WS:`ws://${location.host}/term`(同源,无需配置 IP)
4. 绑定 `term.onData → ws.send(input)``ws.onmessage → term.write(output)`
5. `ResizeObserver` / window resize `fit()` 发送 resize 消息(防抖 100ms)
### 6.2 连接状态反馈
- 连接中 / 已断开时在终端打印彩色状态行(直接写 ANSI 序列,顺便练习转义码)
- shell 退出后显示 "进程已退出,按回车重新连接" —— 回车触发重建 WS
- 断线后自动重连(指数退避:1s/2s/4s上限 30s),携带 localStorage 中的 sessionId
### 6.3 移动快捷键栏(Claude Code 必需)
手机软键盘打不出 Claude Code 的高频按键,在终端下方固定一排触摸按钮:
| 按钮 | 发送字节 | Claude Code 中的作用 |
|------|----------|---------------------|
| Esc | `\x1b` | 打断 Claude 当前回答 |
| Shift+Tab | `\x1b[Z` | 切换 auto-accept / plan 模式 |
| | `\x1b[A` | 菜单/历史选择 |
| Enter | `\r` | 确认 |
| Ctrl+C | `\x03` | 终止进程 |
| Tab | `\t` | 补全 |
实现:按钮 `touchstart` 直接 `ws.send(input)`,不经过 xterm,避免软键盘弹出
桌面端(宽度 > 768px)默认隐藏该栏。
---
## 7. 安全设计(必读)
> ⚠️ 本项目 = **把你电脑的完整 shell 权限交给任何能访问该端口的人**。
| 风险 | 对策 |
|------|------|
| 公网暴露 = 远程任意代码执行 | 只绑定局域网;**永远不要**端口映射/内网穿透到公网 |
| 局域网内他人访问(咖啡店/办公室 WiFi) | 默认绑定 `0.0.0.0` 但文档显著警告;只在可信家庭网络使用 |
| **明文嗅探 / 中间人(MITM)** | `ws://` 不加密,键盘输入(可能含密码、API key、claude token)与全部输出在局域网明文传输,同网段可被动监听、ARP 欺骗可劫持。Origin 校验对此**无任何防护**。缓解:**Tailscale(WireGuard 加密)** 或本地自签 TLS 走 `wss://`——在不可信网络下这不是可选优化,而是必需加固 |
| 跨站 WebSocket 劫持(CSWSH):恶意网页在你浏览器里偷连 ws://内网IP | **校验 Origin 头**,只允许来自本服务页面的连接(零成本,必须做) |
| 后续想加最简认证 | 预留:URL token(`?token=xxx` 比对环境变量),v0.2 可选 |
Origin 校验是无认证方案里**唯一不能省**的防线,因为攻击者无需在你的局域网内,
只要你访问了恶意网页,网页里的 JS 就能尝试连接 `ws://192.168.x.x:3000`
**Origin 白名单的正确来源(关键)**:`0.0.0.0` 是监听通配地址,浏览器永远不会以它为 Origin。
白名单须由**本机各网卡 IPv4 + localhost + 可选主机名**推导出 `http(s)://<host>:<port>`,而非从 `bindHost` 推导,
否则 F4(局域网设备访问)会被 F9(Origin 校验)误判 401——二者打架。实现见 ARCHITECTURE §3.1/§3.3。
前端 WS 的 scheme 随页面协议(`https→wss`),避免 Tailscale/TLS(HTTPS)部署下 `ws://` 被 mixed-content 拦截。
**已知接受风险 —— `/sessions` 端点(O2 历史浏览)**:该路由对局域网内任意设备**无鉴权**返回最近的 Claude Code 会话信息:每个会话的 `cwd`、首条 prompt 的前 ~120 字、以及可 `claude --resume` 的会话 UUID。这是一处真实的信息泄露,但与本应用的威胁模型一致——本应用本身就把完整 shell 交给任何能访问端口的人(无 auth、仅局域网、永不公网)。故**保持现状不加门禁**,通过 Tailscale 部署收敛网络面即可。(此前也评估过用 `/hook` 的 loopback 检查门禁它,但那会使局域网设备无法浏览历史,与 F4 冲突。)代码处有同义注释。
**状态变更路由的 CSRF 守卫**:`DELETE /live-sessions[/:id]`(manage 页 kill/批量 kill)会改变服务端状态,而普通 HTTP 路由不像 WS 升级那样天然有 Origin 检查。故这两条 DELETE 复用同一 `allowedOrigins` 白名单做 Origin 守卫——异源/缺失 Origin → 403——挡住恶意页面无预检触发 Kill-All。配套的安全响应头(`X-Frame-Options: DENY``X-Content-Type-Options: nosniff`、保守 CSP)阻止点击劫持。
**连接级限频**:`maxPayload` 只约束单帧**大小**;另加每连接漏桶限频(`MAX_MSGS_PER_SEC`,默认 2000)约束单连接帧**频率**,超限丢帧(不 close,避免误杀合法突发)+ 节流日志。
---
## 8. 功能清单
> 设计原则:本终端的主用途是 **vibe coding** —— 给 Claude Code 发任务后人离开,
> 之后从任意设备(手机/平板/另一台电脑)回来查看和确认。
> 因此"会话不死"和"移动端可操作"是核心需求,不是锦上添花(见第 11 节调研)。
### v0.1 MVP(本期)
| # | 功能 | 验收标准 |
|---|------|----------|
| F1 | 浏览器交互式终端 | `ls`、tab 补全、Ctrl+C、方向键历史均正常 |
| F2 | 全彩 + 全屏程序支持 | `vim``top``claude` TUI 颜色与重绘正确 |
| F3 | 终端自适应 | 拖动浏览器窗口,行列数实时调整,TUI 不错位 |
| F4 | 局域网访问 | 同网段手机/电脑通过 `http://<IP>:3000` 可用 |
| F5 | **会话保活** | 刷新页面/换设备重连后,Claude Code 会话仍在跑,内容不丢 |
| F6 | **重连回放** | 重连时回放离线期间的输出(环形缓冲,默认 2MB/会话) |
| F7 | **移动快捷键栏** | 手机上可按 Esc、Shift+Tab、↑↓←→、Enter、Ctrl+C |
| F8 | 进程退出处理 | shell 退出后提示重连,回车开新会话 |
| F9 | Origin 校验 | 来自其他源的 WS 握手被拒绝(401) |
会话保活实现方式(F5):PTY 不再随 WS 断开而销毁。会话由服务端以 sessionId 管理,
WS 断开只是"分离"(detach),PTY 与其中运行的 Claude Code 继续存活;
新 WS 携带 sessionId 即可重新附着(attach)。服务端重启保活(tmux 方案)留给 v0.2。
### v0.2 候选(按对 vibe coding 的价值排序)
- **Claude Code 状态感知**:通过 Claude hooks(Stop / PermissionRequest / PreToolUse)
把"工作中 / 等待批准 / 空闲"推送到页面标题与会话列表(参考 claude-coterminal、control-center)
- **多会话仪表盘**:并行开多个 Claude agent,列表显示各会话状态,点击切换
- **历史会话恢复**:扫描 `~/.claude/projects/*/` 的 JSONL,一键 `claude --resume <id>`
(参考 cc-remote-term 的 history-index 设计)
- **tmux 后端**:PTY 活在 tmux 里,服务重启也不丢会话(同类项目的事实标准方案)
- 浏览器通知:Claude 等待输入时推送提醒
- 简单 token 认证、复制粘贴增强(@xterm/addon-web-links)
---
## 9. 任务拆解
| 阶段 | 内容 | 预估 |
|------|------|------|
| T1 | 项目脚手架 + 依赖安装(node-pty 需本地编译,验证环境) | 0.5h |
| T2 | protocol.js + 单元测试(TDD:先写编解码与校验测试) | 0.5h |
| T3 | session.js + server.js(PTY ↔ WS 桥接,Origin 校验) | 1h |
| T4 | 前端 index.html + main.js(xterm + fit + 状态提示) | 1h |
| T5 | 联调验收 F1F7(vim/top/resize/局域网手机实测) | 0.5h |
### 已知坑位
- **node-pty 编译**:macOS 需要 Xcode Command Line Tools;Node 大版本升级后需 `npm rebuild`
- **`\r` vs `\n`**:终端回车发送的是 `\r`(0x0D),不是 `\n`,自己拼输入时容易踩
- **fit 时机**:必须在容器有实际尺寸后调用,`display:none` 时 fit 会得到 NaN
- **中文/IME 输入**:xterm.js 已处理 composition event,但要避免自己重复监听 keydown
---
## 10. 运行方式(规划)
```bash
npm install # node-pty 触发原生编译
npm start # 监听 0.0.0.0:3000
npm test # 协议层单元测试
# 局域网访问:
# 1. 查本机 IP:ipconfig getifaddr en0
# 2. 其他设备打开 http://<IP>:3000
```
配置通过环境变量覆盖(无硬编码):`PORT``SHELL_PATH``BIND_HOST``IDLE_TTL`
---
## 11. 同类项目调研(2026-06)
调研目的:本项目主用途是 vibe coding,需要对 Claude Code 友好。以下是同赛道
开源项目的共性设计,v0.1/v0.2 功能取舍即来源于此。
### 11.1 重点参考项目
| 项目 | 技术栈 | 值得借鉴的设计 |
|------|--------|----------------|
| [cc-remote-term](https://github.com/AliceLJY/cc-remote-term) | Next.js + ws + node-pty + tmux | tmux 保活;5MB 输出环形缓冲重连回放;扫描 `~/.claude/projects/` 的历史会话浏览器 + 一键 `--resume`;iOS 触摸键栏 |
| [open-claude-remote](https://github.com/StephenTowne/open-claude-remote) | Node + xterm.js | 移动端细节:QR 码连接、长按复制、点击聚焦、50K 行回滚、多网卡 IP 探测 |
| [claude-coterminal](https://github.com/adamorad/claude-coterminal) | FastAPI + tmux | 用 Claude hooks(Stop/PreToolUse)检测会话状态(工作中/等输入/完成),手机一眼看到哪个会话在等人 |
| [control-center](https://github.com/charan1319/control-center) | Node + SQLite + tmux | hooks POST 到服务端 → WS 推送状态;多会话仪表盘;Telegram 通知 |
| [claude-command-center](https://github.com/afstkla/claude-command-center) | Node + Express + tmux | 不进终端即可批准/拒绝工具调用的快捷按钮;ntfy 手机推送;WS + SSE 双传输 |
| [claudecodeui / CloudCLI](https://github.com/siteboon/claudecodeui) | 全家桶 | 直接读写 `~/.claude` 配置;聊天界面 + 文件浏览器 + git 面板(超出本项目范围,仅作参照) |
### 11.2 提炼的共性模式("Claude Code 友好"的定义)
1. **会话与连接解耦**(所有项目):PTY 活在服务端(多数用 tmux),浏览器只是附着的视图。
vibe coding 的工作流是"发任务 → 离开 → 回来看",连接断了任务不能死。
2. **重连回放缓冲**:环形缓冲保留输出,重连先回放再续流。
3. **移动触摸键栏**:Esc / Shift+Tab / 方向键是 Claude Code 高频键,软键盘打不出。
4. **状态感知靠 hooks 而非解析终端输出**:解析 TUI 输出脆弱(Claude 改版即坏),
正确做法是利用 Claude Code 官方 hooks 机制写状态文件 / POST 事件。
5. **历史会话 = 读 `~/.claude/projects/*/` JSONL**,恢复 = `claude --resume <id>`,
不需要自己存任何对话数据。
6. **安全上偏好 Tailscale 而非裸局域网**:多数项目文档推荐 Tailscale,
免认证又加密;本项目 v0.1 仍按局域网 + Origin 校验,Tailscale 作为部署建议。
### 11.3 与现成方案的关系
若只为使用,cc-remote-term / CloudCLI 已可直接安装。本项目坚持自研的理由是学习
PTY/WS/终端协议本身;设计决策时优先参考 cc-remote-term(目标形态最接近,
代码结构清晰),移动端体验参考 open-claude-remote。