跳到主要内容

数据截至 (上游 commit 60706feb348c)

一条连接:守护进程、线协议与端到端加密中继

30 秒导读: Paseo 让你在手机上盯着跑在自己电脑里的编码 agent。手机和电脑之间只有一条 WebSocket——同一条连接上既跑 JSON 控制消息,又跑终端字节流和文件块;既能在局域网直连,也能穿过一台 Cloudflare 中继而中继看不到明文。本章讲清这条连接怎么建、怎么混流、怎么不被撑爆、怎么加密。

本章不讲 agent 语义与归一化(见 02)、生命周期与时间线(见 03)、工具目录与编排(见 06)。


1. 这是什么(零基础也能懂)

一句话定义: Paseo 的 daemon 是一个跑在你开发机上的常驻进程,它把本机的编码 agent、终端、git 工作区,通过一条 WebSocket 暴露给手机 / 浏览器 / CLI。

解决什么问题: 你在公司电脑上让 Claude Code 跑一个重构,人要去吃午饭。你想在地铁上看它跑到哪了、批准一次危险操作、顺手在终端里敲两行命令。代码不能上传到云端,agent 进程必须留在你自己的机器上——所以需要一条从手机通到那台机器的、安全的、双向的实时通道。

这条通道要同时扛四种流量:

流量形态例子
控制与查询JSON 请求/响应列 agent、创建工作区、批准权限
agent 事件JSON 推送助手消息、工具调用、状态变化
终端 I/O原始字节PTY 输出、键盘输入、resize
文件传输二进制分块上传附件、下载文件

用起来什么样(最小路径):

# 1. 在开发机上起 daemon(默认 127.0.0.1:6767)
npm run dev

# 2. 生成一次配对邀请:终端里直接打印二维码
npm run cli -- daemon status
# → https://app.paseo.sh/#offer=<base64url 的 {serverId, daemonPublicKeyB64, relay}>

# 3. 手机扫码。App 拿到 daemon 公钥,连中继,握手,加密,然后发 hello。

一句话直觉: 把这条 WebSocket 想成一根 USB 线——同一根线里既走"你按了哪个键"这种小控制包,也走"屏幕刷了一屏"这种大数据包;插在本机就是直连,插在中继上就相当于中间加了一段谁也看不懂内容的加密延长线。


2. 顶层全景(它大概怎么转)

先看整体结构。读法:左边是客户端,右边是 daemon 进程内部;中间两条虚线是同一条 WebSocket 的两种到达方式。

