远程线路兼容性¶
Orca 的远程服务器功能将桌面客户端与远程 Orca 运行时配对,用户会独立更新这两者。混合版本是正常状态,而不是边缘情况。本页是更改配对客户端和主机交换的任何内容的契约:运行时 RPC 信封、终端二进制流,以及双方通过它们发布的内容。
src/shared/protocol-version.ts 说明了何时提升 RUNTIME_PROTOCOL_VERSION。本页涵盖不会提升它的更改,因此容易出错。
规则 1 — 在现有帧上新增可选 JSON 字段是安全的¶
每个 JSON 负载都使用会忽略未知键的解码器解析(RPC 参数使用 zod .strip(),流帧使用 JSON.parse)。从未听说过该字段的旧对端只是不会读取它。
安全:
// host adds a field; older clients ignore it
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
只有当所有读取者都将其视为可选时,该字段才是安全的。 一旦某个较新的客户端_要求_它,该客户端就会与所有早于该字段的主机不兼容——这与移除字段是同一类缺陷,只是更晚被发现。如果新行为依赖于该字段存在,那就属于规则 2:协商它,或让读取者回退。
规则 2 — 新流操作码不安全;必须协商它¶
decodeTerminalStreamFrame 对未知操作码返回 null,并且 runtime-rpc.ts 会无错误地丢弃该帧:
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return // silently dropped — the sender never learns
}
因此,发送给旧对端的新操作码不会大声失败。它会消失,其背后的功能看起来像挂起。以新操作码发送的输入会被吞掉。
新操作码必须在订阅握手中宣布,并且只有在对端确认后才发送。现有模式是 SetOutputPaused(操作码 16):
- 客户端在
Subscribe帧的capabilities中声明支持; - 主机在
subscribed事件中回显capabilities: { outputPause: 1 }; - 客户端仅在该回显之后发送操作码 16(
stream.supportsOutputPause); - 主机仅在其已协商时才对操作码 16 采取行动(
stream.supportsOutputPause)。
只要能用新增可选负载字段(规则 1)表达更改,就复用现有操作码;只有当帧结构确实不同时才使用新操作码。
操作码编号是永久的。参见 src/shared/terminal-stream-protocol.ts 中 Ack = 13 和 ClaimViewport = 14 的注释,了解为什么即使其背后的功能被移除,已发布的编号也不能复用。
规则 3 — 更改主机发布的内容会在没有线路更改的情况下破坏旧客户端¶
帧形状可以保持不变,而版本偏差仍然真实存在,因为客户端会对帧_内容_作出反应。PR #12641 是一个实例:主机停止合成已完成的代理状态,运行旧代码的客户端在相同帧中看到了不同内容。
即使编解码器中没有任何变化,也要将这些视为线路更改:
- 主机停止填充的字段(旧客户端读取它现在会看到
undefined); - 含义、单位或可空性发生变化的值;
- 主机停止合成、修剪或开始从新来源派生的内容;
- 主机在现有路径上停止发送或开始发送的帧。
如果旧客户端无法正确解释新的投影,请像规则 2 对操作码进行门控一样,将其置于运行时能力之后。
规则 4 — 枚举分支集合是线路表面;未知分支必须降级,绝不能拒绝¶
客户端回复模式中的封闭 z.enum 是一种版本声明:它断言主机永远不会发送此构建从未听说过的分支。添加一个分支的较新主机随后会看到其整个回复被拒绝,或者承载该分支的行被静默丢弃,尽管客户端实际读取的每个成员都存在且格式良好。
相反,使用 src/shared/zod-salvage.ts 中的 openEnum 将分支集合声明为开放:
// unknown arm degrades to a member the reader already handles; a non-string stays fatal
status: openEnum(GIT_BRANCH_COMPARE_STATUS, 'error')
不要使用 .catch()。它也会吞掉缺失和错误类型,从而将读取者依赖的成员变成静默默认值。
回退不必是分支。git.status 的 area 是一个实例:staged、unstaged 和 untracked 各自提供一种操作入口,因此将未知 area 强制转换为其中之一,会对客户端无法定位的行提供暂存、取消暂存或提交操作。它降级为缺失,从而扣留全部三种——每个读取者都是针对已知分支的相等性检查,因此该行不会落入任何部分——同时保留该行本身。最后这一点是关键。丢弃该行也会将其从未解决冲突门控中移除,而一个看起来干净的冲突工作树会被授予它本不应拥有的 hosted-review 创建。扣留操作入口是降级;移除门控读取的证据则不是。
回退只允许塑造读取。如果该成员被发回主机——客户端回显到后续调用参数中的令牌——请将其作为 z.string() 透传,并让发送位置原样保留它。hostedReview 的 provider 就是这种情况:资格回复会命名它,创建调用会返回它,因此那里的 openEnum(..., 'unsupported') 并不会软化客户端读取较新主机 provider 的方式,而是将 unsupported 放到线路上,并使该主机拒绝它自己的。回复模式回退绝不能塑造参数。相反,应对令牌进行门控,由客户端决定它愿意对未知分支做什么。
执行¶
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts 会运行来自两个构建的真实主机 RPC 方法和真实渲染器多路复用器,使它们相互测试——当前工作树与最新 release 标签,双向偏差——覆盖一个脚本化终端旅程(订阅、输入、隐藏/显示快照、断开、重连)。
运行方式:
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
当帧被接收端构建的解码器拒绝(规则 2)、观察到的帧序列发生变化(规则 3),或发布的快照内容或协商的能力与契约不一致时,它会失败。重复的帧形状按对应的旅程出现位置(initial、reveal、reconnect)进行比较,因此从某一次出现中移除的字段无法隐藏在仍然发布该字段的兄弟帧后面。添加一个可选字段会使测试套件保持绿色(规则 1);让客户端依赖该字段会使新客户端/旧宿主配对变红。
永远不要写下旧端拥有什么¶
基线是最新的发布标签,因此它会在每次切版时移动。形如“旧端没有 X”的期望——一个 not.toHaveProperty、一个 not.toContain、一个硬编码字段列表——会在某个发布首次包含 X 时停止为真。随后,测试套件会在任何正在进行的 pull request 上变红,尽管任何地方都没有代码变更,而这个任务会训练人们忽略它。这比没有测试更糟,因为滚动基线最终会包含 wire 拥有的每一个新增字段,而添加字段是演进它的认可方式。
从实际签出的基线推导期望:
- 对于已发布的帧,将每个构建与其自身版本的客户端配对,并将偏斜配对与该同版本参考进行比较,因此期望就是该构建今天发布的内容。使用
tests/e2e/cross-version-wire/published-field-shape.ts中的comparePublishedFieldOccurrences按对应出现位置比较重复帧;永远不要跨 initial、reveal 和 reconnect 帧合并键,因为兄弟帧可能掩盖某一次出现中被移除的字段; - 对于协商表面,从旧构建的签出中读取其通告的能力和已注册方法名,并断言它们彼此一致,而不是断言旧构建缺少它们;
- 对于“客户端太旧以至于不知道 X”,通过从基线自身列表中移除 X 来推导该客户端的通告列表,以便在 X 发布后该门控仍被演练。
在断言中指明方向。new client against old server 和 old client
against new server 因不同原因失败,而宿主是唯一编写已发布帧的一方——2026-08-29 终端 terminalOwner 误报被误读为新客户端发送了未知字段,而实际上是旧服务器在发布它。仍然可以直白陈述两件事:当前构建自身的契约,以及对每个版本都成立的不变量。
固定一个遗留 ref 是回退方案,当契约确实需要某个功能发布之前的发布时,就像 cross-version-browser-placement.unit.test.ts 使用 LEGACY_BROWSER_PLACEMENT_RELEASE_REF 所做的那样。它不会在切版时腐化,但它是手工维护的,因此优先使用推导。
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts 在结构化 agentSession.* 表面上配对相同的两个构建。由于已发布构建不能命名其自身源码从未包含的能力字符串,旧端的通告列表和已注册方法名是从提取的签出中读取的,而不是手工编写的。它覆盖了该表面可能失败的三种偏斜:
- 旧客户端——通告基线列表减去该能力——被告知整个表面不存在,并且无法到达任何宿主方法;
- 新客户端对旧调度器总是得到回答而不是沉默,并且对于该发布未注册的每个方法都得到
method_not_found,因此缺失在协商期间可见,而不是通过调用才可见; - 游标在宿主重启后存活:客户端的 fence 作为过期被拒绝,并附带当前有效的 fence,从保留的游标恢复时只重放它错过的内容。
运行方式:
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
测试框架覆盖终端流和结构化 agent-session 表面。它不覆盖 session-tab 同步通道、遗留 agent-session 发布、文件或 Git RPC、mobile/E2EE 帧封装,或 relay 传输。这些路径上的变更仍需要针对上述三条规则进行各自的推理。
实例演练:终端和 worker 读取中的 agentWait¶
terminal.show、orchestration.workerShow 和 orchestration.federationShow 携带一个可选的 agentWait,用于命名一个停在只有人类才能回答的提示上的 pane。它是规则 1——一个新的可选字段——但它有一个规则 1 单独无法描述的第二状态,而弄错它会把偏斜变成虚假的“没有阻塞”。
- 存在对象 — 该 pane 正在等待,并带有证明它的证据。
- 存在
null— 宿主评估了该 pane,并且没有任何东西证明存在等待。 - 缺失 — 宿主从未评估它:它早于该字段,worker 身份无法验证,pane 不可读,或 agent 探针未能在时间内回答。
新客户端对旧宿主会看到该字段缺失,这就是为什么缺失必须被读作 未知,而绝不能被读作 未等待。在任何一跳中将缺失折叠为 null——包括 RPC 处理器中方便的 ?? null——会使旧的对等端或不可达的对等端与健康空闲 worker 无法区分,而这正是该字段存在要消除的失败。
旧客户端对新宿主忽略该键,规则 1 允许如此。添加到 RuntimeTerminalWaitBlockedReason 的新成员也是规则 1:没有消费者对它做穷举 switch,并且 CLI 和 worker-start 都将其作为不透明字符串插值。
实例演练:turn 日志项及其过渡性降级¶
结构化聊天日志将 turn 记录为一等项,
{ kind: 'turn', turnId, state, userItemId?, startedAt?, completedAt?, durationMs? },而它过去会写入 { kind: 'status', text, turnLifecycle }。codec 中没有任何东西移动,但它是规则 3:早于该 item 的客户端不知道该 kind,并将其渲染为没有文本的文本气泡。因此该 item 由客户端能力 agent-session.turn-item.v1 门控。
该门控仅存在于 RPC 边界处,位于
src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts,围绕
agentSession.history 和 agentSession.subscribe 组合而成,并位于后台任务投影旁边。未声明该能力的客户端会收到每个被重写为旧状态形式的 turn 项,完整生命周期位于 turnLifecycle 下;声明了该能力的客户端以及任何进程内调用方会收到规范主体。日志、状态流以及每个宿主侧读取器都保留 turn 项;src/shared/agent-session-turn-record.ts 中的 readAgentJournalTurn 可以读取任一形式,因此新客户端面对仍写入状态行的旧宿主也能工作。
面对新宿主的旧客户端会看到它一直看到的状态行。面对旧宿主的新客户端会声明宿主忽略的能力,并通过共享读取器读取状态行。该降级是过渡性的:一旦没有任何受支持的版本缺少该能力,就删除投影模块和能力检查,并保留读取器。
跨版本测试套件通过从基线自身的列表中移除该能力来推导旧客户端的列表,遵循上述规则,因此在某个版本发布该能力后,降级仍会被执行。
已知债务:JSON-RPC 错误会丢弃 Node 的字符串 code¶
在 SSH 宿主上抛出的错误会以 JSON-RPC 形式穿过中继,而
ssh-channel-multiplexer 会使用 TRANSPORT 的数字 code 重建它。Node 的
字符串 code —— 'ENOENT'、'EACCES' —— 无法保留下来,因此这一侧的调用方
无法询问它属于哪种失败。
src/main/ipc/filesystem-path-containment.ts 中的 isENOENT 为此付出了代价:它还
匹配 Node 的规范消息文本,这正是让远程 worktree 创建能够工作的原因。代价是,宿主可以通过在消息中放入那句话,让一个无关失败被读作“absent”。
出路是规则 1:在错误负载上新增一个可选字段来携带原始字符串 code,并改为读取该字段。旧宿主会省略它,消息匹配仍然覆盖它们;一旦发送它的宿主成为最低要求,就可以删除消息匹配,而不是在约 10 个调用点继续忍受它。如果不这样做就把 isENOENT 收窄回 .code,会重新引入该 bug —— 传输层已经把它覆盖掉了。
已知隐患:客户端会忽略宿主在客户端放置的页面上发布的失败字段¶
RuntimeMobileSessionBrowserTab —— 宿主在 session-tab 同步通道上发布的浏览器标签页 —— 允许同时包含 placement、loadError 和 certificateFailure。但对于 placement.kind 为 'client' 的标签页,引擎运行在客户端自己的应用中:失败由本地 guest webview 抛出,宿主看不到它(RuntimeBrowserClientPage,即注册表实际发布来源,不包含这两个字段)。因此,从本版本开始的客户端会拒绝宿主对客户端放置的页面的这两条记录的所有权(web-session-tabs-sync.ts 中的 placement?.kind !== 'client' 例外)—— 如果没有这一点,每个元数据快照都会删除本地记录的失败,页面的失败覆盖层会在导航中途消失。
该隐患是面向未来的,并且具有规则 3 的形态。如果宿主日后开始为客户端放置的页面发布 loadError 或 certificateFailure,这些客户端会将其作为静默丢弃的内容接收,因此宿主将看不到错误,也看不到任何效果。发布它必须受能力门控,并将例外收窄到未协商该能力的客户端。注意,跨版本测试框架不会执行 session-tab 同步通道,因此如果忘记这一点,什么都不会失败 —— 本说明是唯一记录。
一个相关的例外覆盖 title、url、loading、canGoBack 和 canGoForward(resolveMirroredBrowserPageContent),对于这些字段,隐患已经实际存在,而不是面向未来:宿主确实会发布它们,但它只能从 RuntimeBrowserClientPage 中通过客户端自己的 browser.clientHost.pageMetadata 调用间接得知。因此,它的副本从注册表默认值开始('Browser'、创建时的 url),而在这些发布失败期间,它永远不会离开这些默认值。
不过,该副本并不只是落后,客户端也不应将其视为如此。当租约重新附加时,宿主会从客户端宿主自己的清单中刷新页面(runtime-browser-client-page-recovery.ts),它会读取实时 guest —— 因此它可能严格比本地行更新,而该本地行的面板已卸载,其元数据发布器也已随之销毁。忽略宿主 url 的客户端是在依赖自己的 guest 在重新挂载时重新应答,而 ClientHostedBrowserPagePane 在挂载时的 syncNavigation 正是使这一点成立的东西。
因此,这五个字段只会被实际运行该页面的客户端拒绝:placement.browserHostClientId 会与客户端自己的宿主 id 进行比较(readBrowserClientHostId)。Main 在创建时将该 id 写入承载 guest 的窗口的 additionalArguments,preload 再从它自己的 argv 中读回它 —— 答案必须在解释第一个快照之前就存在,这早于渲染器可以等待的任何 IPC 处理程序。其他所有查看器 —— 第二个桌面、web 客户端(它根本不安装页面渲染器)、仪表盘弹出窗口(它被刻意保留未写入)—— 都继续跟踪宿主,这是镜像查看器永远显示其第一个快照之外的任何内容的唯一原因。改进 第二个 客户端所看到的内容仍然意味着修复发布,而不是例外;该例外不再妨碍它。
上述两个失败字段被刻意保留在更宽松的 placement?.kind !== 'client' 谓词上。今天它是不可观察的 —— 宿主根本不会为客户端放置的页面发布这两个字段中的任何一个,因此镜像无论哪种情况都没有可接收的内容。如果本节预期的受能力门控的发布最终落地,应以相同方式收窄它们,而不是按 placement 类型收窄:镜像应接收一个它无法通过其他方式看到的失败,而只有承载客户端应拒绝它。
已知风险:在移动端接口上,作用域拒绝并不等同于方法缺失¶
上述 agent-session 测试框架断言:当对等端探测未知方法时,会收到
method_not_found。这一点适用于运行时作用域接口,但不适用于移动端接口。
runtime-rpc-websocket-dispatch.ts 会检查 MOBILE_RPC_METHOD_ALLOWLIST,并在调用分发器之前回答
forbidden。对于桌面端版本早于其引入的方法,该方法既不在允许列表中,也不在注册表中,而门禁会先响应,因此手机永远不会看到
method_not_found。只有当某个方法在允许列表中但未注册时,method_not_found 才会到达移动端作用域设备;而 src/main/runtime/mobile-rpc-allowlist.test.ts
要求手机调用的每个方法都必须同时满足这两点,因此这种组合无法发布。相反的情况
——一个已注册但尚无任何版本将其加入允许列表的方法——才是实际发生的偏差。
因此,手机端“该主机是否足够新,能够服务 X?”的探测必须将这两种代码都视为
缺失。仅以 method_not_found 为依据,代码可以编译并通过所有同版本测试,却永远不会触发。Files 和 Git 回退自发布起就同时读取两者
(isMobileMethodUnavailableError、isMobileGitUnavailable);Relay 配对探测是例外,其代价是一个只写一次的 direct-relay 升级日志,其中保存了一个待处理的 resume secret,而旧桌面端永远无法将其退役
(mobile/src/transport/pairing-relay-rpc-unavailable.ts,该门禁由桌面端通过
src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts 固定)。
只有在 forbidden 不可能同时表示真实授权失败的地方,放宽才是安全的。
应针对每个探测而非全局证明这一点:配对处理器不会发出该代码(未接线的提供者会回答
runtime_error),无效或已吊销的 token 会回答 unauthorized,并且该门禁是 src/main 中仅有的两个会发出该代码的位置之一。如果某个探测的处理器_可能_
通过授权拒绝,则不得放宽它。
跨版本测试框架以移动端客户端身份分发,但直接调用分发器,因此 它永远不会运行该门禁,即使上述任何一项被遗忘,也不会有任何测试变红。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca