跳到主要内容

数据截至 (上游 commit 4ec6bbf5884e)

一个 runtime,多种客户端 — RPC 内核与远程执行

30 秒导读: Orca 的所有真本事都长在一个进程内的服务对象 OrcaRuntimeService 上。本章讲它怎么把自己"接出去":一张方法表 + 一层可插拔传输,让桌面 App、CLI、手机、浏览器、另一台机器看到同一个 runtime、同一套语义


1. 先讲清楚问题

假设你在自己的 Mac 上开着 Orca 桌面 App,里面跑着六个并行 agent 的 worktree。现在你想:

  • 在终端敲 orca worktree ps 看看谁在跑;
  • 上班路上用手机看某个 agent 卡在哪、给它回一句话;
  • 让另一台服务器上的 CI 脚本连回你的 Mac 触发一次操作;
  • 顺手在浏览器里开同一份界面。

朴素做法是给每个入口写一套逻辑。那必然分叉:CLI 说 worktree 有 6 个、手机说 5 个,谁也不知道谁对。

Orca 的答案是把"能力"和"入口"彻底分开:

  • 能力只有一份 —— OrcaRuntimeServicesrc/main/runtime/orca-runtime.ts:2991),一个约 3.6 万行的单类,是 worktree / 终端 / git / agent 状态的唯一权威。
  • 对外只开一张方法表 —— ALL_RPC_METHODSsrc/main/runtime/rpc/methods/index.ts:47),把 40 个领域模块拍平成一个数组。
  • 入口只是"接线方式" —— 传输可换,方法表不变。

于是"五类客户端"之间的差别,被压缩成三个旋钮:

客户端走哪条传输凭什么进门能调多少方法
CLI(本机)Unix socket / Windows 命名管道orca-runtime.json 里的共享 authToken全部 538 个
桌面渲染进程(本机)Electron IPC,直接调服务对象同进程,不过 RPC 边界不适用
手机 AppWebSocket(局域网)或 Orca Relay(公网)逐设备令牌 + E2EE 握手白名单内 257 个
浏览器网页客户端同一个 WebSocket 端口(顺带托管静态文件)逐设备令牌(scope=runtime全部
另一台机器(CLI / 桌面连远端环境)WebSocket + 配对码配对码里的设备令牌 + E2EE全部

注意第二行:本机桌面渲染进程不走 RPC,它通过 Electron IPC 直接调同一个 OrcaRuntimeService。RPC 是"跨进程/跨机器"那一层的事。但当桌面要驱动远端环境时,它自己也变成 RPC 客户端(src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.tssrc/main/ipc/runtime-environment-transport-routing.ts:38 getRuntimeEnvironmentStatus)。

再往外还有第六种"客户端",但它反着来:SSH 远端主机。那台机器上没有 Orca,Orca 把一个单文件 daemon 投送过去,然后由桌面反向调用它。这条线用的是完全独立的一套协议,第 8 节单独讲。


2. 顶层全景

怎么读这张图:从左到右是一次调用的方向;中间那根竖线是唯一的安全边界,越过它的每一帧都被鉴权和校验过。

客户端 传输(可插拔) RPC 服务器 能力
───────── ──────────────── ───────────── ──────────
① CLI ────────────→ Unix socket / pipe ─┐
② 手机 ───────────→ WebSocket ─┤
③ 网页 ───────────→ (同一 WS 端口) ─┼→ OrcaRuntimeRpc ──→ RpcDispatcher
④ 远端 CLI/桌面 ──→ WebSocket + 配对码 ─┤ Server ↓
⑤ 手机(公网) ─────→ CloudRelay 反向拨号 ─┘ ·鉴权 方法表(538)
·限额 ↓
·保活 OrcaRuntimeService

各部件一句话职责:

部件干什么在哪个文件
OrcaRuntimeService唯一权威:worktree / 终端 / git / agent 状态src/main/runtime/orca-runtime.ts:2991
OrcaRuntimeRpcServer唯一安全边界:起传输、验令牌、发凭据、控限额src/main/runtime/runtime-rpc.ts:481
RpcDispatcher查表、校验参数、区分一问一答 / 流式src/main/runtime/rpc/dispatcher.ts:31
ALL_RPC_METHODS方法表本身,一个 grep 点看清全部对外面src/main/runtime/rpc/methods/index.ts:47
RpcTransport传输契约,只有 start() / stop()src/main/runtime/rpc/transport.ts:19
RuntimeClientCLI 侧的客户端,本地走 socket / 远程走 WSsrc/cli/runtime/client.ts:38
Orca Relay(云)手机在公网时的反向拨号中继src/main/runtime/relay/desktop-relay-service.ts:51
SSH relay daemon投送到远端主机的单文件守护进程src/relay/relay.ts:533

主线走一遍(以 orca worktree ps 为例,不进代码):

  1. CLI 读 ~/…/orca-runtime.json,拿到 socket 路径和 authToken
  2. 往 socket 写一行 JSON(含 id / authToken / method / params);
  3. 服务器校验令牌 → 判断是不是长轮询 → 交给 dispatcher;
  4. dispatcher 查表、用 zod 校验 params、调 handler;
  5. handler 问 OrcaRuntimeService 要答案;
  6. 一行 JSON 回来,CLI 校验 idruntimeId 后打印。

3. 方法表:538 个方法怎么被声明和路由

这节讲"一张表"具体长什么样。

3.1 一个方法 = 名字 + schema + handler

Orca 没有用现成的 RPC 框架,方法就是一个三字段对象:

// 示意,非源码
const worktreePs = defineMethod({
name: 'worktree.ps', // 表里的键
params: z.object({ limit: OptionalPositiveInt }), // 入口校验
handler: (params, ctx) => ctx.runtime.listWorktreeProcesses(params) // 干活
})

真实定义在 src/main/runtime/rpc/core.ts:125 defineMethod,流式版本在 :159 defineStreamingMethod。区别只有一个 stream: true 标志位——dispatcher 靠它决定走"返回一个 Promise"还是"反复 emit()"(core.ts:172 isStreamingMethod)。

统计到本 commit:526 个普通方法 + 12 个流式方法。12 个流式的全是订阅型:

流式方法推什么
terminal.subscribe / terminal.multiplex终端输出
session.tabs.subscribe / subscribeAll标签页快照
notifications.subscribe通知
accounts.subscribe账号额度变化
nativeChat.subscribe原生聊天会话
runtime.clientEvents.subscribe客户端事件
browser.screencast浏览器录屏帧
files.watch文件变更
jira.getIssueStream / jira.issueCommentsStreamJira 增量

3.2 注册表:重名当场炸

buildRegistrycore.ts:178)把数组变成 Map,遇到重名直接 throw new Error('duplicate_rpc_method:...')core.ts:181-183)。这是启动期硬失败,不是运行时静默覆盖——40 个领域模块各自维护自己的方法数组,没有这道闸就会出现"某个模块偷偷抢了另一个模块的方法名"。