客户端(App / 浏览器 / CLI) 开发机上的 daemon 进程
┌───────────────────────────┐ ┌───────────────────────────────────┐
│ DaemonClient │ │ express + http.Server (一个端口) │
│ ├ WebSocket transport │ │ ├ /api/* REST 少量接口 │
│ └ E2EE transport(可选) │ │ ├ /ws WebSocket 升级 │
└───────────┬───────────────┘ │ └ 静态 Web UI │
│ │ ↓ │
①直连 │ ws://127.0.0.1:6767/ws │ VoiceAssistantWebSocketServer │
─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ ├ 物理 socket 记账 / 高水位 │
│ │ ├ hello 握手 → Session │
②中继 │ wss://relay/ws?role=... │ └ 二进制帧分流 │
─ ─ ─ ─ ─ ┼ ─ ─▶ [ relay ] ─ ─ ─ ─ ─ ─▶│ ↓ │
│ 只转字节 │ Session(每个 clientId 一个) │
│ │ ├→ AgentManager (见 03) │
│ │ ├→ TerminalController → PTY worker│
│ │ └→ WorkspaceFiles / Git (见 05) │
└────────────────────────────┴───────────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
createPaseoDaemon把 HTTP、WS、存储、agent、中继全部接线并返回 start/stoppackages/server/src/server/bootstrap.ts:571
parseListenString6767 / 0.0.0.0:6767 / unix:///tmp/x.sock 解析成监听目标packages/server/src/server/bootstrap.ts:41
VoiceAssistantWebSocketServer管物理 socket:升级校验、hello、发帧记账、分流packages/server/src/server/websocket-server.ts:539
Session一个逻辑客户端的全部会话状态与业务分发packages/server/src/server/session.ts
decodeBinaryFrame看第一个字节判断这是终端帧还是文件帧packages/protocol/src/binary-frames/demux.ts:16
sendBoundedPhysicalFrame发帧前先看缓冲区,超限就不发并杀连接packages/server/src/server/websocket/physical-socket.ts:97
startRelayTransportdaemon 主动外连中继,按需为每个客户端开数据 socketpackages/server/src/server/relay-transport.ts:109
EncryptedChannelNaCl box 加解密 + 握手 + 帧形态保持packages/relay/src/encrypted-channel.ts:320
DaemonClient客户端侧:选传输、发 hello、10 秒心跳、重连packages/client/src/daemon-client.ts

主线走一遍(高层):

  1. daemon 起 HTTP 服务器,监听成功后才创建 WS 服务器与中继运行时。
  2. 客户端连 /ws,服务端校验 Host / Origin / 密码,连接进入"待 hello"状态。
  3. 客户端发 hello(带 clientId、协议版本、能力集),服务端建立或恢复 Session,回一条 server_info
  4. 之后所有流量在同一条 socket 上混跑:JSON 文本帧走 Session 的消息分发,二进制帧按 opcode 分给终端或文件子系统。
  5. 客户端每 10 秒发一次 ping;服务端据此续租,45 秒不续就砍掉这条物理 socket。

3. 装配顺序:daemon 是怎么被拼起来的

这节讲 createPaseoDaemon 的接线顺序,以及为什么顺序不能乱。

3.1 先把"监听在哪"解析清楚

parseListenString(bootstrap.ts:41)把一个字符串归一成三种目标之一:tcp / socket(Unix domain socket)/ pipe(Windows 命名管道)。判断按顺序短路:

输入形态判定结果依据行
\\.\pipe\paseopipe://paseo{type:"pipe"}bootstrap.ts:43-48
unix:///tmp/paseo.sock{type:"socket"}bootstrap.ts:50-52
C:\Users\...直接抛错(不是 Unix socket)bootstrap.ts:53-56
/tmp/paseo.sock~/x.sock{type:"socket"}bootstrap.ts:57-60
6767{type:"tcp", host:"127.0.0.1"}bootstrap.ts:61-66
0.0.0.0:6767 / [::1]:6767{type:"tcp"},方括号被剥掉bootstrap.ts:67-78

纯数字默认绑到 127.0.0.1 而不是 0.0.0.0——默认不对外是这里的第一道安全边界。

监听目标的类型还会向下决定两件事:非 TCP(Unix socket)时跳过 Host 头校验(bootstrap.ts:705),因为 Unix socket 上不存在 DNS rebinding;TCP 时才把 localhost:<port> 等变体加进 CORS 允许集(bootstrap.ts:718-729)。

3.2 中间件顺序 = 一条责任链

express 的 app.use 顺序是有意义的,bootstrap.ts 里的排列可以直接读成一条链:

请求进来

├─▶ serviceProxy.middleware() 先认领"这是某个工作区脚本的域名" (:638)
├─▶ Host 允许名单(仅 TCP) 挡 DNS rebinding (:642-651)
├─▶ CORS + OPTIONS 预检 只放行配置里的 origin (:668-681)
├─▶ /api/terminal-activity 本地 hook 上报,token 门控,跳过鉴权 (:684)
├─▶ mountWebUi() 静态 Web UI,故意放在鉴权之前 (:694)
└─▶ daemon bearer 鉴权 → 其余 API

注释里把"为什么 Web UI 放在鉴权前"写得很直白:静态资源不需要密码就能加载,而 API 和 WebSocket 仍然受保护(bootstrap.ts:759-763)。

3.3 HTTP server 建好之后,还有一个 upgrade 竞争

createHTTPServer(app) 之后立刻注册了一个 upgrade 监听器:

httpServer.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: true }));

bootstrap.ts:853 —— 这必须先于 VoiceAssistantWebSocketServer 注册自己的 upgrade 监听,否则指向工作区脚本(比如你自己起的 vite dev server)的 WebSocket 升级会被 daemon 的 WS 服务器抢走。passthroughUnknown: true 保证不匹配的升级请求原样落到下一个监听器。

3.4 start() 的关键顺序:先 listen,后建 WS 与中继

start()(bootstrap.ts:1512)里最反直觉的一点:VoiceAssistantWebSocketServer 是在 listening 事件回调里才 new 出来的(bootstrap.ts:1588),中继运行时更在它之后(bootstrap.ts:1661)。

原因是有一批参数只有"绑定成功后"才知道:

httpServer.listen(...)

▼ 'listening'
resolveBoundListenTarget() ← 端口写 0 时,真实端口只有此刻才有 (:1462)

├─ createAgentMcpBaseUrl() ← MCP 注入给 agent 的 URL 依赖真实端口 (:1463)
├─ new VoiceAssistantWebSocketServer(httpServer, ...) (:1503)
├─ createRelayRuntime({ attachSocket: ws => wsServer.attach... }) (:1568)
└─ hubRelationships.start() (:1587)

中继运行时拿到的 attachSocket 是一个闭包,内部断言 if (!wsServer) throw new Error("WebSocket server is not ready")(bootstrap.ts:1670-1673)——把"顺序依赖"变成了显式错误而不是隐式 undefined。

stop()(bootstrap.ts:1714)是反向的,并且有一条值得抄的收尾细节:关完 wsServer 后仍然调用 httpServer.closeAllConnections(),注释解释了为什么——已升级的 WebSocket socket 不算 "idle",closeIdleConnections() 抓不到它们,不强关就会让 close() 迟迟不返回(bootstrap.ts:1734-1741)。


4. 握手与能力协商:一条连接怎么做到"新旧都能连"

4.1 三道门,然后才是 hello

一条客户端连接要过三道独立的门:

TCP/TLS 建立

├─① verifyClient Host 允许名单 + Origin 允许名单 + 关机拒绝
│ websocket-server.ts:808 verifyWsUpgrade
├─② 子协议里的 bearer 有密码时必须带 paseo.bearer.<password>
│ websocket-server.ts:842 attachAuthenticatedSocket
├─③ 15 秒内必须发 hello 超时 4001 关闭
│ websocket-server.ts:1194 (HELLO_TIMEOUT_MS = 15_000)

Session 建立,回 server_info

第②道有个浏览器现实约束:浏览器的 WebSocket 构造器不让你设 Authorization 头,所以密码被塞进 Sec-WebSocket-Protocol 子协议里。selectWebSocketProtocol(websocket-server.ts:2869)在有密码时只接受能解出 bearer token 的子协议,没有就返回 false 拒绝升级;客户端侧对应的是 protocols = ["paseo.bearer.<password>"](daemon-client.ts:1232)。

握手前允许的消息只有两种:ping(会被提前处理并返回 pong,websocket-server.ts:2216)和 hello。其它 JSON 消息一律 4002 关闭(websocket-server.ts:2154-2166),二进制帧同样(websocket-server.ts:2110-2119)。

4.2 hello 干三件事:验版本、认身份、报能力

handleHello(websocket-server.ts:1509)的逻辑很短,但每一步都对应一个协议决策:

步骤行为依据
版本protocolVersion !== 1 直接 4003 关闭websocket-server.ts:1516-1531
身份clientId 空则 4002 关闭websocket-server.ts:1533-1543
恢复clientId 已有会话 → resumeSession 把新 socket 加进旧会话的 socket 集合websocket-server.ts:1562-1564(resumeSession:1595)
新建否则新建 Session,登记进 externalSessionsByKeywebsocket-server.ts:1570-1580
回执两条路径都发一条 server_infowebsocket-server.ts:1584 / :1628

"一个 clientId 可以挂多条物理 socket"是这层的核心设计。 同一个客户端可能同时开着直连和中继两条路,或者桌面端开了两个窗口。所以 TrustedSessionConnection.sockets 是一个 Set(websocket-server.ts:1397),掉一条只记日志、会话继续(websocket-server.ts:1923-1934);全掉光才进 90 秒宽限期,过期才真正清理会话(EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000,websocket-server.ts:501:1727)。

4.3 两个方向的能力,不要搞混

协议兼容在这里是双向的,两个方向用的是完全不同的机制:

方向载体键名风格消费方式
客户端 → daemonhello.capabilitiessnake_case,如 selective_agent_timelinesession.supports(CLIENT_CAPS.x) 门控发送内容
daemon → 客户端server_info.featurescamelCase,如 workspaceRecovery客户端判断"这个 RPC 能不能发"

客户端能力集中定义在 packages/protocol/src/client-capabilities.ts:1CLIENT_CAPS,每一项都带一段注释说明为什么需要门控。挑一条最能说明问题的:

// COMPAT(terminalReflowableSnapshot): added in v0.1.88. The daemon attaches
// per-row soft-wrap flags (gridWrapped/scrollbackWrapped) to terminal snapshots
// only when the client advertises this ... Old clients use a strict TerminalState
// schema and would reject the extra fields.
terminalReflowableSnapshot: "terminal_reflowable_snapshot",

client-capabilities.ts:12-17 —— 旧客户端的 schema 是严格的,多给字段它会直接解析失败。于是 daemon 只在客户端明确声明支持时才加这几个字段。

daemon 侧的 features 是一张长表(buildServerInfoStatusPayload,websocket-server.ts:1639-1769),每一项都挂着 // COMPAT(name): added in vX, remove after <date> 注释——这既是文档,也是清理待办清单(rg "COMPAT\(" 就能列出全部)。

4.4 schema 只准 append,并且有测试守着

规则很硬:wire schema 上新增字段必须 optional,不准收窄、不准删、不准 require。WSHelloMessageSchema(packages/protocol/src/messages.ts:6815)整个 capabilities 对象是 .passthrough().optional() 的——未知能力键被原样保留而不是报错。

这条规则不是靠人自觉,messages.wire-compat.test.ts 把它钉成了测试。三个最典型的断言:

测试断言什么
hello 兼容不带 capabilities 的老 hello 和带 project_updates 的新 hello 都能解析messages.wire-compat.test.ts:46-76
server_info 前向兼容未知 feature 键被静默剥掉而不是让解析失败messages.wire-compat.test.ts:78-95
旧客户端解新 payload用 v0.1.65-beta.3 的旧 schema 解析今天的 sub_agent 工具调用仍然通过messages.wire-compat.test.ts:154-172

第二条特别值得注意:测试里 workspaceGithubClone: true 被丢弃、agentTurnIdentity: true 被保留,证明"老 daemon 发来一个新客户端不认识的 feature"不会炸掉整条连接。

4.5 能力门控落到哪一行

Session.supports(packages/server/src/server/session.ts:1212)就一行:查一个 Set。真正有意思的是它的粒度——因为一个会话挂多条 socket,而不同 socket 可能是不同版本的客户端:

supportsForSource(capability: ClientCapability, source: object): boolean {
return this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability);
}

session.ts:1216-1220 —— 能力按来源 socket 记录(updateClientCapabilities(caps, ws),session.ts:1113)。于是同一个会话可以做到:给支持 selective_agent_timeline 的那条 socket 只推它订阅的 agent 流,给老 socket 推全量(forwardAgentStream,session.ts:1184-1209)。

服务端能力变化时,broadcastCapabilitiesUpdate(websocket-server.ts:1791)重发一遍 server_info——同一条消息既是握手回执,也是能力变更通知,客户端只需要一套解析逻辑。


5. 文本帧与二进制帧混流:靠第一个字节分家

5.1 分流点只有一处

每条入站消息都先走一次二进制探测,再退回 JSON:

ws.on("message", data)

▼ handleRawMessage (websocket-server.ts:1983)
租约续期 applicationSocketLease.renew(ws)

▼ maybeHandleBinaryFrame (websocket-server.ts:1909)
decodeBinaryFrame(bytes)

├─ 命中 → session.handleBinaryFrame() → 终端 or 文件 → return
└─ 未命中(返回 null)


JSON.parse → WSInboundMessageSchema.safeParse → 业务分发

decodeBinaryFrame(binary-frames/demux.ts:16)只看 bytes[0]:落在终端 opcode 集合就按终端帧解,落在文件 opcode 集合就按文件帧解,都不是就返回 null 让调用方回退到 JSON。opcode 空间因此必须全局唯一——终端占 0x01–0x05,文件传输占 0x10–0x12,中间留了空隙。

5.2 终端帧:两字节头,极致省

终端流帧(packages/protocol/src/binary-frames/terminal.ts:64 encodeTerminalStreamFrame)

byte 0 byte 1 byte 2 ...
┌────────┬─────────┬──────────────────────────────┐
│ opcode │ slot │ payload │
└────────┴─────────┴──────────────────────────────┘
0x01 0..255 随 opcode 变化
opcode方向payload
Output0x01daemon → 客户端PTY 原始字节
Input0x02客户端 → daemon键盘字节(UTF-8 解码后送 PTY)
Resize0x03客户端 → daemonJSON {rows, cols}
Snapshot0x04daemon → 客户端JSON 的 TerminalState 全量网格
Restore0x05daemon → 客户端渲染成 ANSI 的快照(比 JSON 网格小得多)

opcode 定义在 terminal.ts:9-15;方向从使用点可以核实:输入/resize 只在服务端被解码(terminal-session-controller.ts:227:237),Snapshot/Restore 只在服务端被编码(terminal-restore.ts:60:71)、在客户端被解码(daemon-client.ts:5492-5494)。

slot 是这里的关键设计。 一个字节的槽位号,把"哪个终端"压缩成 1 字节而不是每帧带一个 UUID。槽位在订阅时分配、通过 subscribe_terminal_response 的 JSON 消息告诉客户端(terminal-session-controller.ts:691-699),上限 256(MAX_TERMINAL_STREAM_SLOTS = 256,terminal-session-controller.ts:40),分配是环形扫描且槽位可回收(terminal-session-controller.ts:1051-1056)。槽位耗尽时不是崩,而是回一条带 error: "No terminal stream slots available" 的响应(terminal-session-controller.ts:680-688)。

于是一次按键的开销是:2 字节头 + 1 字节键。同样的事如果走 JSON,光 {"type":"terminal_input","terminalId":"...","data":"a"} 就上百字节。

5.3 文件传输帧:变长 requestId + 长度前缀

文件帧要携带一个字符串 requestId(和 JSON 侧的上传请求对应),所以头部是变长的:

FileBegin(0x10) —— 只带元数据,不带内容
┌────────┬──────────┬──────────────┬────────────┬───────────────────┐
│ opcode │ idLen(1) │ requestId(N) │ metaLen(2) │ metadata JSON │
└────────┴──────────┴──────────────┴────────────┴───────────────────┘

FileChunk(0x11) / FileEnd(0x12)
┌────────┬──────────┬──────────────┬───────────────────────────────┐
│ opcode │ idLen(1) │ requestId(N) │ payload(FileEnd 必须为空) │
└────────┴──────────┴──────────────┴───────────────────────────────┘

编解码在 binary-frames/file-transfer.ts:58 / :88。解码器是偏执的,三处硬校验:

  • requestIdLength === 0 || > 剩余长度 → 返回 null(file-transfer.ts:97-99)
  • FileBeginmetadataLength !== body.byteLength - 2 → 返回 null(file-transfer.ts:110-112),即长度前缀必须和实际字节数精确相等,不接受"多余尾巴"
  • FileEnd 带任何 payload → 返回 null(file-transfer.ts:124-126)

返回 null 而不是抛错,是为了让 decodeBinaryFrame 能干净地退回 JSON 路径。元数据本身还要过一遍 zod(FileBeginMetadataSchema,file-transfer.ts:12),并且 metadata.byteLength > 0xffff 在编码侧就抛 RangeError(file-transfer.ts:63-65)——2 字节长度前缀的物理上限被显式守住。

5.4 出站分流:三种发法,各有理由

服务端往外发有三条路径,差别在要不要等对方吃下去:

函数用于等待 drain依据
sendMessageToSocketsJSON,广播给会话的全部 socketwebsocket-server.ts:1140
sendBinaryToClient终端帧websocket-server.ts:1162
sendBinaryToClientAndWait文件下载块websocket-server.ts:1168

文件传输必须 await,否则读文件的速度会瞬间灌满 socket 缓冲。终端输出不能 await,否则一个卡住的客户端会把 daemon 的输出流水线也拖住——终端侧另有自己的背压机制(见 §6.4)。

JSON 广播还有一处省 CPU 的细节:先把已经超限的 socket 过滤掉,再 JSON.stringify 一次,然后对剩下每条 socket 复用同一个字符串和它的字节长度(websocket-server.ts:1141-1159)。


6. 物理 socket 自保:64 MiB、45 秒、90 秒

这节讲"客户端不读了怎么办"。答案是:daemon 宁可砍连接,也不让自己 OOM。

6.1 高水位:64 MiB(与项目文档不一致,以代码为准)

// OOM backstop for a socket whose client stopped draining. A daemon normally has
// 1-10 physical sockets (tens at the outside), so 64 MiB bounds abandoned queues
// without treating ordinary large frames as a protocol or frame-size violation.
export const MAX_PHYSICAL_SOCKET_BUFFERED_BYTES = 64 * 1024 * 1024;

packages/server/src/server/websocket/physical-socket.ts:4

诚实提示: 该项目自带的 docs/architecture.md:240 写的是 "Every physical send path enforces an 8 MiB outbound high-water mark"。在本文锁定的 commit 上,代码里的常量是 64 MiB。二者不一致,以代码为准;文档大概率是该常量调大后没有同步更新。

注释里的推理值得单独拿出来看:阈值不是拍脑袋定的,而是从"一个 daemon 通常只有 1–10 条物理 socket"反推——总内存上界 = 阈值 × socket 数,所以可以把单条阈值放得比较宽松,从而避免把正常的大帧误判成协议违规

6.2 检查在发之前,不在发之后

export function physicalSocketHasCapacity(socket, frameBytes): boolean {
if (typeof socket.bufferedAmount !== "number") return true;
return socket.bufferedAmount + frameBytes <= MAX_PHYSICAL_SOCKET_BUFFERED_BYTES;
}

physical-socket.ts:89-95 —— 检查的是"当前缓冲 + 这一帧",即预判而非事后。超限时不发这一帧,并回调 onHighWater(physical-socket.ts:68-71:105-108)。

超限的处置是 closePhysicalSocket,里面有一句关键注释:

// A close frame queues behind application data, so it cannot enforce a
// hard memory cutoff. Production transports expose terminate().
if (ws.terminate) { ws.terminate(); } else { ws.close(); }

websocket-server.ts:1238-1244 —— 优雅关闭在这里是无效的,因为 close 帧会排在那 64 MiB 未发数据后面。要真正释放内存必须 terminate() 直接掐断 TCP。

注意杀掉的是这一条物理 socket,不是整个会话——同一个 clientId 的其它 socket 不受影响(参见 §4.2 的多 socket 模型)。

6.3 应用层租约:45 秒 / 10 秒巡检

TCP 层的存活检测在移动网络下不可靠(锁屏、切网、NAT 超时),所以另做了一层应用租约:

客户端每 10 秒发 {type:"ping"} daemon-client.ts:966 LIVENESS_HEARTBEAT_INTERVAL_MS


handleRawMessage 收到任何消息 → lease.renew(ws) websocket-server.ts:1991
message.type === "ping" → lease.claim(ws) websocket-server.ts:2028


每 10 秒巡检一次,把过期的 socket 全部 terminate websocket-server.ts:778-789

三个常量凑成一组,注释直接给出了算式:

常量含义
APPLICATION_SOCKET_LEASE_MS45_000一次 ping 换 45 秒租期physical-socket.ts:7
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS10_000巡检周期physical-socket.ts:8
客户端心跳10_000daemon-client.ts:966

注释写的是 "Current clients ping every 10 seconds. Four delayed cycles fit inside the lease"(physical-socket.ts:5-6)——45 秒 ≈ 4 个心跳周期加余量,既容忍地铁隧道里的抖动,又不让一条废弃 socket 挂几分钟。

claimrenew 的区别是这套机制的核心,也是它的边界:

claim(socket) { this.deadlines.set(socket, this.clock() + APPLICATION_SOCKET_LEASE_MS); }
renew(socket) { if (this.deadlines.has(socket)) { this.claim(socket); } }

physical-socket.ts:17-25 —— renew 只刷新已存在的条目。所以一条从未发过 ping 的 socket 永远不在租约表里,也就永远不会被租约巡检杀掉。租约只约束"声明过自己会心跳"的客户端;从不心跳的连接由 hello 超时和高水位两道机制兜底。

6.4 终端另有一层软背压

物理高水位是硬杀,终端流还有一层"软降级":输出积压到一定程度时,不再补发丢失的字节,而是直接发一张全屏快照

判据是两个条件同时成立:

if (
activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES && // 256 KiB
(clientBufferedAmount === null || clientBufferedAmount > MAX_CLIENT_BUFFERED_BYTES) // 4 MiB
) { /* 改发快照 */ }

