Claude Code 深度拆解:远程模式 4 — 无环境直连架构

Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,专注于 V2 无环境直连架构------理解 Anthropic 如何用"减法重构"砍掉 V1 的六步环境生命周期,实现从 Web 端到本地 Agent 的零轮询延迟直连。

本文聚焦一件事:Claude Code 的 V2 架构如何在不去掉传输协议的前提下,砍掉 Environments API 这一整层------register、poll、ack、heartbeat、stop、deregister 全部消失,替换为三步直连:create session → fetch JWT → connect transport。

读完全文,你将能回答这几个问题:

  • V2 到底"减"掉了什么? 答案:Environments API 的整个 poll/dispatch 层------六步生命周期变成三步。但不是所有的 V1 都"慢"------V1 也可以用 V2 的传输协议。
  • FlushGate 是什么?为什么 transport 重建需要它? 一个不到 70 行的状态机,却在 JWT 刷新时保护了消息不丢失、不乱序。
  • Epoch(纪元)为什么是 V2 的核心发明? 每个 /bridge 调用递增 epoch,旧 epoch 的心跳自动失效------不需要显式的"取消旧连接"操作。
  • 笔记本电脑合盖几小时后打开,JWT 过期了怎么办? 答案:authRecoveryInFlight 布尔标志防止双重刷新------scheduler 和 SSE 401 几乎同时触发,但只有一条路径执行。

阅读提示 :如果你想了解远程模式的完整图景,推荐先读本系列的远程模式专栏完整版。本文聚焦 V2 无环境直连架构,可独立阅读,不依赖其他姊妹篇上下文。


本篇覆盖的源码范围

模块 核心文件 核心代码行 职责
V2 无环境核心 src/bridge/remoteBridgeCore.ts L1-1009 跳过 Environments API 的直连架构主入口
会话 API 客户端 src/bridge/codeSessionApi.ts L1-169 createCodeSession() + fetchRemoteCredentials()
传输层适配 src/bridge/replBridgeTransport.ts L1-371 createV2ReplTransport():SSETransport + CCRClient 组合
写栅栏 src/bridge/flushGate.ts L1-72 transport 重建期间的消息排队状态机
JWT 刷新 src/bridge/jwtUtils.ts ~L1-200 createTokenRefreshScheduler() 提前 5 分钟调度
环境变量配置 src/bridge/envLessBridgeConfig.ts ~L1-150 V2 专属配置:超时、重试、心跳参数
准入控制 src/bridge/bridgeEnabled.ts L1-203 isEnvLessBridgeEnabled() 特性开关

前情提要:V1 的代价------轮询延迟是分布式系统的固有代价

在 V1 篇中,我们拆解了六步生命周期------register → poll → ack → heartbeat → stop → deregister。这套架构的优点是成熟稳定------每一步解决一个分布式系统经典问题。但代价是轮询延迟

问题空间:V1 的核心瓶颈不在代码,而在架构模型本身。用户发消息 → 服务端存到队列 → Bridge 轮询拿到 → 确认 → 孵化子进程------这中间有多少个网络往返?即使长轮询最快返回,"最快"不等于"瞬时"。用户点完发送按钮后看着聊天界面"正在连接..."的那几秒,就是 V1 的产品天花板。

约束条件:不能推翻重来。V1 已经部署在生产环境,有活跃用户;CCR v2 传输协议(SSE + CCRClient)已经设计好,工作稳定;OAuth 认证体系不能改------改 OAuth 会影响所有非远程控制功能。

方案思路 :不做重写,做减法重构。砍掉 Environments API 这一整层------register、poll、ack、heartbeat、stop、deregister 全部消失,替换为三步直连:create session → fetch JWT → connect transport。同时保留 CCR v2 传输协议------V1 也可以启用 CCR v2 传输,只是还得走 Environments API 那一层。V2 额外做了"去环境化"。

但在进入深度拆解之前,有一个关键澄清必须前置。remoteBridgeCore.ts 顶部有一段注释精确总结了 V2 的边界:

