数据截至 (上游 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/stop | packages/server/src/server/bootstrap.ts:571 |
parseListenString | 把 6767 / 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 |
startRelayTransport | daemon 主动外连中继,按需为每个客户端开数据 socket | packages/server/src/server/relay-transport.ts:109 |
EncryptedChannel | NaCl box 加解密 + 握手 + 帧形态保持 | packages/relay/src/encrypted-channel.ts:320 |
DaemonClient | 客户端侧:选传输、发 hello、10 秒心跳、重连 | packages/client/src/daemon-client.ts |
主线走一遍(高层):
- daemon 起 HTTP 服务器,监听成功后才创建 WS 服务器与中继运行时。
- 客户端连
/ws,服务端校验 Host / Origin / 密码,连接进入"待 hello"状态。 - 客户端发
hello(带 clientId、协议版本、能力集),服务端建立或恢复Session,回一条server_info。 - 之后所有流量在同一条 socket 上混跑:JSON 文本帧走
Session的消息分发,二进制帧按 opcode 分给终端或文件子系统。 - 客户端每 10 秒发一次
ping;服务端据此续租,45 秒不续就砍掉这条物理 socket。
3. 装配顺序:daemon 是怎么被拼起来的
这节讲 createPaseoDaemon 的接线顺序,以及为什么顺序不能乱。
3.1 先把"监听在哪"解析清楚
parseListenString(bootstrap.ts:41)把一个字符串归一成三种目标之一:tcp / socket(Unix domain socket)/ pipe(Windows 命名管道)。判断按顺序短路:
| 输入形态 | 判定结果 | 依据行 |
|---|---|---|
\\.\pipe\paseo 或 pipe://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,登记进 externalSessionsByKey | websocket-server.ts:1570-1580 |
| 回执 | 两条路径都发一条 server_info | websocket-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 两个方向的能力,不要搞混
协议兼容在这里是双向的,两个方向用的是完全不同的机制:
| 方向 | 载体 | 键名风格 | 消费方式 |
|---|---|---|---|
| 客户端 → daemon | hello.capabilities | snake_case,如 selective_agent_timeline | session.supports(CLIENT_CAPS.x) 门控发送内容 |
| daemon → 客户端 | server_info.features | camelCase,如 workspaceRecovery | 客户端判断"这个 RPC 能不能发" |
客户端能力集中定义在 packages/protocol/src/client-capabilities.ts:1 的 CLIENT_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 |
|---|---|---|---|
Output | 0x01 | daemon → 客户端 | PTY 原始字节 |
Input | 0x02 |