terminal-session-controller.ts:861-864,常量在 terminal-restore.ts:10:17

注释解释了为什么必须是"且"而不是"或":一个持续消费的客户端 bufferedAmount 永远接近 0,即使产出了几百 MB 也应该继续流式发送;历史上只看产出字节数,导致每 256 KB 编译输出就强制一次全量 JSON 网格快照(约 20 万个对象跨 IPC),那正是当年卡顿的来源(terminal-session-controller.ts:850-859)。

clientBufferedAmount === null 这一支很关键:中继 socket 不暴露 bufferedAmount(它是个多路复用的加密适配器),读不到就宁可保守——直接按字节阈值降级,免得慢速中继客户端无限落后。这个 null 语义在会话侧也被显式保护:getTransportBufferedAmount 宁可返回 null 也不返回 0,免得"没有信号"被误读成"客户端跟得上"(websocket-server.ts:1368-1383)。

6.5 四道防线一览

防线触发条件后果位置
hello 超时15 秒内没 hello关闭码 4001websocket-server.ts:1302-1310
应用租约ping 过后 45 秒无任何消息terminate() 该 socketwebsocket-server.ts:838
物理高水位缓冲 + 本帧 > 64 MiB丢帧 + terminate()physical-socket.ts:89
终端软背压产出 > 256 KiB 缓冲 > 4 MiB降级为快照terminal-session-controller.ts:861

