# 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": "[1;32m..." }    // 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 必须是 1–1000 的整数(防御恶意/异常值打挂 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 | 联调验收 F1–F7(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。