"Env-less" = no Environments API layer. Distinct from "CCR v2" (the /worker/* transport protocol) --- the env-based path can also use CCR v2 transport. This file is about removing the poll/dispatch layer.

V2 砍掉的是 poll/dispatch 层,不是传输协议。 这就是本文要拆解的。


第一章:三步替代六步------V2 的连接建立

V2 的主入口是 remoteBridgeCore.tsinitEnvLessBridgeCore()(1009 行)。源码用编号注释(── 1.── 10.)标注了完整流程。我们先看前三步------连接建立的核心。

1.1 Step 1:创建会话(createCodeSession)

typescript 复制代码
// codeSessionApi.ts
POST /v1/code/sessions
Headers: Authorization: Bearer {OAuth token}
Body: { title: "Bug fix for auth", bridge: {} }
→ Response: { session: { id: "cse_abc123" } }

注意这是 /v1/code/sessions ,不是 V1 的 /v1/sessions。不需要 environment_id------因为根本没有环境注册。也不需要 session_context(Git 仓库、分支信息)------V2 的服务端从 OAuth 令牌推导这些元数据。

bridge: {} 是一个正向信号 ,告诉服务端这个会话走 Bridge 路径。如果不传(或传 environment_id: ""),服务端现在会返回 400。BridgeRunner 目前是空消息,作为未来 bridge-specific 选项的占位符。

返回的 session ID 是 cse_ 前缀(Code Session),区别于 V1 的 session_ 前缀。客户端有一个兼容 shim(sessionIdCompat.ts)将 cse_* 转换为 session_*,以便 claude.ai 前端路由正常工作。

1.2 Step 2:获取 Bridge 凭证(fetchRemoteCredentials)

typescript 复制代码
// codeSessionApi.ts
POST /v1/code/sessions/{sessionId}/bridge
Headers: Authorization: Bearer {OAuth token}
→ Response: {
    worker_jwt: "eyJ...",       // Worker JWT(不透明,不解码)
    api_base_url: "https://...", // API 基础地址
    expires_in: 3600,            // JWT 有效期(秒)
    worker_epoch: 42             // 工作纪元(整数,可能 int64 字符串)
  }

/bridge 端点用 OAuth 令牌换一个 worker JWT。 这个 JWT 的声明中包含 session_id 和 worker 角色------OAuth 令牌没有这两个声明,所以不能直接用于 worker 端点。

每个 /bridge 调用递增 epoch(服务端计数器)------这是 V2 架构的核心设计。Epoch 使得 JWT 刷新、transport 重建、心跳保活三件事在服务端形成了统一的因果链。

关于 worker_epoch 的类型处理:protojson 序列化 int64 为字符串(避免 JS 精度丢失),但 Go 也可能返回数字。源码做了兼容处理:

typescript 复制代码
const rawEpoch = data.worker_epoch
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
if (!Number.isFinite(epoch) || !Number.isSafeInteger(epoch)) { return null }

1.3 Step 3:建立传输层(createV2ReplTransport)

typescript 复制代码
// replBridgeTransport.ts
transport = await createV2ReplTransport({
  sessionUrl: buildCCRv2SdkUrl(credentials.api_base_url, sessionId),
  ingressToken: credentials.worker_jwt,
  sessionId,
  epoch: credentials.worker_epoch,     // ← 从 /bridge 响应传入
  heartbeatIntervalMs: cfg.heartbeat_interval_ms,
  heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
  getAuthToken: () => credentials.worker_jwt,  // 闭包捕获,不写 env var
})

V2 传输层由两部分组成:

  • SSETransport(读):服务端 → 客户端的 SSE 流,接收入站消息和控制指令
  • CCRClient(写):客户端 → 服务端的 HTTP POST,发送出站消息 + 心跳 + 状态汇报

一个重要的安全设计:getAuthToken 闭包。 源码注释解释了为什么不用环境变量:

Per-instance closure --- keeps the worker JWT out of process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN, which mcp/client.ts reads ungatedly and would otherwise send to user-configured ws/http MCP servers.

如果 JWT 写入 CLAUDE_CODE_SESSION_ACCESS_TOKEN 环境变量,用户配置的 MCP 服务器可能通过 WebSocket/HTTP 读取到它------这是一个安全泄漏。闭包捕获确保 JWT 只在 V2 transport 内部使用。

connect() 的执行顺序 也经过精心编排。sse.connect() 是 fire-and-forget(打开读流),ccr.initialize(epoch) 注册 worker。onConnect 回调在 ccr.initialize() resolve 后触发------此时写路径就绪,可以开始发送消息。SSE 流在 ~30ms 内打开并开始推送入站事件。


第二章:10 步完整流程------为什么是 10 步

前三步(创建会话、获取凭证、建立传输)是 V2 的"连接建立三步曲"------替代了 V1 的 register → poll → ack。但连接建立只是开始。一个可靠的远程会话还需要 JWT 刷新、消息去重、传输重建、auth 恢复、优雅关闭------这些构成了剩余七步。

10 步不是"多",而是 V1 把很多事交给了服务端(心跳、租约、重派),V2 把它们拿回了客户端。 客户端的复杂度增加了,但换来的是零轮询延迟。下面是源码注释中标注的完整 10 步拆解:

2.1 步骤 4:状态初始化

typescript 复制代码
// Echo dedup sets
const recentPostedUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)  // 容量 2000
const initialMessageUUIDs = new Set<string>()  // 初始消息 UUID(无界回退)
const recentInboundUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)

// FlushGate:在历史 flush 期间排队新消息
const flushGate = new FlushGate<Message>()

// 控制标志
let initialFlushDone = false
let tornDown = false
let authRecoveryInFlight = false    // 防止双重 JWT 刷新

双重去重 是防御深度设计。recentPostedUUIDs 是 2000 容量环形缓冲区------可能因大量实时写入而淘汰初始消息 UUID。initialMessageUUIDs 是无界 Set,作为回退------即使环形缓冲区淘汰了初始 UUID,去重依然有效。

2.2 步骤 5:JWT 刷新调度器

typescript 复制代码
const refresh = createTokenRefreshScheduler({
  refreshBufferMs: cfg.token_refresh_buffer_ms,  // 默认 300000(5 分钟)
  getAccessToken: async () => {
    const stale = getAccessToken()
    if (onAuth401) await onAuth401(stale ?? '')
    return getAccessToken() ?? stale
  },
  onRefresh: (sid, oauthToken) => { /* 见下文 */ },
  label: 'remote',
})
refresh.scheduleFromExpiresIn(sessionId, credentials.expires_in)