再加一条时间维度的:全部 socket 掉光后,会话保留 90 秒等重连(websocket-server.ts:501)。


7. PTY 侧:为什么终端跑在另一个进程里

7.1 worker 进程模型

终端不在 daemon 主进程里跑。createConfiguredTerminalManager 无条件返回 worker 版本(packages/server/src/terminal/terminal-manager-factory.ts:8-12),后者 fork 出一个独立的 Node 子进程:

fork(fileURLToPath(resolveWorkerUrl()), [], {
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
});

worker-terminal-manager.ts:142-148。三个选择各有原因:

  • serialization: "advanced" —— 用结构化克隆而不是 JSON,Buffer 可以直接过 IPC 不必先转字符串。
  • stdiostdin/stdout 全部 ignore,只留 stderr: inheritipc —— PTY 数据只走 IPC,不污染标准流。
  • 单独进程 —— node-pty 是原生模块,xterm 的屏幕缓冲维护是 CPU 密集的;隔离出去,终端刷屏就不会阻塞 agent 流水线。

主进程侧留的是一层代理 session 对象(worker-terminal-manager.ts:246 起),持有本地缓存的 state、监听器集合和一个 sendBestEffortRequest。请求超时统一 10 秒(REQUEST_TIMEOUT_MS,worker-terminal-manager.ts:37)。

