数据截至 (上游 commit eb980a5c9eea)
加密同步流与不可读的 RPC 中继
30 秒导读: 手机、服务器、终端里的 CLI 三方要实时对上话,但服务器没有密钥、读不懂任何一个字节。 这一章讲 Happy 怎么在「中间人是瞎子」的前提下,把消息排好序、把状态改对、还能让手机远程喊 CLI 执行一条命令。
本章只讲传输层。密钥怎么来、配对怎么握手见 01 端到端加密与配对握手; 消息解密后的内容怎么解释见 04 远程回合 与 06 多种 agent 后端与 App 端的消息归一。
1. 这是什么(零基础也能懂)
一句话定义: Happy 的传输层是一条「服务器看不懂内容的三端同步管道」。
它要解决的场景。 你在笔记本终端里跑着 happy claude,人走了;在地铁上掏出手机想接着聊。
手机和笔记本不在一 个网络里,只能靠中间那台服务器转发。可 Happy 的卖点是端到端加密——
服务器必须在读不懂内容的情况下,把事情办对。
「办对」具体是三件事:
| 要办的事 | 白话 | 难在哪 |
|---|---|---|
| 消息不乱序、不丢 | 手机上看到的对话顺序,和终端里发生的顺序一致 | 网络会断,socket 会掉包,重连后得知道「我落下了哪几条」 |
| 状态改不打架 | 手机改标题的同时 CLI 也在改,不能互相覆盖 | 两边都在改同一个值,而服务器不能「合并」密文 |
| 手机能指挥电脑 | 在手机上点一下,笔记本上真的跑了 git status | 请求要精确送到「那一台机器上的那一个会话」,且参数不能让服务器看见 |
一句话直觉: 把服务器当成一个不识字的邮局。 信封上写着房间号(路由信息,明文),信纸上是密文。邮局只认房间号,永远不拆信。
2. 顶层全景:一条 Socket.IO 通道,三种业务流
2.1 三端怎么接进来
三种客户端连的是同一个 Socket.IO 端点(path: '/v1/updates'),靠握手里的 clientType 区分身份:
happy-app(手机/网页) clientType: 'user-scoped' ──┐
│ ┌──────────────────────────┐
happy-cli(某个会话) clientType: 'session-scoped' ──┼─────►│ happy-server │
+ sessionId │ │ Socket.IO /v1/updates │
│ │ + REST /v3/sessions/... │
happy daemon(某台机器) clientType: 'machine-scoped' ──┘ │ │
+ machineId │ 只见 base64 密文,不解密│
└──────────────────────────┘
鉴权发生在 Socket.IO 的 middleware 里而不是 connection 回调里,这是个有意的选择:
packages/happy-server/sources/app/api/socket.ts:78-121 的注释写明了理由——
如果把异步的 auth.verifyToken 放在 connection 回调里,客户端的 connect 事件会先于 handler 挂载触发,
那段窗口里到达的 rpc-register、rpc-call 会被静默丢弃。
2.2 三条业务流
同一条 socket 上跑着语义完全不同的三条流,外加一条「丢了也无所谓」的易失流:
| 通道 | 传什么 | 载体 | 语义 |
|---|---|---|---|
| ① 有序消息日志 | 对话正文(密文) | 写读走 REST /v3/sessions/:id/messages;推送走 socket update 事件、body.t = 'new-message' | 只追加,每条一个会话内自增的 seq |
| ② 状态版本号 | 会话元数据 / agentState(密文) | socket update-metadata / update-state(带 ack);推送走 update、body.t = 'update-session' | 单值覆盖,乐观并发 |
| ③ 实时 RPC | 调用参数与返回值(密文) | socket rpc-register / rpc-call / rpc-request | 一问一答,不落库 |
| ④ 易失事件 | 在线、思考中、token 用量 | socket ephemeral 事件 | 尽力而为,丢了就丢了 |
为什么「消息」要用 REST 写而不是 socket 发? 因为 ① 需要可重放的持久顺序:
写入要落库、要分配 seq、要能按 seq 回头补拉。socket 只负责「提速」——
告诉你有新东西了,顺便捎上内容;真正的正确性由 REST 兜底。这是本章最重要的一条主线,下一节展开。
3. 通道一:有序消息日志
它要解决的小问题: 断线重连之后,我怎么知道自己落下了哪几条消息?
3.1 思路:给每条消息发一个号码牌
服务器给每个会话维护一个自增计数器,每写入一条消息就发一个号。
allocateSessionSeqBatch(packages/happy-server/sources/storage/seq.ts:30-45)用一次
session.seq += count 的原子自增拿到一整段连续号码,再切成 [start … end] 分给这一批消息——
一次数据库往返搞定整批,而不是逐条 allocateSessionSeq。
客户端只需记住一个数:lastSeq。重连后拿着它问服务器「比这个大的都给我」。
3.2 收发双循环
CLI 端把「收」和「发」做成两个互不阻塞的独立循环,各自由一个 InvalidateSync 驱动
(packages/happy-cli/src/api/apiSession.ts:242-243):
┌──────────────── receiveSync ────────────────┐
socket 'connect' ─┤ │
seq 断号 ─┤ invalidate() ──► fetchMessages() │
│ GET /v3/.../messages │
└─────────────────────────────────────────────┘
┌──────────────── sendSync ───────────────────┐
enqueueMessage() ─┤ invalidate() ──► flushOutbox() │
│ POST /v3/.../messages │
└─────────────────────────────────────────────┘
InvalidateSync(packages/happy-cli/src/utils/sync.ts:14-27,_doSync 在 55-73)是个只有 70 行的小类,
但它是整个同步层的地基。它的语义是:「我脏了,请重跑」,而不是「请跑一次」。
- 正在跑的时候再
invalidate(),不会并发第二次,只置一个_invalidatedDouble标记; - 当前这轮跑完,看到标记就再跑一轮,然后清标记(
sync.ts:66-72); - 整个
_command外面包着backoff(packages/happy-cli/src/utils/time.ts:43),失败自动重试。
所以调用方永远不用关心「现在能不能跑」「跑失败怎么办」,只管喊脏。
这段演示它的核心想法:
// 示意,非源码:两条循环,socket 只是提速器
const receiveSync = new InvalidateSync(() => fetchMessages()); // 把服务器的新消息拉下来
const sendSync = new InvalidateSync(() => flushOutbox()); // 把本地待发队列推上去
socket.on('connect', () => receiveSync.invalidate()); // 一连上就补齐
socket.on('update', (u) => {
if (u.body.message.seq === lastSeq + 1) applyLocally(u); // 连号:直接用,零 HTTP
else receiveSync.invalidate(); // 断号:别猜,回去拉
});
重点看最后两行:socket 推送只在「号码正好接上」时被信任,否则一律退回 REST 全量补拉。
3.3 真实实现:快路径与回退
CLI 的 update 处理器(apiSession.ts:304-360)里,new-message 分支只有一个门槛:
if (typeof messageSeq !== 'number' || messageSeq !== this.lastSeq + 1 || data.body.message.content.t !== 'encrypted') {
this.receiveSync.invalidate();
return;
}
—— apiSession.ts:313-318。序号不连、或内容不是 encrypted 密文封套,就不猜,直接把 receiveSync 喊脏。
只有严格连号时才走下面的 decrypt + routeIncomingMessage + this.lastSeq = messageSeq(apiSession.ts:319-329)。
App 端是同一套判断:packages/happy-app/sources/sync/sync.ts:2278-2291,
incomingSeq === currentLastSeq + 1 才 enqueueMessages,否则 this.getMessagesSync(sid).invalidate()。
两端独立实现、结论一致,说明这是设计约定而非巧合。
3.4 回退路径:fetchMessages 的分页与防死循环
fetchMessages(apiSession.ts:581-643)是一个 after_seq 向前翻页的 while 循环:
- 每轮
GET /v3/sessions/:id/messages?after_seq=<lastSeq>&limit=100; - 服务端
take: limit + 1多取一条来判断hasMore(packages/happy-server/sources/app/api/routes/v3SessionRoutes.ts:114-120); - 停滞保护:若
hasMore为真但这一页的maxSeq没有前进,直接 break,避免无限循环 (apiSession.ts:631-637);App 端有一份对应的fetchForwardSince,同样的保护在sync.ts:2036-2040。
单条消息解密失败只跳过这一条并记日志(apiSession.ts:620-626),不会让整轮同步崩掉。
重连时还有个开关: skipExistingMessages()(apiSession.ts:919-921)置位后,下一次 fetchMessages 只推进
lastSeq、不投递消息(apiSession.ts:582-587, 611)——用于「接管一个已存在的会话」时不把历史重放一遍,
细节见 03 本地与远程双模态。
3.5 发送侧:localId 去重 + 批量
enqueueMessage(apiSession.ts:675-684)做三件事:加密、给一个 randomUUID() 当 localId、喊 sendSync 脏。
const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content));
this.pendingOutbox.push({ content: encrypted, localId: randomUUID() });
—— apiSession.ts:676-680。localId 是客户端生成的幂等键,这是「重试安全」的全部秘密。
flushOutbox(apiSession.ts:647-673)按 MAX_OUTBOX_BATCH_SIZE = 50(apiSession.ts:645)切批,
而且从队尾切:
const batchStart = this.pendingOutbox.length - batchSize;
const batch = this.pendingOutbox.slice(batchStart);
—— apiSession.ts:652-653。注释说明了意图(apiSession.ts:648-649):先发最新的,让用户立刻看到近期活动,
积压的旧消息在后续批次里回填。
服务端的去重在一个事务里完成(v3SessionRoutes.ts:158-211):
- 请求体内先按
localId去重,同一批里重复的只留第一条(v3SessionRoutes.ts:148-155); - 事务内查
sessionMessage里已存在的localId(v3SessionRoutes.ts:160-172); - 只给新的那些分配 seq 并插入,已存在的原样回显(
v3SessionRoutes.ts:181-205)。
于是客户端可以无脑重试整批,不会产生重复消息。
广播在事务之外(v3SessionRoutes.ts:215-234):逐条 allocateUserSeq 拿一个用户级 update 序号,
buildNewMessageUpdate 打包,再 emitUpdate 到 all-interested-in-session 房间。
3.6 一个可预期的浪费
这条 REST 写入路径没有 skipSenderConnection(v3SessionRoutes.ts:229-233)——
HTTP 请求本来就没有对应的 socket 可跳过。所以 CLI 自己 POST 上去的消息,还会经 socket 原路广播回它自己的会话房间。
CLI 收到后,UserMessageSchema 和 FileEventMessageSchema 都不匹配,落到 this.emit('message', message)
(apiSession.ts:551-579);生产代码里没有任何地方订阅 ApiSessionClient 的 'message' 事件,等于丢弃 (inferred)。
3.7 CLI 与 App 的取历史策略不同
CLI(apiSession.ts:fetchMessages) | App(sync.ts:fetchMessages) | |
|---|---|---|
| 首次拉取方向 | 从 after_seq=0 向前 | 从 before_seq=2147483647 向后取最新一页 |
| 用意 | CLI 只关心「新指令」,历史在本地转录文件里 | 打开长会话不能卡在拉全量历史上 |
| 关键符号 | fetchMessages | fetchInitialLatestPage(sync.ts:1982-2011)、loadOlderMessages(sync.ts:2073) |
| 触发时机 | 连接建立 / seq 断号 | onSessionVisible(sync.ts:292),按会话懒加载 |
服务端为此提供了互斥的两个游标参数,契约写在 v3SessionRoutes.ts:8-25:
after_seq 正向升序、before_seq 反向降序,两者不能同时给。
4. 通道二:状态版本号(乐观并发)
它要解决的小问题: 手机和 CLI 同时改会话标题,谁赢?怎么让输的那个知道自己输了?
4.1 思路:比对版本号,输了就重来
会话上有两个可变值,各自带一个版本号:
| 字段 | 版本号 | 装什么 | 谁常改 |
|---|---|---|---|
metadata | metadataVersion | 路径、标题、summary、lifecycleState | 两端都改 |
agentState | agentStateVersion | 权限请求、controlledByUser 等运行时状态 | CLI 为主 |
写入协议是 compare-and-set:客户端把「我以为的版本」一起发上去,服务器只在版本相等时才写。
4.2 服 务端:两道防线
sessionUpdateHandler(packages/happy-server/sources/app/api/socket/sessionUpdateHandler.ts)先读一次比对
(:33-38),再用一句带版本条件的 updateMany 落库:
const { count } = await db.session.updateMany({
where: { id: sid, metadataVersion: expectedVersion },
data: { metadata, metadataVersion: expectedVersion + 1 }
});
if (count === 0) { callback({ result: 'version-mismatch', ... }); return null; }
—— sessionUpdateHandler.ts:40-50。第一次读比对只是快速失败;真正的防线是 where 里的版本条件,
count === 0 说明在读和写之间被别人抢先了。update-state 是完全对称的一份(sessionUpdateHandler.ts:106-116)。
写成功后广播 buildUpdateSessionUpdate(sessionUpdateHandler.ts:58-64),只带新值和新版本号。