JWT 刷新不是在过期时触发,而是提前 5 分钟。 这给了足够窗口完成刷新------如果拖到过期那一刻,SSE 流会收到 401 并触发恢复路径(两个路径可能竞争,后面讲)。

getAccessToken 闭包无条件刷新 OAuth (不是检查过期)------因为 getAccessToken() 返回过期令牌作为非空字符串(不检查 expiresAt),truthiness 不代表有效性。传递旧令牌给 onAuth401 是为了让 handleOAuth401Error 做 keychain 比较(检测并行刷新)。

2.3 步骤 6:绑定回调(wireTransportCallbacks)

这是最长的步骤,包含三个核心回调:

onConnect :连接建立后,flush 历史消息到服务端,drain FlushGate 中的排队消息,通知状态变为 connected

onData :收到入站消息时,调用 handleIngressMessage() 路由分发(control_response → 权限确认、control_request → 控制指令、SDKMessage → 用户输入)。

onClose :传输关闭时,区分可恢复(401 → recoverFromAuthFailure)和不可恢复(4090 epoch 冲突、4091 init 失败、4092 重连预算耗尽)两种关闭。

2.4 步骤 7:Transport 重建

rebuildTransport() 是 V2 架构最精妙的设计。在拆解它之前,必须先理解 FlushGate

FlushGate:不到 70 行的状态机

typescript 复制代码
// flushGate.ts
export class FlushGate<T> {
  private _active = false
  private _pending: T[] = []

  start(): void  { this._active = true }
  end(): T[]     { this._active = false; return this._pending.splice(0) }
  enqueue(...items: T[]): boolean {
    if (!this._active) return false  // 不活跃 → 调用者应直接发送
    this._pending.push(...items)
    return true                      // 活跃 → 已排队
  }
  drop(): number { this._active = false; const c = this._pending.length; this._pending.length = 0; return c }
  deactivate(): void { this._active = false }  // 保留排队项,不清空
}