ALL_RPC_METHODS 的注释直说了它的定位:一个 grep 点,用来审计安全边界或接新 CLI 命令(methods/index.ts:44-46)。

3.3 参数校验刻意"宽容"

src/main/runtime/rpc/schemas.ts 里那批组合子有点反直觉——它们不报错,而是把不合法的值悄悄变成 undefined

// src/main/runtime/rpc/schemas.ts:13 OptionalFiniteNumber
export const OptionalFiniteNumber = z
.unknown()
.transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined))

原因写在文件头(schemas.ts:8-11):老 handler 把非数字 / NaN 的 limit 当"不限制"而不是"错误",迁移到 zod 时必须保住这个行为,否则老 CLI 传字符串数字就全挂。想要严格的,用 requiredStringschemas.ts:71)、requiredNumberschemas.ts:82)显式声明。

还有个更细的例子:TriStateLinkedIssueschemas.ts:48)区分 undefined(不改)/ null(清空)/ number(设置)三态——JSON 里这三个是不同的东西,schema 必须一起保住。

3.4 RpcContext:这次调用是谁打来的

handler 拿到的第二个参数是"调用现场"。它是差异化行为的唯一合法来源——不能从用户 params 里读身份:

字段含义谁会用
runtime服务对象本体所有 handler
signal客户端断线时 abort长轮询 handler
connectionId单个 WebSocket 的 id订阅清理
clientId设备令牌设备级状态清理
pairedDeviceId可吊销的设备身份导航状态归属
clientKind'mobile' 还是 'runtime'手机端裁剪 payload
sendBinary / registerBinaryStreamHandler二进制旁路终端帧

定义在 src/main/runtime/rpc/core.ts:64。每个字段上面的注释都写了"为什么不能从别处拿",比如 pairedDeviceId 那条:导航按可吊销的设备身份索引,绝不按承载凭据或临时 socket id(core.ts:74-75)。


4. 传输层:契约只有两个方法

4.1 RpcTransport 小到不像话

// src/main/runtime/rpc/transport.ts:19
export type RpcTransport = {
start(): Promise<void>
stop(): Promise<void>
}

onMessage 故意不在契约里。文件头解释了原因(transport.ts:1-8):各传输的消息签名不一样——Unix 传输多带一个 RpcMessageContext,WebSocket 多带 ws 句柄用于绑定身份。需要这些扩展的调用方持有具体类型,而不是 RpcTransport

RpcMessageContext 只有两样东西(transport.ts:14):

  • signal —— 连接断开时 abort,让长轮询立刻退出而不是耗完 timeout;
  • startKeepalive —— 按需开保活定时器。短 RPC 从不调用它,所以不付 setInterval 的开销。

4.2 Unix socket / 命名管道:本机 CLI 的门

先解决"怎么找到它"。 服务器启动时算出一个端点名并写进元数据文件。createRuntimeTransportMetadatasrc/main/runtime/runtime-rpc.ts:1791 开始,POSIX 分支的 return 在 :1551-1554