代理里有一个刻意的乐观更新:

send(message: ClientMessage): void {
if (message.type === "resize") {
record.state = { ...record.state, rows: message.rows, cols: message.cols };
}
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
}

worker-terminal-manager.ts:255-263 —— resize 先改本地缓存再发 IPC,于是紧接着的 getSize()(:335)立刻返回新尺寸,不用等 worker 回话。

7.2 输出合并:前沿立即 + 尾沿聚合

PTY 输出是碎片化的(一次按键回显可能只有 1 字节,一次 make 输出可能是几千个小 chunk)。TerminalOutputCoalescer 用 5 毫秒窗口两头兼顾:

数据到达

├─ 没有待定定时器 且 距上次 flush ≥ 5ms ──▶ 立即 flush(前沿:交互回显不延迟)

└─ 否则 ──▶ 累积,起一个 5ms 定时器 ──▶ 到点合并成一个 Buffer 再发(尾沿:刷屏合帧)

terminal-output-coalescer.ts:52-63,窗口 DEFAULT_FLUSH_DELAY_MS = 5(:19)。

还有一个不显然的 markFlushed()(:87):快照帧是绕过合并器直接发的,发完要"假装刚 flush 过",这样紧随其后的输出会走尾沿路径,而不是和快照帧背靠背黏在一起——保留了消费方依赖的那一点点间隙。