FlushGate 只有两个状态:active (消息排队)和 inactive(消息直接发送)。它的生命周期:

复制代码
start() → 开始排队
  enqueue() 返回 true(消息被排队)
  enqueue() 返回 true
end() → 停止排队,返回排队消息(调用者负责发送)
  enqueue() 返回 false(消息直接发送)

为什么 transport 重建需要 FlushGate? 因为 /bridge 调用后旧 transport 的 epoch 已失效------旧 epoch 的 CCRClient 心跳会在 20 秒内收到 409。如果没有 FlushGate,writeMessages() 添加 UUID 到 recentPostedUUIDs(标记已发送),然后 writeBatch 静默失败(uploader 已关闭)------消息永久丢失。

有了 FlushGate,重建期间的写入被排队。重建完成后 drainFlushGate() 一次性通过新 transport 发送所有排队消息。

Transport 重建的完整流程

typescript 复制代码
async function rebuildTransport(fresh: RemoteCredentials, cause: ConnectCause) {
  connectCause = cause

  // ① 开启写栅栏:后续写入排队
  flushGate.start()

  try {
    // ② 记录当前 SSE 序列号(避免历史重放)
    const seq = transport.getLastSequenceNum()

    // ③ 关闭旧 transport
    transport.close()

    // ④ 创建新 transport(携带序列号 + 新 JWT + 新 epoch)
    transport = await createV2ReplTransport({
      sessionUrl: buildCCRv2SdkUrl(fresh.api_base_url, sessionId),
      ingressToken: fresh.worker_jwt,
      sessionId,
      epoch: fresh.worker_epoch,
      initialSequenceNum: seq,         // ← 关键:从旧序列号继续
      heartbeatIntervalMs: cfg.heartbeat_interval_ms,
      heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
      getAuthToken: () => fresh.worker_jwt,
      outboundOnly,
    })

    // ⑤ 重新绑定回调
    wireTransportCallbacks()

    // ⑥ 建立连接
    transport.connect()

    // ⑦ 重新调度下次刷新
    refresh.scheduleFromExpiresIn(sessionId, fresh.expires_in)

    // ⑧ 排空 FlushGate------所有排队消息通过新 transport 发送
    drainFlushGate()
  } finally {
    flushGate.drop()  // 失败路径:丢弃排队消息(transport 仍然不可用)
  }
}

第 ② 步传递序列号是整个机制的精华。 如果不传 initialSequenceNum,服务端从 seq 0 开始重放整个会话历史------客户端会收到大量重复消息。传了序列号,服务端从上次断开的地方继续推送。源码中的注释说得精确:

Passed to the new SSETransport so its first connect() sends from_sequence_num / Last-Event-ID and the server resumes from where the old stream left off. Without this, every transport swap asks the server to replay the entire session history from seq 0.

第 ⑧ 步的 drainFlushGate 在 ccr.initialize() resolve 之前运行。 transport.connect() 是 fire-and-forget------SSE 流和 CCR 初始化异步进行。但 SerialBatchEventUploader 内部的队列在初始化完成前会缓冲消息。所以 drain 在初始化之前运行也没问题------消息等待 uploader 就绪后自动发送。

2.5 步骤 8:401 恢复(recoverFromAuthFailure)

当 SSE 流收到 401(JWT 过期)时,setOnClose 触发 recoverFromAuthFailure()

typescript 复制代码
async function recoverFromAuthFailure() {
  // 双重刷新防护------setOnClose 和 onRefresh 可能同时触发
  if (authRecoveryInFlight) return
  authRecoveryInFlight = true
  onStateChange?.('reconnecting', 'JWT expired --- refreshing')

  try {
    // 无条件刷新 OAuth
    const stale = getAccessToken()
    if (onAuth401) await onAuth401(stale ?? '')
    const oauthToken = getAccessToken() ?? stale

    // 获取新凭证(含新 JWT + 新 epoch)
    const fresh = await fetchRemoteCredentials(sessionId, baseUrl, oauthToken, ...)

    // 401 可能打断了初始 flush------writeBatch 已在关闭的 uploader 上
    // 静默 no-op。重置 initialFlushDone,让新 onConnect 重新 flush。
    initialFlushDone = false

    // 重建 transport
    await rebuildTransport(fresh, 'auth_401_recovery')
  } finally {
    authRecoveryInFlight = false
  }
}

