摘要
WebSocket 中继是许多需要"手机远程操控桌面"场景的基础设施,但开源社区中生产级的实现并不多见。Orca 是一个开源 IDE(MIT 协议),其 Cloud Relay 子系统实现了一套完整的 WebSocket 中继服务,支持手机 companion app 在任意网络环境下远程监控和操控桌面端的 AI 编码助手。本文基于 Orca 仓库中 cloud/ 目录的源码,深入分析其 Director/Cell 两层架构、双向帧转发与背压控制、端到端加密配对机制、断连恢复策略,以及跨版本线路兼容性保障。
1. 问题场景
Orca 桌面端可以同时运行多个 AI 编码助手。与之配对的手机 companion app(iOS / Android)允许用户在外出时查看 agent 的工作状态、发送 prompt、接收完成通知。
核心的通信难题是:手机如何连到用户的电脑?
- 局域网直连:只在同一 Wi-Fi 下可用,离开局域网即失效;家庭网络和企业网络普遍存在 NAT,手机无法直接发现电脑的内网地址
- P2P 打洞:STUN/TURN 方案实现复杂,对称型 NAT 下成功率不高
- 云端中继:手机和电脑各自与云端服务器保持长连接,服务器在中间透明转发
Orca 采用第三种方案,同时支持局域网直连作为备选(local-only 模式)。Cloud Relay 是这套云端中继的完整实现。
2. 架构概览
2.1 独立的工程工作区
Cloud Relay 的代码位于 Orca 仓库的 cloud/ 目录,是一个独立的 pnpm workspace ,拥有自己的 package.json、pnpm-lock.yaml 和 tsconfig.base.json。所有构建和测试命令在 cloud/ 目录下执行,与桌面端 Electron 应用完全解耦。
perl
cloud/
├── apps/
│ ├── relay/ # Relay 中继服务(Director 或 Cell)
│ ├── push/ # Push 推送网关(APNS + FCM)
│ ├── relay-fence-broker/ # 变更租约服务
│ └── relay-ops/ # 运维控制台
├── packages/
│ ├── relay-contract/ # 线路协议(桌面端/手机/relay 共用)
│ └── push-contract/ # 推送协议
└── infra/terraform/ # GCP 基础设施定义
2.2 三方参与者
scss
┌──────────┐ ┌────────────────┐ ┌──────────┐
│ 手机端 │──WebSocket──▶│ Cloud Relay │◀──WebSocket──│ 桌面端 │
│ (Phone) │ E2EE │ (Director+Cell)│ JWT │ (Host) │
└──────────┘ └────────────────┘ └──────────┘
│
┌────────┴────────┐
│ Push Gateway │
│ (APNS + FCM) │
└─────────────────┘
- 桌面端(Host):Orca 桌面应用,以 JWT 认证连接 relay,维护 control 和 data 两条 WebSocket 通道
- 手机端(Phone):Orca companion app,以 invite token 或 resume credential 连接 relay
- Relay:云端 WebSocket 中继,分为 Director 和 Cell 两个角色
- Push Gateway:独立的推送通知服务,通过 APNS(iOS)和 FCM(Android)发送离线通知
3. Director/Cell 两层架构
3.1 角色分离
Relay 服务的同一份代码(apps/relay/)通过环境变量 ORCA_RELAY_ROLE 决定运行角色:
typescript
ORCA_RELAY_ROLE: z.enum(['combined', 'director', 'cell']).default('combined')
- Director:入口节点,负责"host 应该连到哪个 cell"。维护全局的 host → cell 分配表,但不承载实时转发流量
- Cell:实际的数据平面节点,承载 host 和 phone 之间的 WebSocket 长连接和帧转发
- Combined:本地开发模式,一个进程同时充当 Director 和 Cell
这种分离的动机是水平扩展与区域就近分配。Director 是轻量级的分配服务,可以全球单点部署;Cell 是重 I/O 的转发节点,可以在多个区域部署多个实例。
3.2 连接流程
手机首次配对的连接流程如下:
- 手机携带 invite token 连接到 Director 的
/v1/connect/<hostId>端点 - Director 查找 invite,解析 host 的 cell 分配;若无分配则创建一个
- Director 向手机发送
relay-moved消息,指示其转向分配的 cell:
typescript
webSocket.send(JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: assignment.cellUrl,
assignmentEpoch: assignment.assignmentEpoch
}))
webSocket.close(RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell')
- 手机断开与 Director 的连接,转向 Cell 建立持久会话
- Cell 验证 invite,建立 host-phone 之间的 splice(拼接转发)
桌面端的连接更简单:直接以 JWT Bearer token 连接到分配的 Cell 的 /v1/host/control 端点,Cell 通过 JWKS 验证 token 后建立 control 通道。
3.3 三条 WebSocket 通道
Cell 上维护三种 WebSocket 连接,各有不同的认证方式和用途:
| 路径 | 参与者 | 认证 | 用途 |
|---|---|---|---|
/v1/host/control |
桌面 → Cell | JWT Bearer | 控制通道,桌面保持长连接 |
/v1/host/data/<connId> |
桌面 → Cell | connTicket | 数据通道,按 phone 连接建立 |
/v1/connect/<hostId> |
手机 → Cell | invite / resume token | 手机连接通道 |
Control 和 data 通道的分离允许桌面端在 phone 连接/断开时不必重建 control 通道,减少了认证开销。
4. SpliceForwarder:双向帧转发与背压控制
Relay 的核心职责是在 phone 和 host 之间透明转发 WebSocket 帧。这个转发器实现在 cloud/apps/relay/src/splice-forwarder.ts 中的 wireSplice() 函数。
4.1 直传快路径
当目标 socket 可写且缓冲区低于阈值时,帧直接转发,不经过队列:
typescript
source.on('message', (data, binary) => {
if (
queue.length === 0 &&
target.readyState === target.OPEN &&
target.bufferedAmount <= RELAY_ADMISSION_BUDGETS.spliceHighWaterBytes
) {
target.send(data, { binary })
input.onForwardedBytes?.(bytes)
return
}
// 进入排队路径...
})
4.2 排队与背压
当目标 socket 的 bufferedAmount 超过 high water mark(256KB)时,帧进入内存队列,同时暂停源 socket 的读取(TCP 级背压):
typescript
queue.push({ data, binary, bytes })
queuedBytes += bytes
wedgedSince ??= Date.now()
transport(source)?.pause() // TCP 级背压
刷新定时器每 25ms 尝试排空队列,当 bufferedAmount 降至 low water mark(64KB)以下时恢复源 socket 的读取:
typescript
while (
queue.length > 0 &&
target.bufferedAmount <= RELAY_ADMISSION_BUDGETS.spliceLowWaterBytes
) {
const frame = queue.shift()!
target.send(frame.data, { binary: frame.binary })
}
if (queue.length === 0) {
transport(source)?.resume()
}
4.3 三层过载保护
- Per-splice 队列硬上限(~8.25MB):单个 splice 的排队字节超过此值时立即断开
typescript
if (queuedBytes + bytes > RELAY_ADMISSION_BUDGETS.spliceHardQueuedBytes) {
close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'relay queue limit exceeded', 'queue-limit')
}
- 进程级全局字节预算 (64MB):
ProcessQueuedByteBudget跟踪所有 splice 的累计排队字节,防止整个进程 OOM
typescript
export class ProcessQueuedByteBudget {
reserve(bytes: number): boolean {
if (this.queued + bytes > RELAY_ADMISSION_BUDGETS.maxProcessQueuedBytes) return false
this.queued += bytes
return true
}
}
- 楔死检测(10 秒超时):如果排队状态持续 10 秒无法排空,判定链路楔死,强制断开
typescript
if (wedgedSince !== null &&
Date.now() - wedgedSince >= RELAY_ADMISSION_BUDGETS.spliceWedgedTimeoutMs) {
close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'wedged relay link', 'wedged')
}
4.4 TCP Nagle 优化
所有 WebSocket 连接在建立时关闭 Nagle 算法(setNoDelay(true)),减少小帧的传输延迟。这对交互式的 agent 状态更新尤为重要。
5. 连接准入与 DDoS 防护
Relay 实现了多层准入控制,定义在 cloud/packages/relay-contract/src/admission-budgets.ts 中:
typescript
export const RELAY_ADMISSION_BUDGETS = {
cloudRunConcurrency: 900, // Cloud Run 并发上限
maxPreAuthConnections: 45, // 全局未认证连接上限
maxPreAuthPerSource: 4, // 单 IP 未认证连接上限
maxPreAuthAttemptsPerSourcePerMinute: 30, // 单 IP 每分钟尝试次数
reservedHostControls: 100, // 为 host control 通道预留的连接数
reservedHostDataSockets: 150, // 为 host data 通道预留的连接数
maxProcessQueuedBytes: 64 * 1024 * 1024, // 进程级排队字节预算
// ...
}
关键的设计决策:
- Pre-auth 阶段单独限流:WebSocket 升级完成后、第一帧认证前的连接被单独计数。这防止了攻击者通过大量建立连接但不发送认证帧来耗尽资源
- Per-source 限制 :按源 IP(从
X-Forwarded-For取倒数第二跳)限制,防止单一来源的洪泛 - Host control 通道预留:即使 cell 的连接数已满,也为 host 的 control rebind(重连)预留空间。这确保桌面端在手机端大量重连时不会被挤出
- LRU 清理:per-source 的计数器 map 限制在 4096 条,超出时清理最旧条目,防止源 IP churn 本身成为内存攻击
协议限制定义在 protocol-limits.ts 中:
typescript
export const RELAY_PROTOCOL_LIMITS = {
firstFrameDeadlineMs: 2_000, // 第一帧必须在 2 秒内到达
maxFrameBytes: 8 * 1024 * 1024, // 单帧最大 8MB
inviteTtlMs: 10 * 60 * 1000, // invite token 10 分钟过期
inviteMaxAttempts: 5, // invite 最多 5 次尝试
resumeTtlMs: 30 * 24 * 60 * 60 * 1000, // resume token 30 天过期
controlPingIntervalMs: 15_000, // control 通道 15 秒 ping
controlSilenceTimeoutMs: 75_000, // 75 秒无响应判定断开
}
maxFrameBytes 设为 8MB 的原因在注释中说明:桌面端的 worktree catalog 响应在大型工作区中已经超过 1MB,一个过小的帧限制会在每次重连时杀死会话。
6. 端到端加密与配对
6.1 配对流程
配对的入口是桌面端生成一个 QR 码,手机扫码完成配对。QR 码编码为 orca://pair?code=<base64url> 格式的深度链接。
PairingOffer(v2)的结构定义在 src/shared/mobile-relay-pairing-offer.ts:
typescript
export const PairingOfferSchema = z.object({
v: z.literal(PAIRING_OFFER_VERSION), // 版本号 = 2
endpoint: z.string(), // 桌面端可达地址
deviceToken: z.string(), // 设备标识
publicKeyB64: z.string(), // Curve25519 公钥(Base64)
pairedDeviceId: z.string().optional(), // 已配对设备 ID
scope: z.enum(['mobile', 'runtime']).optional(),
relay: z.object({ // 可选的 relay 信息
directorUrl: z.string(), // Director 地址
cellUrl: z.string(), // Cell 地址
relayHostId: z.string(), // Relay 上的 host 标识
inviteToken: z.string(), // 一次性 invite token
inviteExpiresAt: z.number(), // 过期时间
e2eeFraming: z.literal(2), // E2EE 帧版本
}).optional()
})
6.2 两种连接模式
src/shared/mobile-pairing-connection-mode.ts 定义了两种模式:
automatic(Anywhere):通过 cloud relay 连接,需要桌面端登录。适用于任何网络环境local-only:局域网直连,不经 relay。不需要登录,但要求手机和电脑在同一网络
当用户选择 Anywhere 模式但桌面端未登录时,自动降级为 local-only:
typescript
export function effectiveMobilePairingConnectionMode(args: {
preferred: MobilePairingConnectionMode
signedIn: boolean
}): MobilePairingConnectionMode {
if (args.preferred === 'automatic' && !args.signedIn) {
return 'local-only'
}
return args.preferred
}
6.3 E2EE 保障
所有通过 relay 转发的消息使用 E2EE v2 帧格式加密。加密密钥在配对时通过 QR 码中的 Curve25519 公钥协商。Relay 服务器看不到消息内容,只做透明转发。
Relay offer 中对公钥有额外的校验------必须是规范的 32 字节 Base64,因为 relayHostId 是从公钥字节导出的:
typescript
.superRefine((offer, ctx) => {
if (offer.relay && !isCanonicalBase64Key(offer.publicKeyB64)) {
ctx.addIssue({
code: 'custom',
path: ['publicKeyB64'],
message: 'Relay offers require a canonical 32-byte public key'
})
}
})
7. 断连恢复策略
7.1 Close Code 体系
Relay 定义了六个自定义 WebSocket close code,每个对应明确的语义和恢复策略:
typescript
export const RELAY_CLOSE_CODE = {
BAD_OUTER_CREDENTIAL: 4401, // 凭证无效
HOST_OFFLINE: 4404, // 桌面端不在线
PEER_DROPPED: 4408, // 对端连接断开
WRONG_CELL: 4409, // 连到了错误的 cell
LIMIT_EXCEEDED: 4429, // 超过限制
DRAINING: 4503 // Cell 正在排空(维护/迁移)
}
7.2 恢复策略映射
src/shared/mobile-relay-close-codes.ts 将每种 close code 映射到精确的恢复动作:
| Close Code | 恢复策略 | 说明 |
|---|---|---|
4401 BAD_OUTER_CREDENTIAL |
disable-relay-credential |
禁用 relay 凭证,直连不受影响 |
4404 HOST_OFFLINE |
retry-after-host-offline |
带 full jitter 的退避重试 |
4408 PEER_DROPPED |
reconnect-fresh-e2ee |
重新建立 E2EE 会话 |
4409 WRONG_CELL(phone invite) |
resolve-invite-through-director-ws |
通过 Director 的 WebSocket 重新解析,要求严格更新的 epoch |
4409 WRONG_CELL(phone resume) |
resolve-resume-through-director-post |
通过 Director 的 HTTP POST 解析 |
4429 LIMIT_EXCEEDED |
backoff |
带 full jitter 的退避 |
4503 DRAINING |
resolve-configured-director |
重新从 Director 获取分配 |
每种恢复策略都标注了是否需要 full jitter(随机退避),以避免大量客户端同时重连造成的"惊群效应"。
7.3 凭证生命周期
首次配对使用 invite token(一次性,10 分钟过期,最多 5 次尝试)。成功后,手机获得一个 resume token,用于后续重连:
- Resume token 有效期 30 天
- 支持版本号和 grace period(新旧 token 短期内并存)
- 完整的 install → resume → confirm 生命周期
凭证合约定义在 src/shared/mobile-relay-credential-contract.ts,包含 resume token hash、版本号、过期时间等字段,均通过 zod schema 严格校验。
8. Push 通知网关
当手机不在前台(app 被杀、锁屏)时,agent 完成任务或需要用户关注的事件通过推送通知触达。
Push Gateway 是一个独立的 Cloud Run 服务 (cloud/apps/push/),与 relay 共享仓库但独立部署。
8.1 认证机制
手机不直接与 Push Gateway 通信。认证流程是:
- 桌面端使用与 relay 相同的 X25519 密钥,向 Push Gateway 回答加密 challenge
- 成功后获得 24 小时的 session
- 桌面端将手机的原生推送 token(APNS / FCM)注册到 Push Gateway
- 当需要推送时,桌面端向 Push Gateway 发请求,Gateway 向手机推送
8.2 推送简化
Agent 在桌面端有四种状态(working / blocked / waiting / done),但推送到手机时简化为两种:
typescript
export const MOBILE_PUSH_AGENT_STATES = ['needs-input', 'finished'] as const
推送来源有三类:
typescript
export const MOBILE_PUSH_SOURCES = ['agent-task-complete', 'terminal-bell', 'plugin'] as const
8.3 推送过滤
用户可以配置推送行为:
typescript
export type MobilePushFilter = {
onlyWhenDesktopAway?: boolean // 仅当桌面端不活跃时推送
sound?: boolean // 是否播放提示音
}
Gateway 的日志严格限制在聚合计数器层面:token、通知标题、通知正文和完整的 host 指纹永远不会出现在日志中。
9. 线路兼容性保障
9.1 问题
桌面端和 relay 服务是独立更新的。用户可能在新版桌面端配对一台旧版 relay 服务的 cell,或反之。混合版本是常态,不是异常。
9.2 四条规则
docs/reference/remote-wire-compatibility.md 定义了严格的协议演进规则:
Rule 1------新增可选 JSON 字段是安全的。 所有 JSON payload 的解码器都忽略未知键(zod .strip()),旧版 peer 简单地不读它。
Rule 2------新增 stream opcode 不安全,必须协商。 解码器对未知 opcode 返回 null 并静默丢弃,发送方永远不会知道功能失效。新 opcode 必须在握手中声明,对端确认后才能发送。
Rule 3------改变 host 发布的内容,即使帧格式不变,也是破坏性变更。 因为客户端对帧内容有反应。停止填充的字段、改变含义的值、停止发送的帧都属于此类。
Rule 4------枚举值集合是 wire surface,未知值必须降级而非拒绝。 一个严格的 z.enum 会导致新增值时整个响应被拒绝。应使用 openEnum,将未知值降级到一个安全的默认值。
9.3 自动化验证
项目中有跨版本兼容性测试,将当前代码与最新 release tag 的代码双向配对,覆盖终端流(terminal stream)和结构化 agent session 两个通信表面。测试会在帧被对端解码器拒绝(Rule 2)、帧序列变化(Rule 3)时失败。
10. 部署模型
10.1 生产环境
Orca 官方在 Google Cloud 上运营 relay 服务:
- 计算:Cloud Run(容器化部署)
- 数据库:Cloud SQL(PostgreSQL)
- 基础设施 :Terraform 管理(
cloud/infra/terraform/) - CI/CD:25 个 GitHub Actions workflow 管理部署、扩容、区域迁移、监控
所有 workflow 都有 vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' 的门控,在开源仓库中默认关闭。
10.2 本地开发
本地开发使用 SQLite 替代 PostgreSQL,无需外部依赖:
bash
cd cloud
pnpm install
pnpm build
pnpm test
需要 PostgreSQL 集成测试时:
bash
docker run --rm -d --name orca-relay-pg \
-e POSTGRES_HOST_AUTH_METHOD=trust \
-e POSTGRES_DB=orca_relay_test \
-p 55440:5432 postgres:16-alpine
ORCA_RELAY_TEST_POSTGRES_URL=postgres://postgres@127.0.0.1:55440/orca_relay_test \
pnpm --filter @orca-cloud/relay test
唯一的必需配置是 ORCA_RELAY_ASSIGNMENT_SIGNING_KEY(≥32 字节),其余环境变量有本地默认值。
10.3 普通用户
普通用户无需自行部署 relay。Orca 桌面端默认连接官方运营的 relay 服务。选择 "Local Only" 模式时则完全不经 relay,走局域网直连。
11. 总结与可复用的设计模式
Orca Cloud Relay 是一个面向特定场景的 WebSocket 中继实现,但其中几个设计模式具有较高的通用性:
| 设计模式 | 适用场景 |
|---|---|
| Director/Cell 分层 | 任何需要全局分配 + 区域就近转发的实时通信系统 |
| 三层背压控制(per-splice 队列 + 进程级预算 + 楔死检测) | WebSocket / TCP 代理、消息队列中间件 |
| Pre-auth 独立计数 + per-source 限流 | 面向公网的任何长连接服务 |
| Close code → 恢复策略的确定性映射 | 需要可靠重连的移动端 / IoT 场景 |
| 线路兼容性四规则 | 客户端与服务端独立升级的任何 RPC / 流式协议 |
| E2EE 透明中继 | 对中间人不信任的中继场景 |
值得注意的是,relay-contract 包(cloud/packages/relay-contract/)将协议常量、close code、准入预算、splice 状态机等提取为桌面端、手机端和 relay 服务端的共享依赖。这意味着三方对协议的理解在编译期就是统一的,减少了手动同步的风险。
对于正在设计类似"手机远程操控桌面"或"IoT 设备云端中继"系统的工程团队,Orca Cloud Relay 的完整实现提供了一个可供参考的生产级样本。
本文基于 Orca 开源仓库(MIT 协议)v1.4.197 版本的 cloud/ 目录源码分析。