- TECH_DOC + ARCHITECTURE (cross-validated, 12 fixes M1-M7/L1-L5) - PLAN: waves W0-W5, tasks T1-T21 for multi-agent parallel dev - PROGRESS_LOG: orchestrator-owned cross-session memory - CLAUDE.md: required-reading + orchestrator-worker workflow - .claude/agents: module-builder, module-reviewer
351 lines
19 KiB
Markdown
351 lines
19 KiB
Markdown
# 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 拦截。
|
||
|
||
---
|
||
|
||
## 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。
|