笔记本电脑唤醒是典型的边界场景。 笔记本合盖几小时 → 打开 → JWT 已过期。此时两个路径几乎同时触发:

  • scheduler 的 proactive refresh (超时,onRefresh 回调)
  • SSE 的 401 onClose(服务端检测到过期 JWT)

源码用 authRecoveryInFlight 布尔标志 防止双重刷新------先拿到标志的路径执行,另一个看到标志已设置就跳过。更关键的是:如果两者都执行,会产生两个 /bridge 调用 → 两个 epoch → 第一个 rebuild 获得过时的 epoch → 409 错误。

源码注释精确描述了这个竞态:

Laptop wake fires both paths ~simultaneously. Claim the flag BEFORE the /bridge fetch so the other path skips entirely --- prevents double epoch bump (each /bridge call bumps; if both fetch, the first rebuild gets a stale epoch and 409s).

2.6 步骤 9:历史 flush 与 drain

连接建立后,onConnect 中调用 flushHistory(initialMessages)

typescript 复制代码
async function flushHistory(msgs: Message[]) {
  const eligible = msgs.filter(isEligibleBridgeMessage)
  const capped = initialHistoryCap > 0 && eligible.length > initialHistoryCap
    ? eligible.slice(-initialHistoryCap)  // 取最后 N 条
    : eligible
  const events = toSDKMessages(capped).map(m => ({ ...m, session_id: sessionId }))
  await transport.writeBatch(events)
}

V2 始终创建新的服务端会话 (不像 V1 可以复用环境),所以不需要担心重复 flush。V1 的 previouslyFlushedUUIDs 机制在 V2 中不适用------那个 Set 跨 REPL enable/disable 周期持久化,会错误地抑制重新启用后的历史 flush。

容量上限 由 GrowthBook 配置 tengu_bridge_initial_history_cap 控制(默认 200 条)。slice(-cap)最后 N 条------最新的消息最重要。

2.7 步骤 10:Teardown(优雅关闭)

typescript 复制代码
async function teardown() {
  if (tornDown) return
  tornDown = true

  // ① 取消所有定时器
  refresh.cancelAll()
  clearTimeout(connectDeadline)
  flushGate.drop()

  // ② 在 archive 之前发送结果消息
  // archive 是 HTTP POST(~100-500ms),给 uploader drain 留出窗口
  transport.reportState('idle')
  void transport.write(makeResultMessage(sessionId))

  // ③ 归档会话
  let status = await archiveSession(sessionId, baseUrl, token, orgUUID, ...)

  // ④ archive 401 重试(笔记本电脑唤醒后 token 过期)
  if (status === 401 && onAuth401) {
    await onAuth401(token ?? '')
    status = await archiveSession(...)
  }

  // ⑤ 关闭传输(必须在 archive 之后)
  transport.close()
}

第 ②-⑤ 步的顺序是精心编排的。 源码注释解释了为什么 archiveSessiontransport.close() 之前:

close() sets closed=true which interrupts drain at the next while-check, so close-before-archive drops the result.

如果先 close,uploader 的 drain 循环在下一次 while-check 时被打断------结果消息可能丢失。先 archive(HTTP POST,异步),在 archive 的 ~100-500ms 内 uploader 完成 drain,然后 close 安全执行。


第三章:Epoch------V2 的核心发明

Epoch 不是 V2 独有的(CCR v1 也有),但 V2 的环境去除让 epoch 承担了更重的协调职责。

Epoch 的三个关键属性:

  1. 单调递增 :每个 /bridge 调用在服务端递增 epoch 计数器。旧 epoch 的心跳自动失效,不需要显式的"取消旧连接"。
  2. JWT 内嵌:worker JWT 包含当前 epoch。服务端验证 JWT 的同时检查 epoch------过期 JWT = 过期 epoch。
  3. 409 冲突检测 :旧 epoch 的 CCRClient 心跳在 20 秒内收到 409(epoch mismatch),触发 onEpochMismatch → 关闭 transport → 通知 replBridge。

