feat/relay-p0:把“连上 daemon”改造成带身份与版本谈判的连接建立
buffin · origin/main...HEAD · 2026-07-27 · 自包含,读完即弃
写法说明:本文按“契约 → 闸门 → 继承结果”的顺序走读。代码片段全部来自当前分支的真实文件并经过裁剪,青色斜体注释是为解释所加,灰色斜体是源码原注释的保留或意译。每条旅程最后都给一张“排查路标”:将来再碰到连接异常、gate 阻塞、附件打不开时,直接知道从哪个文件下手。
1TL;DR
这 5 个提交收紧的不是单个 relay 功能点,而是 “客户端如何确认自己连到的是谁,以及这一条连接在什么版本约束下才允许放行业务流量”。分支把 daemon 从“只要地址可信就能开始说话”推进到“先凭 credential 接入,再跑 daemon.hello,把 daemon 身份和协议版本锁住,然后才启动 model sync、dispatcher、terminal 和 blob”。
实现上有三层:daemon 先拥有稳定的加密身份,把这个身份和版本对外暴露成 daemon.hello;client 把 tRPC WebSocket 包成每个 socket epoch 都要先谈判的适配器,用它挡住所有业务帧;desktop 再把这份 negotiated peer 下沉给 terminal/blob/session-file,让“当前连的是哪个 daemon”不再只靠 endpoint 猜。
因此这个分支最关键的心智补丁不是“支持了 remote-direct”这么简单,而是:连接对象从 URL 升级成了 “reach + credential + daemonId + version pair”。后续任何直连 / relay / pairing 的工作,都会站在这个新连接模型上继续长。
2变更地图
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
packages/client | 谈判适配器、per-epoch gate、URL trust 规则、terminal/blob 跟随 negotiated peer | 导出面重排与测试辅助细节 |
apps/daemon | 身份文件、daemon.hello、三条 carrier 的 protocol floor、isLocal 规则收紧 | 测试 harness 附带调整 |
apps/desktop | App.start() 延后开流、blocked gate 文案、session-file 按 daemonId 分区缓存 | locale 文案同步 |
packages/api | 协商 DTO、version pair、reach 安全策略、错误码和 header 常量 | index re-export |
apps/cli | 状态帮助文本对齐新连接模型 | 测试快照调整 |
测试 | 占比 57.3%,说明这批改动主要在把新连接模型钉死,而不是扩大量产界面 | 无 |
3架构一图流
以前 · 地址可信就可以开始说业务
现在 · 先锁身份与版本,再放行业务
4数据与状态先行
这一批代码先引入了几个新“形状”,后面的旅程都围绕它们转。最重要的不是字段名本身,而是它们拆出了三件以前混在一起的事:怎么到达 daemon、当前连的是哪个 daemon、两边是否版本兼容。
export type DaemonReach =
| { kind: 'local'; baseUrl: string }
| { kind: 'remote-direct'; baseUrl: string }
| { kind: 'remote-relay'; relayUrl: string; pairingId: string }
export interface ConnectionTarget {
expectedDaemonId?: DaemonId
reach: DaemonReach
credential: CredentialStrategy
}
export interface Connection extends ConnectionTarget {
id: DaemonId
}
export const daemonHelloDtoSchema = z.object({
daemonId: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
daemonVersion: z.string().trim().min(1).max(64),
protocolVersion: z.number().int().min(1),
minCompatibleProtocolVersion: z.number().int().min(1),
capabilities: z.array(z.string().trim().min(1).max(128)),
})
export function evaluateProtocolCompatibility(
client: ProtocolVersionPair,
daemon: ProtocolVersionPair,
): NegotiationResult {
if (daemon.protocolVersion < client.minCompatibleProtocolVersion) {
return { ok: false, blocked: 'update-daemon' }
}
if (client.protocolVersion < daemon.minCompatibleProtocolVersion) {
return { ok: false, blocked: 'update-app' }
}
return { ok: true }
}
| 这几种状态各自回答什么问题 | |
|---|---|
reach | 网络上怎么走到 daemon。它描述的是通路,不是身份。 |
expectedDaemonId | 配置里期待的稳定身份。第一次接本地 daemon 可以没有,pairing 后的远端连接则会变成硬约束。 |
NegotiatedDaemonPeer | 这次 socket epoch 真正谈出来的对端:identified daemon 或 legacy daemon。 |
blocked | 不是“暂时断网”,而是“版本或身份不允许继续说业务”。 |
whenEpochNegotiated() | 不是“应用启动过一次”而是“当前这条 socket 已经通过本轮谈判”。terminal/blob 都靠它放行。 |
5底座:三类 carrier 共用一条 protocol floor
这条分支没有把版本检查塞进某个单独入口,而是把它下压到三类 credential carrier:tRPC 的 connectionParams、terminal 首帧、blob HTTP header。这样旧 client 即使从来不调 daemon.hello,也会在真正建立业务通道时被 floor 拦住。
export function requireCompatibleProtocol(raw: string | number | null | undefined): number {
const resolved = resolveCarrierProtocolVersion(raw)
if (!resolved.ok) {
throw new AppError({ code: BuffinErrorCode.protocol.incompatible })
}
return resolved.protocolVersion
}
const principal = verifyCredentialToPrincipal(
deps.expectedToken,
readConnectionToken(opts.info.connectionParams),
)
if (!principal) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Daemon credential required.' })
}
assertCompatibleConnectionProtocol(opts.info.connectionParams)
return {
principal,
isLocal: deriveIsLocal(principal, opts.req.socket.remoteAddress ?? null),
services: deps.services,
}
app.use('/blob/:sessionId', async (c, next) => {
c.set('principal', verifyBlobPrincipal(daemonToken, c.req.header(DAEMON_TOKEN_HEADER) ?? null))
// 版本策略不能被未认证调用方拿来探测,所以放在 credential 之后
requireCompatibleProtocol(c.req.header(PROTOCOL_VERSION_HEADER) ?? null)
await next()
})
daemon.hello 只是“新 client 的主动探测”;真正阻止过旧 caller 的,是 carrier 本身携带的 protocolVersion。这样旧 client 即使没有 hello,也不会静默跑进半兼容状态。
isLocal 的补丁:以前 loopback 地址就足够让请求读作 local;现在要同时满足 loopback remoteAddress 和 principal.subject === 'daemon-runtime'。这一步是给后续 relay / device principal 提前把权限边界钉住。
6旅程 A:先确认“我连的是谁”
这条旅程把 daemon 从“固定端口上的一个进程”抬成“有稳定加密身份的对端”。先有这个身份,后面 client 才能把 cache、gate、session-file、future pairing 都挂到同一个锚点上。
apps/daemon/src/services/identity.ts→ hello 服务
apps/daemon/src/services/daemon-info.ts→ 共享 DTO
packages/api/src/hello.ts→ 测试钉住 shape
A.1daemon 身份从数据库外置成一个长期 keypair
这一步不是“多存了一个 secret file”那么简单,而是把 daemon 身份和 dev-recovery 的数据库生命周期彻底剥离。只要 identity 目录还在,同一个 daemon 的 daemonId 就不会因为数据库被删重建而改变。
const IDENTITY_DIR = 'identity'
const SECRET_KEY_FILE = 'x25519-secret-key'
export function loadOrCreateDaemonIdentity(homePath: string): DaemonIdentity {
const dirPath = join(homePath, IDENTITY_DIR)
const keyPath = join(dirPath, SECRET_KEY_FILE)
const stored = readSecretKey(dirPath, keyPath)
if (stored) return identityFromSecretKey(stored)
const minted = nacl.box.keyPair()
mkdirSync(dirPath, { recursive: true, mode: 0o700 })
writeFileSync(keyPath, `${Buffer.from(minted.secretKey).toString('base64url')}\n`, {
mode: 0o600,
flag: 'wx',
})
return identityFromSecretKey(minted.secretKey)
}
export function deriveDaemonId(publicKey: Uint8Array): string {
return createHash('sha256').update(publicKey).digest('base64url')
}
后两次 follow-up 又把这个身份加载过程做硬了:拒绝 symlink,要求目录和文件归当前 uid 所有,必要时自动修正 mode,且 key 文本必须是 canonical base64url。也就是说,这个分支不仅引入了 daemon 身份,还把“错误身份被静默重铸”这条路封死了。
A.2daemon.hello 把身份、版本对和 capability 公开出来
有了稳定身份之后,daemon 还需要一条“我是谁、我讲什么版本”的自报面。这个面落在 daemon.hello,它不是权限判定点,只是诊断和兼容判断点,所以输入里有 app / appVersion 但 daemon 不会据此分支行为。
export function createDaemonInfoService(
options: CreateDaemonInfoServiceOptions,
): DaemonInfoService {
return {
hello(input): DaemonHelloDto {
logger.debug(
{ app: input.app, appVersion: input.appVersion, protocolVersion: input.protocolVersion },
'daemon.hello negotiated',
)
return {
daemonId: options.daemonId,
daemonVersion: options.daemonVersion,
protocolVersion: PROTOCOL_VERSION,
minCompatibleProtocolVersion: MIN_COMPATIBLE_PROTOCOL_VERSION,
capabilities: [...staticCapabilities, ...probeDynamicCapabilities()],
}
},
}
}
这里现在最重要的字段不是 capabilities,而是 daemonId 和 version pair。capabilities 的 seam 已经预留出来,但当前数组还是空的,说明本分支先把“身份 + 版本谈判”这条主轴铺平,再给以后做 feature-level capability negotiation 留槽位。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| daemon 重启或 dev-recovery 后,客户端却把它当成“另一个 daemon” | apps/daemon/src/services/identity.ts:先看 key 文件是否被替换、损坏、或 ownership / mode 被环境改坏。 |
daemon.hello 能通,但返回 shape 校验不过 | packages/api/src/hello.ts 与 apps/daemon/src/services/daemon-info.ts:先对齐 DTO schema 和服务返回。 |
本地连接意外拿到或失去 localOnlyProcedure 权限 | apps/daemon/src/trpc/ws.ts:重点看 deriveIsLocal 是否同时满足 principal 和 loopback。 |
7旅程 B:每个 socket epoch 都要先过协商闸门
真正把这条分支拉开层次的是这里:hello 不是应用启动时做一次的前置检查,而是每次底层 WebSocket 换新 socket epoch 时都要重跑。因为 tRPC client 自己会自动重连并恢复 pending request,如果只在第一次连接时协商,daemon 重启成另一个版本后,重连出来的那条 socket 仍然会直接放行业务帧。
packages/client/src/negotiation.ts→ transport / session
packages/client/src/trpc-transport.ts, session.ts→ App.start()
apps/desktop/src/renderer/runtime/app.ts→ blocked gate
runtime/lifecycle.ts, ModelReadyGate.tsx
B.1socket 先发 connectionParams,立刻插入 hello,业务帧全部缓冲
适配器没有重写 tRPC 协议,只是在 socket 实例级别劫持了发送节奏:第一帧仍然是 tRPC 自己发的 connectionParams,适配器紧接着插入一个带字符串 id 的 daemon.hello 查询;之后所有业务帧都先排队,直到 hello verdict 落地。
socket.send = (data: string): void => {
if (phase === 'terminal') return
if (data === 'PING' || data === 'PONG') {
realSend(data)
return
}
if (phase === 'connection-params') {
realSend(data)
realSend(JSON.stringify(helloRequestFrame(helloId, identity, clientVersions)))
phase = 'negotiating'
return
}
if (phase === 'negotiating') {
buffered.push(data)
return
}
realSend(data)
}
function judgeEpoch(
verdict: NegotiationResult,
peer: NegotiatedDaemonPeer | undefined,
): { blocked: NegotiationBlockedReason } | { peer: NegotiatedDaemonPeer } {
if (!verdict.ok) return { blocked: verdict.blocked }
if (!peer || !acceptPeer(peer)) return { blocked: 'verify-daemon' }
return { peer }
}
function acceptPeer(peer: NegotiatedDaemonPeer): boolean {
if (expectedDaemonId !== undefined) {
if (peer.kind !== 'identified' || peer.daemonId !== expectedDaemonId) return false
}
if (acceptedPeer === null) {
acceptedPeer = peer
return true
}
if (acceptedPeer.kind !== peer.kind) return false
if (acceptedPeer.kind === 'identified' && peer.kind === 'identified') {
return acceptedPeer.daemonId === peer.daemonId
}
return true
}
connectionParams(token)connectionParams(token, protocolVersion) 先过服务端 floordaemon.hello,业务帧全部缓冲这里还有一个很关键的细节:如果 hello 返回 NOT_FOUND,适配器把它识别成 legacy daemon,而不是普通错误。这保证 pre-negotiation daemon 仍然被归入协议 1,并继续走兼容判断;但除了 NOT_FOUND 以外的 hello 错误都只会让当前 epoch 失败重试,不会偷偷降级成 legacy。
B.2desktop 把协商结果投影成 blocked / unreachable / ready
client 侧谈出 verdict 之后,desktop 没有把它藏在 transport 内部,而是显式投影到了 AppLifecycle。这样“daemon 不可达”和“daemon 可达但不兼容”就分成了两个不同的用户态:前者会继续重试,后者则是终止性 blocked state。
App.start() 先等谈判,再开业务流(节选)const startAfterNegotiation = async (): Promise<void> => {
const unreachableDeadline = setTimeout(
() => lifecycle.markUnreachable(),
MODEL_LIMITS.OFFLINE_PROMOTION_MS,
)
let verdict: NegotiationResult
try {
verdict = await session.negotiate()
} catch {
return
} finally {
clearTimeout(unreachableDeadline)
}
if (disposed || !verdict.ok) return
terminals.start(bench.store)
session.dispatcher.start()
model.start()
}
export type AppLifecycleState = 'starting' | 'ready' | 'unreachable' | 'blocked' | 'disposed'
export type AppBlockedReason = 'update-app' | 'update-daemon' | 'verify-daemon'
block(reason) {
if (state === 'disposed' || state === 'blocked') return
blockedReason = reason
state = 'blocked'
stateEmitter.fire('blocked')
}
export function ModelReadyGate({ children }: { readonly children: ReactNode }) {
const state = useLifecycleState()
if (state === 'ready') return children
if (state === 'unreachable') return <DaemonUnreachableScreen />
if (state === 'blocked') return <NegotiationBlockedScreen />
return <ModelLoadingScreen />
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| socket 一直重连,但 model sync / dispatcher / terminal 从未真正启动 | packages/client/src/negotiation.ts:先看 hello verdict 有没有落地;再看 apps/desktop/src/renderer/runtime/app.ts 的 startAfterNegotiation 是否被 blocked / dispose 截断。 |
UI 显示 update-app / update-daemon / verify-daemon | packages/api/src/hello.ts 看兼容判断;packages/client/src/negotiation.ts 看 identity lock;ModelReadyGate.tsx 只负责把原因翻译成文案。 |
| daemon 明明活着,但应用不是 offline 而是 blocked | 这是设计上的区分:先看 lifecycle.blockedReason;blocked 表示兼容性或身份失败,不是 reachability 失败。 |
8旅程 C:terminal、blob、session-file 都继承同一份 negotiated peer
如果上面的谈判只存在于 tRPC socket,这个分支就只完成了一半。真正把模型闭环收住的是:terminal open、blob 传输、session-file cwd cache 都开始按 negotiated peer 或 daemonId 分区,不再把 endpoint 本身当成稳定 identity。
packages/client/src/session.ts→ terminal gate
packages/client/src/terminal-connection.ts→ blobGate
daemon-http-client.ts, runtime/app.ts→ session-file cwd cache
apps/desktop/src/main/session-file-bridge.ts
C.1terminal 和 blob 不再靠“应用已经启动过”放行
以前 terminal 只等 credential 准备好;现在它要同时等 credential 和当前 epoch 的 negotiation gate。blob 也类似,但它多了一层 legacy 兼容:如果 peer 是 legacy daemon,就明确把 protocol header 设成 null,避免老 daemon 因为 CORS allowlist 里没有这个 header 而被浏览器预检拦下。
const negotiate = async (): Promise<TransportNegotiationResult> => {
const result = await transport.negotiation.result()
if (result.ok) {
resolvedId = result.peer.daemonId ?? fallbackId
pinTimelineCache(resolvedId)
}
return result
}
void Promise.all([connection.credential.getCredential(), options.gate?.()])
.then(([credential]) => {
if (disposed) return
if (socket.readyState !== socketOpen) return
socket.send(
JSON.stringify({
type: 'open',
credential,
terminalId: options.terminalId,
protocolVersion: PROTOCOL_VERSION,
...options.dimensions,
}),
)
openFrameSent = true
flushPendingFrames(socket, pendingFrames)
resolveOpened()
})
httpClient: createClient({
baseUrl,
env,
reach: connection.reach,
blobGate: async () => {
const peer = await session.negotiation.whenEpochNegotiated()
return { protocolVersion: peer.kind === 'legacy' ? null : peer.protocolVersion }
},
})
daemonIdwhenEpochNegotiated()C.2session-file bridge 的 cwd cache 也开始按 daemonId 分区
这个改动量不大,但很说明问题。session-file bridge 本来只是在 Electron main 里替 renderer 安全地做“打开文件 / 预览文件”这些事情;现在它的 cwd cache 不再只按 sessionId 或 daemon 地址近似,而是直接把 daemonId 编进 key。这意味着“同一个地址上的另一个 daemon”不会复用上一个 daemon 的 cwd 结果。
function resolveSessionCwd(
sessionId: string,
daemonBaseUrl: string,
daemonId = daemonBaseUrl,
): Promise<string> {
const key = `${daemonId}\0${sessionId}`
const cached = sessionCwdCache.get(key)
if (cached) return cached
const resolving = loadSessionCwd(sessionId, daemonBaseUrl).catch((error) => {
sessionCwdCache.delete(key)
throw error
})
sessionCwdCache.set(key, resolving)
return resolving
}
这件事和上一节的 terminal/blob 是一类补丁:连接的稳定 identity 终于不再只是某个 baseUrl。一旦 daemon 在同地址上换代、切换身份、或以后通过 relay / pairing 接进来,边路能力也能和主通道一致地认识“这是哪一个 daemon”。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
terminal socket 已连上,但 opened 一直不 resolve 或被 dispose 打断 | packages/client/src/terminal-connection.ts:看 Promise.all([credential, gate]) 是否卡在 gate,或是否先被 dispose 结案。 |
| legacy daemon 上 blob 上传 / 下载只有浏览器环境失败 | apps/desktop/src/renderer/runtime/app.ts 的 blobGate 与 packages/client/src/daemon-http-client.ts:确认 legacy peer 是否真的把 protocol header 置空。 |
| 同一个 session 在 daemon 换代后打开到了错误 cwd | apps/desktop/src/main/session-file-bridge.ts:先看请求里有没有把正确 daemonId 透传进 main 进程,再看 cache key 是否命中了旧项。 |
9心智模型补丁
http://127.0.0.1:19514” 基本就等于“连到本地 daemon”。
连接对象现在是 reach + credential + daemonId + version pair,URL 只回答 reach,不回答身份。
daemon.hello;ok 之前业务帧只会缓冲,不会上 wire。
unreachable 是可恢复的 reachability 问题,blocked 是终止性的兼容或身份问题。
isLocal 需要同时满足 loopback 地址和 daemon-runtime principal。
10新词表
| 连接与协商 | |
|---|---|
daemon.hello | 客户端主动向 daemon 询问身份、版本对和 capability 的查询,不是权限检查点。 |
ProtocolVersionPair | “我现在讲哪个版本”与“我最低还能听懂哪个版本”这两个数字的组合。 |
NegotiatedDaemonPeer | 当前 socket epoch 真正谈出来的对端,可以是 identified daemon,也可以是 legacy daemon。 |
verify-daemon | 版本没问题,但当前 endpoint 回答出来的 daemon 身份不符合预期。 |
| carrier 与 reach | |
|---|---|
carrier floor | 把 protocolVersion 检查放在 credential carrier 本身,而不是仅靠 hello 过程拦截。 |
remote-direct | 直接连远端 daemon 的 reach,要求非 loopback 时必须走 HTTPS。 |
remote-relay | 现在还只是 descriptor,占住连接契约里的位置,真正的数据面还没进这个分支。 |
| desktop runtime | |
|---|---|
whenEpochNegotiated() | “当前这一轮 socket 已经谈判成功”的 promise,比“应用启动过一次”更细。 |
blockedReason | blocked gate 的具体原因,用来决定 UI 文案和排查方向。 |
blobGate | desktop 在每次 blob 传输前读取 negotiated peer,再决定是否带 protocol header。 |
11测试与风险地图
| 有兜底的 | 薄冰 |
|---|---|
|
|
12验收提示
- 如果界面出现
update-app/update-daemon/verify-daemon,那不是“offline 但文案变了”,而是 transport 已经被主动围栏,继续重试不会自己恢复。 - legacy daemon 上 blob 请求故意省略
x-buffin-protocol-version。如果浏览器抓到这类请求没带 header,这是兼容策略本身,不是 header 漏发。 - CLI 在这批提交里没有变成 remote-direct client。分支里真正长出来的是 desktop / client 侧的 reach 模型和谈判器。
session-file仍然只允许 loopback daemon base URL。这里没有跟着 remote-direct 打开,是为了避免 Electron main 进程把本地 daemon token 带到任意地址。
13覆盖声明
这份报告的正文建立在对 5 个提交说明、核心实现文件(packages/api / packages/client / apps/daemon / apps/desktop)以及关键测试文件的一手阅读之上;文中所有代码片段都来自这些一手文件。
我对整个 diff 做了全量称重与文件级范围确认,但没有把 81 个变更文件逐个展开成正文:locale、CLI 小修、部分测试补丁和 re-export 只做了 diff 级核对,没有逐段写入这份叙事。也因此,这份报告最擅长帮人重建“连接模型为什么变了、主路径怎么流转”,而不是给每一条小测试改动做逐文件注释。