7.3 尺寸归属:last-interacting-client-wins

一个 PTY 只有一个尺寸,但可能有手机和桌面两个客户端同时看着。Paseo 的规则是"最后交互的客户端说了算"。

服务端这边没有任何仲裁逻辑——收到 resize 就应用,唯一的处理是去重:

if (msg.message.type === "resize") {
const currentSize = session.getSize();
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
return; // 尺寸没变,不打扰 PTY
}
}
session.send(msg.message);

terminal-session-controller.ts:722-727(二进制 Resize 帧路径同理,:237-243;订阅时携带的 restore.size 也走同一套比较,:662-674)。

规则的另一半落在客户端自律上,项目文档把它写得很明确:客户端只在视口真正变化或用户聚焦/点击终端时才发 resize,被动渲染(附着、恢复可见、字体稳定、渲染器重排)不准发(docs/architecture.md:280)。服务端不广播"谁拥有尺寸",resize 后的 PTY 通过正常输出重绘,其它客户端在自己的视口里渲染那份输出。

这是一个把协商成本推给约定、而不是推给协议的取舍: 省掉了一整套所有权状态机,代价是任何写错的客户端都能引发 resize 抖动。


8. 远程接入与加密:中继看得见字节,看不懂内容

8.1 配对:一张二维码交出公钥

daemon 侧 客户端侧
loadOrCreateDaemonKeyPair() ← 持久化在 PASEO_HOME
getOrCreateServerId() ← 稳定的 daemon 标识,同时用作中继会话 ID


createConnectionOfferV2({ serverId, daemonPublicKeyB64, relay:{endpoint,useTls} })


encodeOfferToFragmentUrl → https://app.paseo.sh/#offer=<base64url>


renderPairingQr(url) → 终端里的二维码 ──── 扫 ────▶ parseConnectionOfferFromUrl

generateLocalPairingOffer(packages/server/src/server/pairing-offer.ts:14)、createConnectionOfferV2 / encodeOfferToFragmentUrl(packages/server/src/server/connection-offer.ts:30:43)、renderPairingQr(packages/server/src/server/pairing-qr.ts:7)、parseConnectionOfferFromUrl(packages/protocol/src/connection-offer.ts:54)。

两个细节值得注意:

  • offer 放在 URL 的 fragment(#offer=)里。fragment 不会被浏览器发给服务器,所以即使这个链接被点开,app.paseo.sh 也拿不到你的 daemon 公钥。
  • offer 的 schema 只有四个字段(connection-offer.ts:9-17):版本、serverId、daemon 公钥、中继地址。没有密码、没有 token——能解密就等于配过对。

8.2 中继连接:控制 socket + 每客户端一条数据 socket

daemon 是主动外连中继的(所以不需要在你家路由器上开端口):

daemon ──連──▶ wss://relay/ws?serverId=..&role=server&v=2 控制 socket

│ 中继推 {type:"connected", connectionId}

daemon ──連──▶ wss://relay/ws?serverId=..&role=server&connectionId=X 数据 socket(每客户端一条)


E2EE 握手 → attachSocket(encryptedSocket) → 走 §4 的 hello 流程

URL 构造见 packages/protocol/src/daemon-endpoints.ts:176 buildRelayWebSocketUrl;控制消息的四种类型(sync / connected / disconnected / ping)与解析见 relay-transport.ts:49-107;按需开数据 socket 见 ensureClientDataSocket(relay-transport.ts:345)。

控制 socket 的保活有一处很务实的注释:

// Cloudflare's runtime auto-responds to protocol pings at the edge without waking the
// hibernated relay Durable Object, so this keepalive does not incur DO CPU billing.

relay-transport.ts:222-224 —— 用 WebSocket 协议层 ping(不是应用层 JSON ping)做保活,因为边缘会代答,不会唤醒休眠的 Durable Object,省钱。相关阈值:每 10 秒 ping、30 秒没动静就 terminate()、连上 8 秒内没收到有效控制消息也 terminate()(relay-transport.ts:56-58)。重连退避是线性封顶 30 秒(relay-transport.ts:337-338)。

8.3 加密:Curve25519 + XSalsa20-Poly1305

客户端 daemon
generateKeyPair()
sharedKey = box.before(daemonPub, mySecret)

│ {"type":"e2ee_hello", key:<myPub>, capabilities:{binaryCiphertext:true}} (明文)
├──────────────────────────────────────────────▶
│ sharedKey = box.before(clientPub, mySecret)
│ {"type":"e2ee_ready", capabilities:{binaryCiphertext:true}} (明文)
◀──────────────────────────────────────────────┤

│ ===== 之后所有帧 = [nonce(24)] [ciphertext] =====

  • deriveSharedKey = nacl.box.before(Curve25519 ECDH,packages/relay/src/crypto.ts:134-150)
  • encrypt = 24 字节随机 nonce + nacl.box.after(XSalsa20-Poly1305),输出 [nonce][ciphertext](crypto.ts:131-140)
  • 客户端发起方 createClientChannel(encrypted-channel.ts:154),daemon 响应方 createDaemonChannel(encrypted-channel.ts:227)

三处工程上的硬骨头:

问题解法依据
派生 shared key 期间到达的下一条消息可能被误判成第二次 hello派生前把 onmessage 换成缓冲函数,派生完再回放,并跳过其中的握手消息encrypted-channel.ts:265-297
客户端没收到 e2ee_ready 会重发 hellodaemon 比对新旧 shared key,相同则只补发 ready、不重新协商密钥(重新 key 会让通道失步)encrypted-channel.ts:503-521
已开通道上收到不同公钥的 hello判定为非法接管,1008 关闭,要求新建传输encrypted-channel.ts:523-530

第三条的注释写得很直接:"A different key on an already-open encrypted channel is not an authenticated reconnect ... instead of allowing the relay to switch this channel to an attacker-chosen key." 也就是说,中继本身被当作不可信

8.4 帧形态保持:文本走 base64,二进制保持二进制

这是本章最容易被忽略、但最影响性能的一处设计。发送端:

const ciphertext = encrypt(this.sharedKey, data);
if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
await this.transport.send(ciphertext); // 原始二进制帧
return;
}
await this.transport.send(arrayBufferToBase64(ciphertext)); // base64 文本帧