// src/main/runtime/runtime-rpc.ts:1551-1554
return {
kind: 'unix',
endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`)
}

Windows 走命名管道 \\.\pipe\orca-${pid}-${suffix}:1545-1549),注释点明了取舍:命名管道没有 Unix socket 的 chmod 加固,所以用 per-runtime 后缀避免端点名可猜(runtime-rpc.ts:1801)。

元数据文件 orca-runtime.jsonwriteSecureJsonFile 写成 0600(src/main/runtime/runtime-metadata.ts:5src/shared/secure-file.ts:115),里面装着 authToken:一个 24 字节随机 hex,每次进程启动重新生成(runtime-rpc.ts:499)。能读这个 0600 文件 = 能调 runtime,安全模型就是文件权限。

孤儿 socket 清扫。 SIGKILL / OOM 会跳过 stop(),留下一地 .sock 文件。启动时扫一遍(runtime-rpc.ts:1755 sweepOrphanedRuntimeSockets):正则匹配文件名取出 pid(RUNTIME_SOCKET_NAME_REGEX:1499),用 process.kill(pid, 0) 探活——ESRCH 表示进程已死,删;EPERM 表示是别人的进程,不碰。自己的 pid 直接跳过,注释写着"这里一个 bug 就会 rmSync 掉我们正要绑定的 socket"(:1518)。

正则和构造函数必须锁步,这一点有单测强制(:1498 的注释)。

帧与限额。 UnixSocketTransportsrc/main/runtime/rpc/unix-socket-transport.ts:32)用换行分隔的 JSON,四个硬上限:

限制为什么
单帧最大1 MBruntime 活在 Electron 主进程,不能让本地客户端撑爆缓冲区(:138-140
空闲超时30 s断线回收
最大连接32server.maxConnections
保活间隔10 s只在长轮询时启用

超限时的处理很干净:置 oversized 标志,用空消息触发 handler 返回 request_too_large,写完就 end():141-147)。服务器侧对应地把空消息识别成"太大"(runtime-rpc.ts:1525-1528)。

4.3 WebSocket:手机、网页、远端机器共用一个端口

WebSocketTransportsrc/main/runtime/rpc/ws-transport.ts:46)默认监听 0.0.0.0:6768runtime-rpc.ts:57)。三件事值得讲。

第一,端口必须跨重启稳定。 已配对的手机存的是 ws://ip:port,端口一变就永久失联。所以有个持久化的 fallback 端口文件(src/main/runtime/rpc/ws-fallback-port-store.ts:12)。绑定顺序如下:

候选端口顺序
────────────
默认(STA-1511): [ 持久化 fallback ] → [ 首选端口 ] → [ OS 随机 ]
serve --port: [ 首选(pin) ] → [ fallback ] → [ OS 随机 ]

代码在 ws-transport.ts:141-171preferPinnedPort 只在 orca serve --port 时打开,注释解释了两个 issue 的角力:默认 fallback 优先是为了配对稳定(STA-1511),而显式 --port 必须能压过陈旧 fallback(#8535)。写回 fallback 的时机在 runtime-rpc.ts:1195-1198——只有实际绑定端口≠请求端口时才写。

第二,未认证连接不能白占位子。 三层防护:

机制挡什么
MAX_WS_CONNECTIONS128已升级的 WS 连接数(ws-transport.ts:11
MAX_TCP_CONNECTIONS256升级前的裸 socket 描述符(:13
PRE_AUTH_TIMEOUT_MS10 s沉默但会自动 pong 的客户端永久占坑(:298-303
心跳 ping/pong15 s手机后台挂起不发 FIN,靠 OS keepalive 要等约 2 小时(:24-25

超容量时先 close(1013),1 秒后强制 terminate()——因为半开的手机可能永远不 ack(:209-215)。

第三,同一个 HTTP server 顺带托管网页客户端。 staticRoot 传进来时挂一个静态处理器(ws-transport.ts:173-180static-web-client-handler.ts:20),路径白名单只放行 /web-index.html/assets/ 前缀(:6-7)。配对 URL 会被改写成 https://…/web-index.html#pairing=…——放进 fragment 而不是 query,因为配对 URL 带完整凭据,fragment 不进代理日志和 Referer 头(runtime-rpc.ts:162-169)。

4.4 CloudRelay:手机在公网时反向拨号

局域网之外没法直连。CloudRelayTransportsrc/main/runtime/rpc/relay-transport.ts:45)反过来:桌面主动向云端拨出一条 WebSocket。

手机 ──→ Relay Cell ←── 桌面(主动拨出)

└─ 控制面通知桌面:"有个 connId 找你" (conn-open)
桌面开一条 data socket 去接:/v1/host/data/<connId>

openConnection:129)建 socket,连上后立刻发一帧 host-data-auth 带上 connTicketgeneration:196-203)。attachDeadlineMs 是硬期限,到点没收到第一帧就 terminate 并释放(:152-160)。

一个踩过的坑写在 waitForClose:226):系统睡眠后半开的 relay socket 可能永远不发 'close',无限等会卡死 stop() 进而卡死退出应用(#9447)。所以有 5 秒硬期限(RELAY_SOCKET_CLOSE_TIMEOUT_MS:9),超时后强行 finalize 并把 socket 隔离起来吞掉迟到的 error(quarantineDetachedSocket:279)。

4.5 四个传输横向对比

Unix/管道WebSocketCloudRelay(对照)SSH relay
谁发起客户端连本机客户端连桌面桌面拨云端桌面 SSH 到远端
帧格式NDJSONWS 文本/二进制WS 文本/二进制自有 13 字节头
鉴权共享 authToken逐设备令牌 + E2EE同左 + host proof版本 + endpoint 凭据
上限1 MB / 32 连接1 MB / 128 连接1 MB / 每 conn 一 socket16 MB / 帧
一次请求一问一答长连接多路复用同 WS长连接多路复用
方法表同一张同一张(手机再过白名单)同一张另一张(见 §8)

5. 线上帧:向前兼容与保活

5.1 三种帧,一个 union

src/shared/runtime-rpc-envelope.ts:52 定义了客户端解码时唯一认识的形状:

成功帧 { id, ok: true, result, _meta: { runtimeId } }
失败帧 { id, ok: false, error: { code, message, data? }, _meta? }
保活帧 { _keepalive: true }

流式方法还会在成功帧上加 streaming: truedispatcher.ts:237),告诉客户端"后面还有"。

5.2 .strip() 是刻意的前向兼容策略

每个子 schema 都挂了 .strip()。注释直说原因(runtime-rpc-envelope.ts:7-8):客户端和 runtime 各自独立升级,所以要"丢掉新增字段,但仍校验每一个已知判别式和字段"。

换句话说:新版 runtime 给帧加字段,旧版 CLI 不会因为 unknown key 而炸;但如果 ok 判别式或 _meta.runtimeId 缺了,仍然当作坏帧。这条策略让"文件放在 src/shared/ 而不是塞进某个客户端"成为必然——CLI、桌面、未来的非 Electron 壳都要用同一份(:1-3)。

5.3 保活帧怎么救活一个 10 分钟的等待

问题:服务器侧 socket 30 秒空闲就断,客户端默认 60 秒超时。可 orchestration.ask 默认要等 600 秒。

解法是双向刷新计时器

服务器 客户端
│ ── 长轮询开始,startKeepalive() ──→│
│ ── {"_keepalive":true}\n ─────────→│ timeout.refresh()
│ (每 10s 一帧) │
│ ── {"_keepalive":true}\n ─────────→│ timeout.refresh()
│ ── { id, ok:true, result } ───────→│ settle
  • 服务器侧:startKeepalive() 只在长轮询被识别后才调用(runtime-rpc.ts:1542-1544);定时器 unref(),不让它单独吊住进程(unix-socket-transport.ts:211-213)。
  • 客户端侧:读帧循环里先走快路径 isKeepaliveFrame(raw)timeout.refresh()src/cli/runtime/transport.ts:125-129),不跑完整 schema。

5.4 客户端还校验两件事

拿到终帧后 CLI 做两次核对(transport.ts:158-177):

  • response.id !== requestIdinvalid_runtime_response
  • response._meta.runtimeId !== metadata.runtimeId"runtime 在请求飞行途中换人了,请重试"

第二条挡的是"你连上的 socket 属于一个刚被替换掉的 runtime 实例"。配套的服务器侧机制是元数据所有权守望:每 10 秒检查一次 orca-runtime.json 还是不是自己写的,被死进程的记录顶掉就重新发布(runtime-rpc.ts:1222-1239runtime-metadata-ownership-watch.ts:17)。

还有个反直觉的收尾:stop() 故意不删元数据文件——共享 userData 目录下可能还有另一个活着的 runtime,删了会把它抹掉(runtime-rpc.ts:1517)。


6. 长轮询与限额:别让等待的人饿死干活的人

6.1 问题

有三类方法会阻塞很久

方法阻塞在等什么典型时长
terminal.wait终端里的命令跑完秒~分钟
orchestration.check --wait编排状态变化秒~分钟
orchestration.ask人或 agent 回一句话默认 600 秒

一队 agent 同时 ask,32 个连接槽瞬间被占光,status.get 这种毫秒级调用就再也进不来了。

6.2 两级配额

长轮询总额度 16
┌──────────────────────────────────────┐
│ ask 子额度 8 │ wait / check 可用 │
└──────────────────────────────────────┘
↑ 满了 → runtime_busy ↑ 满了 → runtime_busy
短 RPC 完全不进这个计数器,走独立的 32 连接上限
  • LONG_POLL_CAP = 16,注释说明是"32 连接预算的一半,长轮询不能饿死短 RPC"(runtime-rpc.ts:154);
  • ASK_LONG_POLL_SHARE = 0.5 推出 askLongPollCap = 8,且刻意不做成可配置——"预留比例必须对任何调用方选的 cap 都成立"(:539-540);
  • 分类器只有一个:longPollClassOf:433),terminal.wait'wait'orchestration.ask'ask'orchestration.check 只有 params.wait === true 才算;
  • 闸门 admitLongPoll:1307)先查总额、再查子额,占位成功返回 null,否则返回拒绝文案变成 runtime_busy 错误。

Unix 和 WebSocket 两条路共用这一个闸(:1286:1423),释放放在 finally 里(:1299-1301:1476-1479)。

6.3 客户端也得配合放宽超时

服务器愿意等 10 分钟没用,客户端 60 秒就撤了。RuntimeClient.resolveMethodTimeoutMssrc/cli/runtime/client.ts:140)把方法级的内层 params.timeoutMs 加上 10 秒宽限,作为 socket 层超时:

// src/cli/runtime/client.ts:151
return Math.max(inner + LONG_POLL_CLIENT_GRACE_MS, this.requestTimeoutMs)

10 秒宽限的用途写在 :32 附近:吸收一个往返 + 最后一个保活窗口。

6.4 另一个方向的排队:远端环境调用池

上面是"进来的限额",还有"出去的限额"。桌面驱动远端环境时,每张卡片的状态刷新会和终端流、worktree 操作抢同一条传输。RuntimeRpcCallQueuePoolsrc/shared/runtime-rpc-call-queue.ts:54)按 selector 分队列,并把方法分成前台/后台两类:

维度
每 selector 并发8
其中后台并发2
每 selector 排队上限256
全局排队上限2048
内存预算REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES

后台方法的判定是一张硬编码列表(:35 isBackgroundRuntimeMethod):git.statusgit.historygithub.listWorkItemsworktree.prefetchCreateBase 之类的"装饰性刷新"。注释说得很直白:这些调用不能踩踏(stampede)真正的交互流量(:131-132)。

三种过载都返回同一个错误码 runtime_rpc_queue_overloaded,但带 scope: 'selector' | 'global' | 'memory' 区分原因(:9-16)。


7. 手机通道:配对 → 端到端加密 → 中继

手机是最"远"的客户端,也是安全要求最高的。整条链路分三段,可以分开看。

① 配对 ② E2EE 握手 ③ 传输选路
───────── ──────────── ──────────
桌面出 QR → 手机拿公钥算共享密钥 → 局域网:直连 ws://
含端点+令牌 → 加密发设备令牌 公网:Orca Relay 中继
+E2EE 公钥 → 桌面验令牌→ready

7.1 配对:一个 QR 里装了什么

createPairingOfferruntime-rpc.ts:664)产出的载荷经 encodePairingOffer 变成 orca://pair?code=<base64url>src/shared/pairing.ts:14)。里面有:端点 URL、设备令牌、桌面 E2EE 公钥、scope(mobileruntime),走公网时再加一份 relay 邀请(runtime-rpc.ts:929-937)。

端点怎么算出来值得单说。监听地址是 0.0.0.0,但通配地址不可拨号,所以默认回落成 127.0.0.1——除非调用方显式给了 --pairing-address

// src/main/runtime/pairing-endpoint.ts:22-25
// Why: a wildcard listener is not client-reachable; default pairing must remain local-only unless explicitly advertised.
endpoint.hostname = '127.0.0.1'

resolveAdvertisedPairingEndpoint:16)接受 hostname、host:port、IPv4/IPv6 字面量、完整 ws(s):// URL,并拒绝端口 0("绑定期的请求,不是可拨号的端点",:53)。

E2EE 身份是一对持久化的 NaCl box 密钥,存在 0600 文件里,格式坏了就重新生成(src/main/runtime/e2ee-keypair.ts:26)。QR 图片本身只是把 URL 编码一下(src/main/runtime/mobile-pairing-qr.ts:8)。

"Anywhere 模式必须 fail closed" 是这块最重要的一条策略:如果用户选了公网模式但 relay 邀请铸造失败,代码不会悄悄降级发一张局域网 QR,而是丢弃未用的待配设备、返回 relay_mint_failed 让 UI 去提示"改用局域网"(runtime-rpc.ts:848-863)。

7.2 E2EE:ws:// 之上再加一层

为什么不用 TLS?注释给了答案:React Native 没法 pin 自签证书,所以改用逐设备令牌 + tweetnacl 应用层加密runtime-rpc.ts:1173)。

E2EEChannelsrc/main/runtime/rpc/e2ee-channel.ts:45)是个三态机:

awaiting_hello ──e2ee_hello──→ awaiting_auth ──密文 e2ee_auth──→ ready
│ │ │
└── 10s 超时关闭 ──────────────┴── 令牌无效→4001 ────────────┘
连续 5 次解密失败 → 4003
  • v1:直接 ECDH 出一把共享密钥(:229 deriveSharedKey),双向同一把;
  • v2:hello 里带 v: 2,走 HKDF 派生四样东西src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.ts:13)——上下行各一把密钥 + sessionId + transcript hash,96 字节一次展开(:26):
const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce]))
const info = concatBytes([INFO_LABEL, transcriptHash])
const expanded = new Uint8Array(hkdfSync('sha256', args.sharedSecret, salt, info, 96))

上下行分开密钥、salt 绑双方 nonce、info 绑握手 transcript —— 这三件事一起挡住重放和方向混淆。

一个关键判断在认证之后:认证通过就把身份绑死在 socket 上,后续请求里再写 deviceToken 也没用——不一致直接 unauthorized,一致则以 socket 绑定的身份为准(runtime-rpc.ts:1637-1650)。注释一句话点破:"E2EE 已经认证过这条通道,按绑定身份授权,而不是按重复的请求字段":1393)。

7.3 手机方法白名单:第二道闸

即使认证通过,scope === 'mobile' 的设备还要过一张 257 项的白名单(runtime-rpc.ts:179 MOBILE_RPC_METHOD_ALLOWLIST,检查在 :1404)。不在表里返回 forbidden

这就是"同一张方法表服务五类客户端"的真实形态:表只有一张,能力按 scope 收窄。网页客户端 scope 是 runtime,不受此限;手机只能碰白名单里的 257 个。

顺带一个细节:老的配对凭据可能没有 scope 元数据,所以 status.get 的响应会在传输边界上被注入当前认证的 scope(injectDeviceScope:453),因为 dispatcher 里没有 per-connection 上下文可用。

7.4 Orca Relay:把桌面变成一台"云可达"的主机

公网路径多三个角色:

角色干什么文件
DesktopRelayService按需开关中继(有 demand 才连)src/main/runtime/relay/desktop-relay-service.ts:51
RelaySessionBroker换 token、要分配、开控制面、定时刷新relay-session-broker.ts:28
CloudRelayTransport每个 conn-open 开一条 data socketsrc/main/runtime/rpc/relay-transport.ts:45

"按需"很重要RelayDemandLedger 决定要不要保持一条常驻控制连接;没有待配对的 QR、没有已绑定设备时就不连。withTransientDemanddesktop-relay-service.ts:288)在一次操作期间临时持有 demand,结束就释放。还有个定时器保证"没人扫的 QR 在服务端邀请过期时自动停止占用控制连接"(:296-303)。

桌面要向 relay 证明自己是谁。 answerRelayHostChallengerelay-host-proof.ts:156)拿到一个 challenge:用自己的 host 私钥 nacl.box.open 解密,取出里面的 transcript 和一段 secret,把 transcript 的 16 个字段逐条校验validateTranscript:91),全过才用 secret 做 HMAC 回答。

16 个字段包含 relayOrigin、challengeId、userId、profileId、organizationId、relayHostId、hostPublicKey、assignmentEpoch、previousGeneration、resumeRequested,以及三个时间检查(未来时间、未过期、窗口 ≤10 秒)。时钟容差 30 秒(:7)。失败时只报检查项名字,绝不带字段值(:32 的注释)。

控制面消息全部 zod .strict() relay-control-protocol.ts 里每个 schema 都收得很紧:32 字节 base64url 用 /^[A-Za-z0-9_-]{43}$/ 卡死(:9),activeConnIds 最多 8 个(:40),attachDeadlineMs 上限 60 秒(:52)。.strict() 意味着多一个字段就拒收——和 RPC 帧那边的 .strip() 正好相反,因为这条是与云服务的协议边界,宽容没有收益。

吊销要跨机器可靠。 换绑定或删设备时,先把云端 revoke 记进本地持久化 outbox,写不进去就整个操作失败(runtime-rpc.ts:987 queueRelayDeviceRevoke);写不进 outbox 时退而求其次把 binding 留在设备上"保住一个引用,而不是彻底孤儿化"(:926-939)。重连到同一账号/主机时再用稳定的 reqId 重放(desktop-relay-service.ts:274-286)。


8. SSH 远端执行:一个单文件 CJS 包的一生

前面七节讲的都是"别人连进来"。这节反过来:Orca 主动把执行能力送到一台远程主机上

远端主机上没有 Orca、没有 npm 依赖、可能只有一个 node。所以整套设计围绕一个约束:投过去的东西必须是一个自包含的文件

8.1 打包:esbuild 一次,六个平台

// config/scripts/build-relay.mjs:52
await build({
entryPoints: [RELAY_ENTRY], // src/relay/relay.ts
bundle: true, platform: 'node', target: 'node18',
format: 'cjs', // 远端 node relay.js 直接跑
external: ['node-pty', '@parcel/watcher', 'electron'],
minify: true
})

三个要点:

  • CommonJS 而非 ESM —— 远端就是 node relay.js,不假设 package.json / type 字段;
  • 原生模块标 external —— node-pty@parcel/watcher 打不进 bundle,必须存在于远端,缺了就优雅降级(:59-61);
  • 六个平台各出一份 —— linux-x64/arm64darwin-x64/arm64win32-x64/arm64:37)。Windows 额外拷一个 node-pty 补丁文件(:69-74)。

同一次构建还产出两个伴生包:relay-watcher.js(崩溃隔离的文件监听子进程)和 managed-hook-runtime.js

版本号 = 语义版本 + 内容哈希。 .version 文件写的是 0.1.0+<sha256前12位>,哈希覆盖三个可执行模块(Windows 再加上补丁文件):

// config/scripts/build-relay.mjs:114-123
const hash = createHash('sha256').update(relayContent).update(watcherContent).update(managedHookRuntimeContent)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)

注释解释了为什么(:108-110):光靠 RELAY_VERSION 不够,忘了升版本号的代码改动也必须能被部署检查发现。

8.2 投送:每个版本一个不可变目录

远端布局仿照 VS Code Server 的 ~/.vscode-server/bin/<commit>/

~/.orca-remote/
├── relay-0.1.0+a1b2c3d4e5f6/ ← 不可变,装完打 .install-complete
│ ├── relay.js
│ ├── relay-watcher.js
│ ├── managed-hook-runtime.js
│ └── .version
└── relay-0.1.0+9f8e7d6c5b4a/ ← 旧版本,GC 时探活后再删

设计文件头写明了理由(src/main/ssh/ssh-relay-versioned-install.ts:1-5):内存里的老 daemon 绝不能用被覆盖过的磁盘代码去服务新客户端。目录名的解析用一个正则做单一真源(:43 RELAY_VERSION_DIR_REGEX),另有一条只匹配旧布局的正则让历史目录也能被排空(:46)。

安装期有一把 install lock(ssh-relay-install-lock.ts),GC 期有一把 gc claim(ssh-relay-gc-claim.ts),因为同一台远端可能被多个 Orca 实例同时够着。

8.3 启动:先探活、能复用就复用

launchRelaysrc/main/ssh/ssh-relay-deploy.ts:1355)的顺序不是"直接启动",而是:

① test -S <sock> ── ALIVE? ──→ ② node relay.js --connect ──→ 成功即返回
│ DEAD │ 失败
↓ ↓ rm -f <sock>
③ nohup node relay.js --detached ... &

④ 轮询 connect 探测 (200ms × 最多 10s)

⑤ node relay.js --connect ← 桥接本次 SSH 通道到那个 Unix socket

关键几行:

# ssh-relay-deploy.ts:1468(简化)
cd <dir> && chmod 600 <cred> && nohup <node> relay.js --detached \
--grace-time <s> --sock-path <sock> --credential-file <cred> --log-file <log> \
> <log> 2>&1 </dev/null &
  • nohup + </dev/null + & 让 daemon 脱离 SSH 通道,活得比 SSH 连接久:1461);
  • 不用 execCommand 而是 fire-and-forget,因为后台子进程永远不让通道关闭,execCommand 会一直阻塞(:1462);
  • 探活不是固定 sleep 而是轮询,因为远端可能是 CI 机器也可能是树莓派(:1477);
  • test -S 只能证明 inode 存在,所以真正的探测是用 node 连一下再断开:1478:1490)——而且刻意用 node(一定有)而不是 python3/socat/perl。

第 ⑤ 步是精髓:后台 daemon 的 stdout 去了日志文件,所以每次新的 SSH 会话都靠一个 --connect 进程把这条 SSH 通道的 stdio 桥接到那个 Unix socket:1516-1518)。App 重启后 PTY 和滚屏都还在原来那个 daemon 里。

8.4 握手:两道门

--connect 桥和 daemon 之间要先过一个前置于 dispatcher 的握手帧(src/relay/relay-handshake.ts:58 setupDaemonHandshake)。daemon 在接受连接后只读一帧 Handshake,然后才挂上 JSON-RPC dispatcher:

检查不通过的后果
帧类型必须是 Handshake直接 destroy(:106-112
msg.version === launchVersionhandshake-mismatch,桥进程 exit(42):126-143:17
endpoint 凭据一致直接 destroy(:144-151

退出码 42 是约定的不可重试信号;其他非零退出被视为瞬时故障(:16)。版本取自脚本旁边的 .version 文件,而不是启动 cwd(readLaunchVersion:19-20)。

endpoint 凭据是一个 32~256 字符的随机串,写在 0600 文件里(src/relay/relay.ts:196-208)。它把"同一台机器上别的用户能不能连这个 socket"这件事从纯文件权限升级成凭据校验。

8.5 grace 期:SSH 断了,PTY 不死

daemon 生命周期的核心状态是"宽限窗口"。SSH 通道断开时不立即退出,而是 startGracerelay.ts:1225):

触发源 决策 decideRelayGrace() 结果
────────── ──────────────────────── ──────
stdin 结束/出错 ───┐
socket 客户端关闭 ──┼──→ 配置的 graceMs? → 定时关机
detached 启动 ─────┤ relay 是否 idle(0 PTY)? → idle 上限 15min
shutdown 被推迟 ───┘ 是否 detached、有无接过客户端? → 空启动 60s

三个默认值:

常量含义
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS00 = 无限,除非显式关闭(src/shared/ssh-types.ts:7
EMPTY_DETACHED_STARTUP_GRACE_MS60 sdetached 启动后没人来接的窗口(relay.ts:92
IDLE_RELAY_GRACE_MS15 min一个 PTY 都没有的 relay 什么也没保住(relay.ts:98

只有 idle-no-ptys 这一支可以被"中途冒出一个 PTY"撤销,其他分支保留已经武装的期限(:837-839)。定时器到点时还要再验一次是否真的仍然 idle,防止把一个刚出现的 PTY 误杀(:1149-1156,issue #6955)。

另外三条保命措施:

  • SIGHUP忽略,否则 SSH 断线会立刻杀掉所有 PTY(:1263-1266);
  • socket 用 umask(0o177) 后再 listen,原子拿到 0600,关掉 chmod-after-listen 的 TOCTOU 窗口(:983-985);
  • EADDRINUSE 不直接放弃,而是先 connect 探一下:ECONNREFUSED/ENOENT 才认定是崩溃后残留的陈旧 socket,unlink 重试;能连上就说明真有活 daemon,老实失败(:1051-1103)。而且 unlink 前会比对 dev/ino/ctime 三元组确认还是同一个 inode(:1034-1049)。

8.6 帧格式与车道:为什么大 diff 不会卡住键盘回显

SSH relay 用的是自有二进制帧,不是前面那套 NDJSON:

13 字节头
┌──────┬──────────┬───────────┬──────────────┐
│ type │ id (u32) │ ack (u32) │ length (u32) │ payload …
└──────┴──────────┴───────────┴──────────────┘
1 字节 序号 累计确认 载荷长度

type 只有三种:Regular=1 / Handshake=2 / KeepAlive=9(src/relay/protocol.ts:30)。HEADER_LENGTH = 13MAX_MESSAGE_SIZE = 16 MBsrc/shared/relay-frame-decoder.ts:14-15)。保活 5 秒一次、超时 20 秒(protocol.ts:63-64)。

一条 SSH 通道上同时跑着"你敲的键盘回显"和"一个 300 MB 的 git diff"。所以写出侧被切成五条车道,由一个轮转调度器仲裁(src/relay/dispatcher-writer-lane-scheduler.ts:10):

liveness ──→ 永远优先(保活)
control ──→ 控制帧、请求响应
interactive ─→ PTY 回显
ordinary ──→ 普通通知
bulk / fixed-bulk ──→ 大块数据

防饿死靠两个计数器:每写 4 帧 producer 帧就必须让一次 bulk,每写 4 帧交互帧就必须让一次 ordinary(:7-8)。大 payload 还有阈值切换——序列化超过 256 KB 的 git 响应改走 bulk 车道分块传(protocol.ts:88),fs 流每块 256 KB、最多 16 条并发流、ack 窗口 4 块(protocol.ts:68-75)。

8.7 信用额度流控:为什么它偏偏长在远端 PTY 上

先说问题。 本地 PTY 出问题时你还有一招:pauseProducer —— 停止读那个伪终端,内核缓冲满了,那个疯狂 cat 大文件的子进程自己就会阻塞在 write 上。这叫内核背压,免费且精确。

IPtyProvider 的契约里明说了这一招在 SSH relay 上不可用

// src/main/providers/pty-provider-contract.ts:113-120(节选注释)
// Best-effort and optional — providers that cannot pause (SSH relay, legacy
// daemon protocols) omit these or no-op silently, and callers must keep
// functioning without them
pauseProducer?: (id: string) => void

原因是链路太长:桌面 → SSH 通道 → relay daemon → node-pty。桌面说"暂停",这句话本身要排在已经在飞的几 MB 数据后面。等它到达,内存已经爆了。

所以改成"发额度"。 消费者(桌面)预先授权一个窗口,生产者(relay)只能发这么多,收到 ack 才能推进:

relay(生产者) 桌面(消费者)
│ ←──── open: windowSu = 256 KB ────│
│ ──── span [0, 64K) ──────────────→│
│ ──── span [64K, 128K) ───────────→│
│ ←──── ack: creditedEndSu = 64K ───│ 窗口滑动
│ ──── span [128K, 192K) ──────────→│
│ (窗口用尽 → 停发,数据留在 ledger) │

核心是 RelayPtySourceCreditLedgersrc/relay/pty-source-credit-ledger.ts:52)。几个动作:

方法干什么
open开一条 delivery,给定 windowSu(默认 256 KB,pty-source-credit-contract.ts:1
appendPTY 有新输出,进 ledger 前先过六道预算检查
reserveNextSend在剩余窗口内切一段出来准备发
commitSend / rollbackSend发送落地或回滚(写失败时数据不丢)
acknowledge收到 ack,creditedEndSu 前移,窗口滑动
rotate重连时换一条 delivery,保留未确认的 span 供恢复

append 的六道检查值得列出来——它同时防"单条 PTY 失控"和"很多条 PTY 一起失控":

检查单条上限全局上限
保留的源范围(su)maxRetainedSourceSumaxAggregateRetainedSourceSu
保留的编码字节maxRetainedDataBytesmaxAggregateRetainedDataBytes
保留的 span 数maxRetainedSpansmaxAggregateRetainedSpans

(代码在 pty-source-credit-ledger.ts:93-113,默认值在 pty-source-credit-limits.ts:22。)

为什么单位叫 su(source unit)而不是字节? 因为终端输出会被转换——转义序列被处理、UTF-8 边界要保住。所以每个 span 同时带 sourceStartSu/sourceEndSu(原始流上的位置)和 displayStart/displayEnd(显示位置),外加一个 transform 描述这段是否被改写过、原始长度多少、是否 scalar-safe(src/shared/pty-source-credit-contract.ts:13-30)。reserveNextSend 里有一条对应的规则:不可分割的 span(或被转换过的 span)如果放不进剩余窗口,就整段不发,绝不切半个转义序列出去(pty-source-credit-ledger.ts:148-153)。

身份是六元组。 PtySourceDeliveryIdentityid + providerGeneration + clientGeneration + ownerGeneration + ptyIncarnation + deliveryToken 组成(src/shared/pty-source-credit-contract.ts:4-12)。重连、换 provider、换 owner 都会让某一维变化,从而让陈旧的 ack 自动失效。关闭的 delivery 还会留一段"墓碑"快照(closedSnapshotssrc/relay/pty-source-credit-ledger.ts:321-324),让竞态的探测请求读到"已关闭"而不是"未知"。

一句话总结这一节:能用内核背压的地方用内核背压;跨越一条高延迟链路时,背压必须变成显式的、可对账的额度。


9. 巧妙之处(可以直接借鉴的)

  1. 一张扁平方法表 + 启动期重名硬失败。 40 个模块拍平成一个数组,buildRegistry 遇到重名当场 throw(core.ts:181)。安全审计只要 grep 一个符号(methods/index.ts:47)。

  2. .strip().strict() 各用其所。 面向自家客户端的 RPC 帧用 .strip(),允许新增字段(runtime-rpc-envelope.ts:7-8);面向云服务的控制协议用 .strict(),多一个字段就拒(relay-control-protocol.ts:27 等)。宽容与严格的选择跟着"谁在独立演进"走。

  3. 按需保活。 startKeepalive 是每请求 opt-in 的(transport.ts:12-13)。短 RPC 一个定时器都不创建;只有被 longPollClassOf 认出来的长轮询才付这份钱。

  4. 分级配额而不是单一队列。 总额 16 保护短 RPC,ask 子额 8 保护 waitruntime-rpc.ts:154-160)。而且比例写死不可配——避免"调大 cap 时不小心把预留比例改没了"。

  5. 凭据不进日志、不进 Referer。 网页客户端 URL 把配对码放 fragment(runtime-rpc.ts:167-168);relay host proof 失败只报检查项名字不报值(relay-host-proof.ts:32);provider 抛的原始错误只记验证过的 code(runtime-rpc.ts:885-892)。

  6. 陈旧资源三重确认。 删 socket 前:正则取 pid → kill(pid,0) 探活 → 只在 ESRCH 时删(runtime-rpc.ts:1763-1788);relay 侧还多一层 dev/ino/ctime 三元组比对(relay.ts:1131-1146)。

  7. 版本号带内容哈希。 0.1.0+<sha12> 让"忘了升版本号"的改动也无法蒙混过关(build-relay.mjs:160-175),配上不可变安装目录,杜绝"内存里的老 daemon 服务新客户端"。

  8. fail closed 而不是静默降级。 公网配对铸不出 relay 邀请时,宁可报错让用户选局域网,也不发一张挂着"Anywhere"标签的局域网 QR(runtime-rpc.ts:848-863)。

  9. 写出车道 + 强制让位计数。 交互回显和大块传输共用一条 SSH 通道,靠 5 条车道 + "每 4 帧必须让位一次"避免任何一侧饿死(dispatcher-writer-lane-scheduler.ts:7-8)。

  10. CLI 惰性加载。 RuntimeClient 的依赖图占 CLI 199 个 eager 模块中的 153 个(zod、ws、tweetnacl)。所以 --help、命令拼写错误这些在它之前就返回的路径完全不加载它(src/cli/index.ts:39-46)。


10. 边界与局限

  • 本机 RPC 的安全边界就是文件权限。 谁能读 0600 的 orca-runtime.json,谁就有 runtime 的全部权限。Windows 命名管道更弱(没有 chmod),只能靠 per-runtime 后缀让端点名不可猜(runtime-rpc.ts:1801)。

  • WebSocket 监听 0.0.0.0,不开 TLS。 机密性完全依赖应用层 E2EE,理由是 RN 无法 pin 自签证书(:1127)。所以没有 E2EE 就没有保护——这也是 relay 路径强制 requireV2 的原因(mobile-socket-wiring.ts:149)。

  • 手机白名单是手工维护的 257 项常量。 加了新方法不动这张表,手机端就用不了;漏审就可能放行不该放行的能力。这是"一张表 + scope 收窄"的代价。

  • SSH relay 上没有真正的生产者暂停。 只能靠信用额度近似,PTY 在窗口耗尽时数据是留在 relay 的 ledger 里的,所以有六道内存预算兜底(pty-source-credit-ledger.ts:93-113)。预算耗尽时 append 直接 throw。

  • 原生模块必须在远端存在。 node-pty@parcel/watcher 是 external,不在 bundle 里(build-relay.mjs:81)。缺了就降级,具体降级到什么程度取决于哪个模块缺失。

  • 本文不涉及具体方法的业务语义。 538 个方法各自在干什么、终端如何常驻、agent 状态怎么识别,分别见 终端底座认得出 agent;worktree 的生命周期见 一条 worktree 的一生;agent 反过来调 Orca 的那一面见 让 agent 反过来开车


11. 代码地图

主题文件路径符号名
服务门面(唯一权威)src/main/runtime/orca-runtime.tsOrcaRuntimeService
PTY provider 契约src/main/providers/pty-provider-contract.tsIPtyProvider
agent 状态事件src/main/runtime/orca-runtime.tsRuntimeTerminalAgentStatusEvent
每 PTY 布局状态src/main/runtime/orca-runtime.tsPtyLayoutTargetPtyLayoutState
RPC 服务器(安全边界)src/main/runtime/runtime-rpc.tsOrcaRuntimeRpcServer
socket 名正则 / 孤儿清扫src/main/runtime/runtime-rpc.tsRUNTIME_SOCKET_NAME_REGEXsweepOrphanedRuntimeSockets
端点元数据构造src/main/runtime/runtime-rpc.tscreateRuntimeTransportMetadata
长轮询分类与配额src/main/runtime/runtime-rpc.tslongPollClassOfadmitLongPollLONG_POLL_CAP
手机方法白名单src/main/runtime/runtime-rpc.tsMOBILE_RPC_METHOD_ALLOWLIST
传输契约src/main/runtime/rpc/transport.tsRpcTransportRpcMessageContext
Unix / 命名管道传输src/main/runtime/rpc/unix-socket-transport.tsUnixSocketTransport
WebSocket 传输src/main/runtime/rpc/ws-transport.tsWebSocketTransport
端口稳定性src/main/runtime/rpc/ws-fallback-port-store.tsreadWsFallbackPortwriteWsFallbackPort
云中继传输src/main/runtime/rpc/relay-transport.tsCloudRelayTransport
网页静态托管src/main/runtime/rpc/static-web-client-handler.tscreateStaticWebClientHandler
分发器src/main/runtime/rpc/dispatcher.tsRpcDispatcher.dispatchdispatchStreaming
方法声明原语src/main/runtime/rpc/core.tsdefineMethoddefineStreamingMethodbuildRegistryRpcContext
方法总表src/main/runtime/rpc/methods/index.tsALL_RPC_METHODS
参数校验组合子src/main/runtime/rpc/schemas.tsOptionalFiniteNumberTriStateLinkedIssuerequiredString
响应封装与错误码src/main/runtime/rpc/errors.tssuccessResponseerrorResponse
线上帧契约src/shared/runtime-rpc-envelope.tsRuntimeRpcEnvelopeSchemaisKeepaliveFrame
远端调用排队src/shared/runtime-rpc-call-queue.tsRuntimeRpcCallQueuePoolisBackgroundRuntimeMethod
元数据读写 / 所有权src/main/runtime/runtime-metadata.tsruntime-metadata-ownership-watch.tswriteRuntimeMetadatawatchRuntimeMetadataOwnership
CLI 客户端src/cli/runtime/client.tsRuntimeClientresolveMethodTimeoutMs
CLI socket 传输src/cli/runtime/transport.tssendRequest
CLI 远端传输src/cli/runtime/websocket-transport.tssendWebSocketRequest
CLI 惰性加载src/cli/index.tsloadRuntimeClientClassresolveInvocationCwd
CLI 启动 / 状态src/cli/runtime/launch.tsstatus.tslaunchOrcaAppserveOrcaAppgetCliStatus
配对码编解码src/shared/pairing.tsencodePairingOfferparsePairingCode
广告端点解析src/main/runtime/pairing-endpoint.tsresolveAdvertisedPairingEndpoint
E2EE 身份密钥src/main/runtime/e2ee-keypair.tsloadOrCreateE2EEKeypair
E2EE 通道状态机src/main/runtime/rpc/e2ee-channel.tsE2EEChannel
E2EE v2 密钥调度src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.tsderiveMobileE2EEV2KeySchedule
手机 socket 接线src/main/runtime/rpc/mobile-socket-wiring.tsMobileSocketWiring
配对二维码src/main/runtime/mobile-pairing-qr.tsencodeMobilePairingQr
中继服务(按需)src/main/runtime/relay/desktop-relay-service.tsDesktopRelayService
中继会话代理src/main/runtime/relay/relay-session-broker.tsRelaySessionBroker
主机身份证明src/main/runtime/relay/relay-host-proof.tsanswerRelayHostChallengevalidateTranscript
中继控制协议src/main/runtime/relay/relay-control-protocol.tsRelayConnectionOpenMessageSchema
SSH relay 入口src/relay/relay.tsmainrunConnectModestartGrace
SSH relay 协议src/relay/protocol.tsencodeFrameMessageTypeRELAY_SENTINEL
SSH relay 握手src/relay/relay-handshake.tssetupDaemonHandshakerunConnectHandshakeEXIT_CODE_VERSION_MISMATCH
SSH relay 分发器src/relay/dispatcher.tsRelayDispatcherrequestAnyClientnotifyBulk
写出车道调度src/relay/dispatcher-writer-lane-scheduler.tsDispatcherWriterLaneScheduler
PTY 信用额度账本src/relay/pty-source-credit-ledger.tsRelayPtySourceCreditLedger
信用额度契约src/shared/pty-source-credit-contract.tsPtySourceDeliveryIdentityDEFAULT_PTY_SOURCE_WINDOW_SU
relay 处理器src/relay/pty-handler.tsfs-handler.tsgit-handler.tsPtyHandlerFsHandlerGitHandler
relay 打包config/scripts/build-relay.mjsPLATFORMSRELAY_VERSION
relay 投送src/main/ssh/ssh-relay-deploy.tslaunchRelay
relay 版本化安装src/main/ssh/ssh-relay-versioned-install.tsRELAY_VERSION_DIR_REGEXgcOldRelayVersions