feat/relay-p0:把“连上 daemon”改造成带身份与版本谈判的连接建立

buffin · origin/main...HEAD · 2026-07-27 · 自包含,读完即弃

5 commits
81 文件
+4557 / −196
57.3% 是测试代码

写法说明:本文按“契约 → 闸门 → 继承结果”的顺序走读。代码片段全部来自当前分支的真实文件并经过裁剪,青色斜体注释是为解释所加,灰色斜体是源码原注释的保留或意译。每条旅程最后都给一张“排查路标”:将来再碰到连接异常、gate 阻塞、附件打不开时,直接知道从哪个文件下手。

1TL;DR

这 5 个提交收紧的不是单个 relay 功能点,而是 “客户端如何确认自己连到的是谁,以及这一条连接在什么版本约束下才允许放行业务流量”。分支把 daemon 从“只要地址可信就能开始说话”推进到“先凭 credential 接入,再跑 daemon.hello,把 daemon 身份和协议版本锁住,然后才启动 model sync、dispatcher、terminal 和 blob”。

实现上有三层:daemon 先拥有稳定的加密身份,把这个身份和版本对外暴露成 daemon.helloclient 把 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
2471 行 · 52.0%
apps/daemon
890 行 · 18.7%
apps/desktop
815 行 · 17.1%
packages/api
544 行 · 11.4%
apps/cli
28 行 · 0.6%
bun.lock
4 行 · 0.1%
子系统设计重心(要细读)可放心略过
packages/client谈判适配器、per-epoch gate、URL trust 规则、terminal/blob 跟随 negotiated peer导出面重排与测试辅助细节
apps/daemon身份文件、daemon.hello、三条 carrier 的 protocol floor、isLocal 规则收紧测试 harness 附带调整
apps/desktopApp.start() 延后开流、blocked gate 文案、session-file 按 daemonId 分区缓存locale 文案同步
packages/api协商 DTO、version pair、reach 安全策略、错误码和 header 常量index re-export
apps/cli状态帮助文本对齐新连接模型测试快照调整
测试占比 57.3%,说明这批改动主要在把新连接模型钉死,而不是扩大量产界面

3架构一图流

以前 · 地址可信就可以开始说业务

desktop / client
connectionParams 只带 token
tRPC / terminal / blob
endpoint
没有独立 daemon 身份
daemonId
App.start()
直接开 dispatcher / model sync
业务流量

现在 · 先锁身份与版本,再放行业务

identity keypair
pubkey → daemonId
daemon.hello
negotiating socket
每个 epoch 先 hello
current peer
App.start()
谈判 ok 后才启动业务
tRPC / terminal / blob / file

4数据与状态先行

这一批代码先引入了几个新“形状”,后面的旅程都围绕它们转。最重要的不是字段名本身,而是它们拆出了三件以前混在一起的事:怎么到达 daemon当前连的是哪个 daemon两边是否版本兼容

packages/api/src/credentials.tsreach 与连接目标(节选)
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
}
packages/api/src/hello.tshello DTO 与兼容结论(节选)
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 拦住。

apps/daemon/src/protocol-gate.ts共享的 carrier 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
}
apps/daemon/src/trpc/ws.tsWS 入口先 credential,后 protocol(节选)
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,
}
apps/daemon/src/routes/blob.tsblob 路由对 GET / POST 都做同一件事(节选)
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()
})
为什么要做成 carrier floordaemon.hello 只是“新 client 的主动探测”;真正阻止过旧 caller 的,是 carrier 本身携带的 protocolVersion。这样旧 client 即使没有 hello,也不会静默跑进半兼容状态。
isLocal 的补丁:以前 loopback 地址就足够让请求读作 local;现在要同时满足 loopback remoteAddressprincipal.subject === 'daemon-runtime'。这一步是给后续 relay / device principal 提前把权限边界钉住。

6旅程 A:先确认“我连的是谁”

这条旅程把 daemon 从“固定端口上的一个进程”抬成“有稳定加密身份的对端”。先有这个身份,后面 client 才能把 cache、gate、session-file、future pairing 都挂到同一个锚点上。

全景 · 涉及 4 个核心文件
身份持久化
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 就不会因为数据库被删重建而改变。

apps/daemon/src/services/identity.ts身份文件与 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 不会据此分支行为。

apps/daemon/src/services/daemon-info.tshello 的返回体(节选)
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.tsapps/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 仍然会直接放行业务帧。

全景 · 涉及 5 个核心文件
negotiating adapter
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 落地。

packages/client/src/negotiation.ts每个 socket epoch 的发送门(节选)
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)
}
packages/client/src/negotiation.tsverdict 不只看版本,也锁身份(节选)
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
}
以前
tRPC socket 建立后发 connectionParams(token)
业务 query / subscription 立刻上 wire
如果 daemon 重启成不兼容版本,只会在业务流里晚些时候失败
现在
connectionParams(token, protocolVersion) 先过服务端 floor
适配器立刻插入 daemon.hello,业务帧全部缓冲
只有 version ok 且 daemon 身份锁定成功,缓冲才按原顺序冲刷

这里还有一个很关键的细节:如果 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。

apps/desktop/src/renderer/runtime/app.tsApp.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()
}
apps/desktop/src/renderer/runtime/lifecycle.tsblocked 进入生命周期,而不是落成一个 transport 内部错误(节选)
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')
}
apps/desktop/src/renderer/components/shell/ModelReadyGate.tsxUI gate 明确区分 blocked 与 offline(节选)
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.tsstartAfterNegotiation 是否被 blocked / dispose 截断。
UI 显示 update-app / update-daemon / verify-daemonpackages/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。