encrypted-channel.ts:475-482

明文类型协商了 binaryCiphertext上线形态膨胀
字符串(JSON 消息)base64 文本帧≈ 4/3
ArrayBuffer(终端/文件帧)原始二进制帧只加 40 字节
任意否(老对端)一律 base64 文本帧≈ 4/3

接收端靠 isBinary 标志把明文还原成字符串还是 ArrayBuffer(decodePlaintext,encrypted-channel.ts:568-572),于是**"这是不是二进制帧"这个信息从不需要从明文内容里猜**——crypto.ts 顶部的注释就是这么写的(crypto.ts:10-12)。

如果没有这一层,终端刷屏经过中继会白白膨胀 33%。40 字节的固定开销 = 24 字节 nonce + 16 字节 Poly1305 认证标签(ENCRYPTED_PAYLOAD_OVERHEAD_BYTES,encrypted-channel.ts:121)。

8.5 加密 socket 也要守 64 MiB

加密通道对 VoiceAssistantWebSocketServer 是透明的——它被包成一个鸭子类型的 socket 对象(createEncryptedRelaySocket,packages/server/src/server/websocket/encrypted-relay-socket.ts:21),同样暴露 readyState / bufferedAmount / send / terminate

它自己也做高水位检查,而且用的是加密后的线上字节数:

const outboundBytes = channel.outboundWireByteLength(outbound);
const queuedBytes = getTransportBufferedAmount() ?? 0;
if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { terminate(); ... }

encrypted-relay-socket.ts:60-67,而 outboundWireByteLength(encrypted-channel.ts:485-492)会把 base64 膨胀算进去。记账口径统一在"实际要写进 socket 的字节"上,不是明文长度。

8.6 一个安全边界要说清楚

中继路径不过 daemon 密码校验:attachExternalSocket(websocket-server.ts:966)直接调 attachSocket,而密码检查只在直连的 wss.on("connection") → attachAuthenticatedSocket 路径上(websocket-server.ts:822-823:847-861)。

也就是说,中继路径的鉴权凭据就是那把从二维码里拿到的公钥:握手不对、密钥不对,通道根本建不起来,建起来了也解不出明文(解密失败会被当作致命错误关闭传输,encrypted-channel.ts:448-458)。这是一个明确的设计取舍——把"扫到二维码"等同于"被授权",相应地,offer 链接必须当成凭据来保管。


9. 客户端驱动:一层层包上去的 transport

客户端侧是一个洋葱结构,每层只关心一件事:

DaemonClient(重连、心跳、请求关联、状态机)


[ 可选 ] createRelayE2eeTransportFactory ← 只有 e2ee.enabled 且 URL 是 role=client 时才包
│ daemon-client.ts:1241-1254

createWebSocketTransportFactory ← 统一 addEventListener / on 两种 API 差异
│ daemon-client-websocket-transport.ts:23

平台 WebSocket(浏览器 / Node ws / RN)

四处值得记住的实现细节:

  • binaryType = "arraybuffer" 在建连时就设好(daemon-client-websocket-transport.ts:45-51),否则浏览器默认给 Blob,二进制帧解码路径会全线失败。
  • 加密层是"传输适配器"而不是"协议层":它实现同一套 DaemonTransport 接口(daemon-client-relay-e2ee-transport.ts:128-167),所以 DaemonClient 完全不知道自己是不是在加密。
  • hello 里客户端把自己支持的能力全打开(sendHelloMessage,daemon-client.ts:5318-5344),外加 ...this.config.capabilities 允许调用方追加。
  • 心跳是 10 秒发一次、15 秒超时(LIVENESS_HEARTBEAT_INTERVAL_MS / LIVENESS_HEARTBEAT_TIMEOUT_MS,daemon-client.ts:966-967),超时计入 liveness 失败并触发重连。这正好对上服务端 45 秒租约的设计前提。

入站分流在客户端是镜像的:先试二进制解码,Output / Snapshot / Restore 交给终端路由(daemon-client.ts:5490-5494),FileBegin/Chunk/End 交给文件传输(daemon-client.ts:5477),都不是才按 JSON 处理。


10. 巧妙之处(可以直接抄走的)

  1. 一个字节的 slot 代替 UUID。 终端帧只有 2 字节头,一次按键的线上开销从上百字节降到 3 字节;槽位号用 JSON 消息带外分配,把"可读性"和"传输效率"分别放在了该放的地方(terminal.ts:64terminal-session-controller.ts:691)。

  2. 高水位检查"发之前",关闭用 terminate 而非 close。 因为 close 帧会排在积压数据后面,优雅关闭在内存压力场景下是个假动作(physical-socket.ts:89websocket-server.ts:1238)。

  3. 背压判据是"且"不是"或"。 产出多不等于客户端慢;只有"产出多 真的堵住"才降级。null(读不到背压信号)被单独当作一支处理,而不是当成 0(terminal-session-controller.ts:850-864websocket-server.ts:1372-1375)。

  4. 能力按 socket 记,不按会话记。 同一个 clientId 上的新旧客户端可以拿到不同粒度的推送(session.ts:1216session.ts:1184-1209)。

  5. 握手回执和能力变更用同一条消息。 server_info 既是 hello 的响应,也是后续能力变化的广播(websocket-server.ts:1771-1793),客户端只写一套解析。

  6. 加密通道保留帧形态。 二进制照旧二进制、文本才 base64,省掉终端流 33% 的膨胀;并且"是不是二进制"由传输标志携带,永远不从明文内容里猜(encrypted-channel.ts:475-482crypto.ts:10-12)。

  7. 握手重试被显式建模。 相同公钥的重复 hello = 重发 ready 不重 key;不同公钥 = 判定接管并关闭(encrypted-channel.ts:503-530)。绝大多数握手实现会在这两种情况上二选一地写错。

  8. 兼容规则由测试而非文档保证。 messages.wire-compat.test.ts 用一份从旧版本抄下来的 schema 去解析今天的 payload,把"向后兼容"变成 CI 里会红的东西(messages.wire-compat.test.ts:10-43)。


