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

20 KiB
Raw Permalink Blame History

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 文本帧。自定义极简协议:

// 客户端 → 服务端
{ "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: DENYX-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 全彩 + 全屏程序支持 vimtopclaude 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. 运行方式(规划)

npm install        # node-pty 触发原生编译
npm start          # 监听 0.0.0.0:3000
npm test           # 协议层单元测试

# 局域网访问:
# 1. 查本机 IP:ipconfig getifaddr en0
# 2. 其他设备打开 http://<IP>:3000

配置通过环境变量覆盖(无硬编码):PORTSHELL_PATHBIND_HOSTIDLE_TTL


11. 同类项目调研(2026-06)

调研目的:本项目主用途是 vibe coding,需要对 Claude Code 友好。以下是同赛道 开源项目的共性设计,v0.1/v0.2 功能取舍即来源于此。

11.1 重点参考项目

项目 技术栈 值得借鉴的设计
cc-remote-term Next.js + ws + node-pty + tmux tmux 保活;5MB 输出环形缓冲重连回放;扫描 ~/.claude/projects/ 的历史会话浏览器 + 一键 --resume;iOS 触摸键栏
open-claude-remote Node + xterm.js 移动端细节:QR 码连接、长按复制、点击聚焦、50K 行回滚、多网卡 IP 探测
claude-coterminal FastAPI + tmux 用 Claude hooks(Stop/PreToolUse)检测会话状态(工作中/等输入/完成),手机一眼看到哪个会话在等人
control-center Node + SQLite + tmux hooks POST 到服务端 → WS 推送状态;多会话仪表盘;Telegram 通知
claude-command-center Node + Express + tmux 不进终端即可批准/拒绝工具调用的快捷按钮;ntfy 手机推送;WS + SSE 双传输
claudecodeui / CloudCLI 全家桶 直接读写 ~/.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。