全景 · 涉及 5 个核心文件
session.negotiate() 提升 connection.id
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 而被浏览器预检拦下。

packages/client/src/session.ts连接 id 从 endpoint fallback 升级成 daemonId(节选)
const negotiate = async (): Promise<TransportNegotiationResult> => {
  const result = await transport.negotiation.result()
  if (result.ok) {
    resolvedId = result.peer.daemonId ?? fallbackId
    pinTimelineCache(resolvedId)
  }
  return result
}
packages/client/src/terminal-connection.tsterminal open frame 等 gate,并把 protocolVersion 带上(节选)
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()
  })
apps/desktop/src/renderer/runtime/app.tsblob header 从 negotiated peer 反推(节选)
httpClient: createClient({
  baseUrl,
  env,
  reach: connection.reach,
  blobGate: async () => {
    const peer = await session.negotiation.whenEpochNegotiated()
    return { protocolVersion: peer.kind === 'legacy' ? null : peer.protocolVersion }
  },
})
以前
tRPC 建立好就算“应用可用”
terminal 只等 token,blob 直接走默认 header 规则
对端 daemon 的真实版本 / 身份没有贯穿到边路
现在
session 先谈出 current peer,并把连接 id 升级成 daemonId
terminal / blob 都等 whenEpochNegotiated()
legacy peer 明确走兼容分支,identified peer 明确走版本化分支

C.2session-file bridge 的 cwd cache 也开始按 daemonId 分区

这个改动量不大,但很说明问题。session-file bridge 本来只是在 Electron main 里替 renderer 安全地做“打开文件 / 预览文件”这些事情;现在它的 cwd cache 不再只按 sessionId 或 daemon 地址近似,而是直接把 daemonId 编进 key。这意味着“同一个地址上的另一个 daemon”不会复用上一个 daemon 的 cwd 结果。

apps/desktop/src/main/session-file-bridge.tscwd cache 的分区键(节选)
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.tsblobGatepackages/client/src/daemon-http-client.ts:确认 legacy peer 是否真的把 protocol header 置空。
同一个 session 在 daemon 换代后打开到了错误 cwdapps/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,不回答身份。
缓存、blocked gate、session-file、future pairing 都围绕这个组合对象建模。
tRPC socket 建好了,业务流量就可以开始跑。 每个 socket epoch 都必须先过 daemon.hello;ok 之前业务帧只会缓冲,不会上 wire。
这是为了解决 daemon 在自动重连期间换版本 / 换身份的场景。
“offline” 和 “不兼容” 对用户来说都是“连不上”。 unreachable 是可恢复的 reachability 问题,blocked 是终止性的兼容或身份问题。
前者继续重试,后者要明确告诉用户该更新 app、更新 daemon,还是检查连接目标。
terminal / blob / file preview 是主连接之外的边路功能。 这些边路都必须继承主连接谈出来的 current peer,不能各自凭 endpoint 猜。
否则主通道已经知道 daemon 变了,边路却还拿旧 endpoint 当稳定身份。
loopback 地址就足够代表“本地请求”。 isLocal 需要同时满足 loopback 地址和 daemon-runtime principal。
daemon 将来自己拨出来的 relay socket 也可能看起来是 loopback,地址本身不再足够表达权限。

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,比“应用启动过一次”更细。
blockedReasonblocked gate 的具体原因,用来决定 UI 文案和排查方向。
blobGatedesktop 在每次 blob 传输前读取 negotiated peer,再决定是否带 protocol header。

11测试与风险地图

有兜底的薄冰
  • packages/client/src/negotiation.test.ts 钉住了 hello 插帧、缓冲顺序、blocked 分支、legacy 分支、identity lock 这些核心 transport 行为。
  • apps/daemon/tests/daemon-hello-trpc.test.ts 钉住了 hello DTO 形状与输入校验。
  • apps/daemon/tests/terminal-ws.test.tsblob-route.test.ts 钉住了 terminal / blob 的 carrier floor 与认证顺序。
  • apps/desktop/src/main/session-file-bridge.test.ts 钉住了 loopback 限制、daemonId 分区缓存、preview 字节上限。
  • 🟠remote-relay 仍是 descriptor-only:tRPC 和 terminal transport 在这条 reach 上都会明确抛出“not available yet”。这是当前范围,不是漏实现,但它也意味着整条 relay 数据面还没接上这一层。
  • 🟡capabilities 数组和 probeDynamicCapabilities() seam 已经存在,但当前仍为空,说明本分支谈的是版本与身份,不是 capability-level routing。
  • 🟡health / shutdown 故意不走 negotiation gate,所以“daemon 活着”与“daemon 可兼容地服务业务”现在是两个不同概念。读状态时不能把两者混成一个布尔值。

12验收提示

13覆盖声明

这份报告的正文建立在对 5 个提交说明、核心实现文件(packages/api / packages/client / apps/daemon / apps/desktop)以及关键测试文件的一手阅读之上;文中所有代码片段都来自这些一手文件。

我对整个 diff 做了全量称重与文件级范围确认,但没有把 81 个变更文件逐个展开成正文:locale、CLI 小修、部分测试补丁和 re-export 只做了 diff 级核对,没有逐段写入这份叙事。也因此,这份报告最擅长帮人重建“连接模型为什么变了、主路径怎么流转”,而不是给每一条小测试改动做逐文件注释。