为什么 JWT 刷新必须重建整个 transport? 源码注释解释:

Schedule a callback 5min before expiry. On fire, re-fetch /bridge with OAuth → rebuild transport with fresh credentials. Each /bridge call bumps epoch server-side, so a JWT-only swap would leave the old CCRClient heartbeating with a stale epoch → 409 within 20s.

不是"刷新 JWT",而是"重建整个 transport + 新 JWT + 新 epoch"。 三者不可分割------只换 JWT 不换 epoch,心跳会 409;只换 epoch 不换 JWT,API 调用会 401。


第四章:CCR Mirror------只出不进的特殊模式

V2 架构还支持一种特殊模式:CCR MirroroutboundOnly = true)。

typescript 复制代码
if (feature('CCR_MIRROR') && outboundOnly) {
  // SSE 流不打开,只激活 CCRClient 写路径
  // 本地会话事件转发到 claude.ai 显示,但不接受远程控制
}

CCR Mirror 模式下,SSE 读流完全跳过------connect() 只调用 ccr.initialize(),不调用 sse.connect()。本地会话的所有事件通过 CCRClient 转发到 claude.ai 显示(像一面"镜子"),但用户不能从 claude.ai 发消息回来。

Control Request 的处理也不同。outboundOnly 模式下,所有可变请求(interruptset_modelset_permission_modeset_max_thinking_tokens)返回 error 而非假成功。只有 initialize 必须成功------否则服务端会杀掉连接。


本章小结

V2 架构的设计哲学可以归纳为三个关键词:减法、双工、容错。

  • 减法:砍掉 Environments API 的六步生命周期,用三步直连替代。不是重写,是去掉不需要的抽象。
  • 双工:SSE(读)+ CCRClient(写)分离,各自独立管理。写路径不依赖 SSE 流状态------即使 SSE 断开,CCRClient 也能继续发送。
  • 容错 :JWT 提前 5 分钟刷新、FlushGate 保护重建期间的消息、authRecoveryInFlight 防止双重刷新、epoch 机制消除旧连接歧义。

V2 最值得学习的不是"用了什么新技术"------它用的 SSE、HTTP POST、JWT 都是成熟的 Web 技术。值得学习的是"如何把成熟技术组合成一个可靠的分布式系统":连接管理、认证轮换、消息去重、优雅降级------这些才是 1009 行代码真正在做的事。


系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列中「远程与非 CLI 形态」命题的子篇章,完整版见本系列远程模式专栏。

本系列其他子篇章覆盖了 V1 环境注册与轮询架构、鉴权链与会话生命周期、消息路由与传输层等命题,可独立阅读。


如果这篇文章对你有帮助,欢迎点赞收藏 支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列 后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

相关推荐
逍遥浪子~1 小时前
知识问答实践
ai·知识问答·ai报表
devpotato2 小时前
人工智能(十六)- SSE 流式:让 Agent 像 ChatGPT 一样"边想边说"
langchain·llm·agent
周周爱喝粥呀2 小时前
在Cursor中配置MCP Server
ai
用户948866003432 小时前
把串行 Agent Pipeline 改成 Temporal 工作流之后,快了 3 倍
agent
晨启AI2 小时前
Perplexity 如何设计 Agent Skills,以及我们能学什么
ai·skill
Agent手记2 小时前
空运智能装箱规划自动化、落地方法与合规适配:2026年Agent矩阵驱动的技术演进与实操指引
运维·人工智能·ai·矩阵·自动化
DigitalOcean2 小时前
AI 推理引擎四大模式:无服务推理、专用推理、批量推理与智能路由,怎么选?
llm·aigc·agent
GISer_Jing2 小时前
Claude Code项目配置终极指南
前端·ai·ai编程
程序员鱼皮2 小时前
别再说 AI 开发就是调接口了!5 种主流模式一次讲清
计算机·ai·程序员·编程·ai编程