11. 边界与局限

  • 协议版本是硬门。 WS_PROTOCOL_VERSION = 1(websocket-server.ts:507),不匹配直接 4003 关闭,没有降级路径。柔性兼容全靠 capabilities/features 两张表,版本号本身是"要么全对要么不连"。

  • 文档与代码在高水位上不一致。 docs/architecture.md:240 说 8 MiB,代码是 64 MiB(physical-socket.ts:4)。读该项目文档时对这个数字要留神。

  • 终端尺寸没有服务端仲裁。 "最后交互者赢"完全依赖客户端遵守"被动渲染不发 resize"的约定(docs/architecture.md:280),代码里没有任何机制阻止一个行为不端的客户端反复抢尺寸。

  • 应用租约只覆盖 ping 过的 socket。 renew 不会为陌生 socket 建立条目(physical-socket.ts:21-25),从不心跳的客户端不受租约约束。

  • 终端槽位上限 256。 超出后返回错误而不是排队(terminal-session-controller.ts:40:685)。

  • Hub 会话不支持二进制帧。 收到就 4002 关闭(websocket-server.ts:2121-2124),即通过 Hub 转发的 agent 间连接没有终端/文件能力。

  • restore.mode === "live" 下的 replay preamble 可能是陈旧的。 源码注释自己承认了这个缺口,并说明"目前没有客户端发 live,真有了再修"(worker-terminal-manager.ts:333-338 附近的注释)。

  • 中继路径没有独立的密码层。 拿到 offer 就等于拿到访问权(§8.6)。


12. 代码地图(导航索引)

主题文件路径符号名
daemon 装配总入口packages/server/src/server/bootstrap.tscreatePaseoDaemon
监听字符串解析packages/server/src/server/bootstrap.tsparseListenString, resolveBoundListenTarget
WS 服务器主体packages/server/src/server/websocket-server.tsVoiceAssistantWebSocketServer
升级校验 / 鉴权packages/server/src/server/websocket-server.tsverifyWsUpgrade, attachAuthenticatedSocket, selectWebSocketProtocol
握手与会话恢复packages/server/src/server/websocket-server.tshandleHello, createSessionConnection, detachSocket
server_info / 能力广播packages/server/src/server/websocket-server.tsbuildServerInfoStatusPayload, broadcastCapabilitiesUpdate
入站分流packages/server/src/server/websocket-server.tshandleRawMessage, maybeHandleBinaryFrame
出站发帧记账packages/server/src/server/websocket-server.tssendMessageToSockets, sendBinaryToClientAndWait, closeAtOutboundHighWater
物理 socket 保护packages/server/src/server/websocket/physical-socket.tsMAX_PHYSICAL_SOCKET_BUFFERED_BYTES, ApplicationSocketLease, sendBoundedPhysicalFrame
客户端能力枚举packages/protocol/src/client-capabilities.tsCLIENT_CAPS
hello / 消息 schemapackages/protocol/src/messages.tsWSHelloMessageSchema, WSInboundMessageSchema
兼容性回归测试packages/protocol/src/messages.wire-compat.test.tsdescribe("wire schema compatibility")
能力门控packages/server/src/server/session.tssupports, supportsForSource, updateClientCapabilities
二进制帧分流packages/protocol/src/binary-frames/demux.tsdecodeBinaryFrame
终端帧编解码packages/protocol/src/binary-frames/terminal.tsTerminalStreamOpcode, encodeTerminalStreamFrame
文件传输帧packages/protocol/src/binary-frames/file-transfer.tsFileTransferOpcode, decodeFileTransferFrame
终端流控制器packages/server/src/terminal/terminal-session-controller.tshandleBinaryFrame, bindActiveStream, trySendSnapshot
终端背压常量packages/server/src/terminal/terminal-restore.tsMAX_TERMINAL_OUTPUT_FRAME_BYTES, MAX_CLIENT_BUFFERED_BYTES
输出合并packages/server/src/terminal/terminal-output-coalescer.tsTerminalOutputCoalescer
PTY worker 进程packages/server/src/terminal/worker-terminal-manager.tsforkTerminalWorker, createWorkerTerminalManager
中继外连packages/server/src/server/relay-transport.tsstartRelayTransport, ensureClientDataSocket, attachEncryptedSocket
中继开关packages/server/src/server/relay-runtime.tscreateRelayRuntime
加密原语packages/relay/src/crypto.tsderiveSharedKey, encrypt, decrypt
加密通道packages/relay/src/encrypted-channel.tscreateClientChannel, createDaemonChannel, EncryptedChannel
加密 socket 适配packages/server/src/server/websocket/encrypted-relay-socket.tscreateEncryptedRelaySocket
配对 offerpackages/server/src/server/pairing-offer.tsgenerateLocalPairingOffer
offer schemapackages/protocol/src/connection-offer.tsConnectionOfferV2Schema, parseConnectionOfferFromUrl
中继 URL 构造packages/protocol/src/daemon-endpoints.tsbuildRelayWebSocketUrl
客户端主循环packages/client/src/daemon-client.tssendHelloMessage, startLivenessHeartbeat
客户端传输层packages/client/src/daemon-client-websocket-transport.tscreateWebSocketTransportFactory
客户端加密层packages/client/src/daemon-client-relay-e2ee-transport.tscreateRelayE2eeTransportFactory

下一步读什么: 这条连接之上跑的是什么语义,见 02 归一层03 AgentManager 与时间线;客户端如何在这条连接上做增量同步,见 04 客户端同步