第 6 章 · 桥接通信系统
当你在 claude.ai 网页端输入一条指令,它如何穿越网络到达你本地机器上运行的 CLI 进程?当 CLI 执行完工具调用后,结果又如何实时回传到浏览器?这一切的幕后功臣就是桥接通信系统(Bridge System)。本章将带你深入理解
src/bridge/目录下 31 个文件的协作机制:从环境注册、工作轮询到会话生命周期管理,从 JWT 认证到消息协议,从独立桥接模式到 REPL 内嵌桥接。
6.1 概述:桥接系统的角色
桥接系统是 Claude Code 实现"远程控制"(Remote Control)功能的核心基础设施。它解决了一个关键问题:如何让运行在云端的 IDE 界面(claude.ai)与运行在用户本地的 CLI 进程实现安全、可靠的双向通信。
桥接系统的核心职责包括:
- 双向通信:在 IDE 扩展(Web 端)和本地 CLI 之间建立实时消息通道
- 会话管理:创建、运行、恢复和归档远程会话
- 安全认证:通过 JWT 和 OAuth 确保通信安全,通过可信设备机制增强安全等级
- 消息协议:定义和处理 SDK 消息、控制请求/响应等多种消息类型
- 容错恢复:处理网络断连、Token 过期、进程崩溃等异常场景
桥接系统的核心文件分布如下:
| 文件 | 职责 |
|---|---|
src/bridge/bridgeMain.ts | 独立桥接模式入口,多会话轮询循环 |
src/bridge/replBridge.ts | REPL 内嵌桥接核心逻辑 |
src/bridge/initReplBridge.ts | REPL 桥接初始化入口 |
src/bridge/bridgeMessaging.ts | 消息协议定义与处理 |
src/bridge/jwtUtils.ts | JWT Token 解码与刷新调度 |
src/bridge/createSession.ts | 会话创建 API 封装 |
src/bridge/sessionRunner.ts | 会话进程生成与管理 |
src/bridge/bridgeApi.ts | 桥接 API 客户端 |
src/bridge/types.ts | 核心类型定义 |
src/bridge/trustedDevice.ts | 可信设备认证 |
src/bridge/replBridgeTransport.ts | REPL 桥接传输层 |
src/bridge/bridgeConfig.ts | 桥接配置与认证解析 |
src/bridge/bridgePointer.ts | 崩溃恢复指针 |
src/bridge/flushGate.ts | 消息刷新门控 |
src/bridge/workSecret.ts | 工作密钥解码与 URL 构建 |
6.2 双向通信架构
两种桥接模式
桥接系统提供两种运行模式,适用于不同的使用场景:
-
独立桥接模式(Standalone Bridge):通过
claude remote-control命令启动,由bridgeMain.ts驱动。这是一个独立进程,专门负责轮询服务器获取工作、生成子进程执行会话。支持多会话并发。 -
REPL 内嵌桥接模式(REPL Bridge):在交互式 REPL 会话中自动启动,由
initReplBridge.ts→replBridge.ts驱动。它复用当前 REPL 进程,通过 WebSocket 或 SSE 传输层与服务器通信。
通信链路全景
一条用户消息从 Web 端到达本地 CLI 的完整链路如下:
环境注册与工作轮询
独立桥接模式的核心是一个"注册-轮询-执行"循环。桥接进程首先向 CCR 服务器注册一个"环境"(Environment),然后持续轮询等待工作分配:
export async function runBridgeLoop(
config: BridgeConfig,
environmentId: string,
environmentSecret: string,
api: BridgeApiClient,
spawner: SessionSpawner,
logger: BridgeLogger,
signal: AbortSignal,
backoffConfig: BackoffConfig = DEFAULT_BACKOFF,
initialSessionId?: string,
getAccessToken?: () => string | undefined | Promise<string | undefined>,
): Promise<void> {
const controller = new AbortController()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
const loopSignal = controller.signal
const activeSessions = new Map<string, SessionHandle>()
const sessionStartTimes = new Map<string, number>()
const sessionWorkIds = new Map<string, string>()
// ...
}
runBridgeLoop 维护了多个 Map 来追踪活跃会话的各种状态——启动时间、工作 ID、兼容性 ID、入口 Token 等。这种"多 Map 并行追踪"的模式虽然 看起来冗余,但避免了创建复杂的嵌套对象,每个 Map 的生命周期可以独立管理。
6.3 JWT 认证机制
认证架构概览
桥接系统采用多层认证机制确保通信安全:
- OAuth 2.0 Token:用户通过 claude.ai 登录获取,用于桥接 API 调用(环境注册、会话创建等)
- Session Ingress JWT:服务器为每个会话签发的短期 Token,用于 WebSocket/SSE 连接认证
- 可信设备 Token:持久化在本地 keychain 中的设备级 Token,用于提升安全等级
JWT 解码与过期检测
jwtUtils.ts 提供了 JWT 的客户端解码能力(不验证签名,仅提取 payload):
/**
* Decode a JWT's payload segment without verifying the signature.
* Strips the `sk-ant-si-` session-ingress prefix if present.
*/
export function decodeJwtPayload(token: string): unknown | null {
const jwt = token.startsWith('sk-ant-si-')
? token.slice('sk-ant-si-'.length)
: token
const parts = jwt.split('.')
if (parts.length !== 3 || !parts[1]) return null
try {
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
} catch {
return null
}
}
注意高亮部分:Session Ingress Token 带有 sk-ant-si- 前缀,解码前需要先剥离。这是 Anthropic 内部的 Token 命名约定,si 代表 "session ingress"。
Token 刷新调度器
长时间运行的桥接会话需要在 Token 过期前主动刷新。createTokenRefreshScheduler 实现了一个精巧的刷新调度器:
/** Refresh buffer: request a new token before expiry. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 // 提前 5 分钟
/** Fallback refresh interval when the new token's expiry is unknown. */
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 分钟兜底
/** Max consecutive failures before giving up on the refresh chain. */
const MAX_REFRESH_FAILURES = 3
export function createTokenRefreshScheduler({
getAccessToken,
onRefresh,
label,
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
}: {
getAccessToken: () => string | undefined | Promise<string | undefined>
onRefresh: (sessionId: string, oauthToken: string) => void
label: string
refreshBufferMs?: number
}): {
schedule: (sessionId: string, token: string) => void
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
cancel: (sessionId: string) => void
cancelAll: () => void
} {
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const failureCounts = new Map<string, number>()
// Generation counter per session — incremented by schedule() and cancel()
// so that in-flight async doRefresh() calls can detect when they've been
// superseded and should skip setting follow-up timers.
const generations = new Map<string, number>()
// ...
}
调度器的核心设计要点:
- 提前刷新:在 Token 过期前 5 分钟触发刷新,避免"刚好过期"的竞态
- 代际计数器(Generation Counter):每次
schedule()或cancel()都递增代际号。异步的doRefresh()在完成后检查代际号是否匹配,如果不匹配说明已被新的调度取代,直接放弃——这优雅地解决了并发刷新的竞态问题 - 失败重试链:最多重试 3 次,每次间隔 60 秒。成功后重置失败计数
- 后续刷新:每次成功刷新后自动安排 30 分钟后的下一次刷新,确保长时间运行的会话始终保持认证状态
可信设备机制
对于安全等级更高的桥接会话(SecurityTier=ELEVATED),系统还引入了可信设备认证:
/**
* Enroll this device via POST /auth/trusted_devices and persist the token
* to keychain. Best-effort — logs and returns on failure so callers
* (post-login hooks) don't block the login flow.
*/
export async function enrollTrustedDevice(): Promise<void> {
// ...
const response = await axios.post<{
device_token?: string
device_id?: string
}>(
`${baseUrl}/api/auth/trusted_devices`,
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 10_000,
validateStatus: s => s < 500,
},
)
// ...
}
可信设备 Token 的生命周期:
- 注册时机:用户执行
claude auth login后立即注册(服务器要求account_session.created_at < 10min) - 存储位置:持久化在系统 keychain(macOS Security Framework / Linux Secret Service)
- 使用方式:每次桥接 API 调用时通过
X-Trusted-Device-Token请求头发送 - 缓存策略:使用
memoize缓存 keychain 读 取结果(避免每次调用都 spawn macOSsecurity子进程),登录和登出时清除缓存
可信设备机制是桥接系统安全层的一部分,与第 9 章介绍的权限系统(工具权限检查、OAuth 认证流程)共同构成了完整的安全保障体系。桥接系统中的 bridgePermissionCallbacks.ts 定义了权限请求/响应的回调接口,使得 Web 端可以远程审批工具执行权限。
6.4 会话生命周期
会话创建
会话创建是桥接通信的起点。createSession.ts 封装了向 CCR 服务器发起 POST /v1/sessions 请求的完整逻辑:
export async function createBridgeSession({
environmentId,
title,
events,
gitRepoUrl,
branch,
signal,
baseUrl: baseUrlOverride,
getAccessToken,
permissionMode,
}: {
environmentId: string
title?: string
events: SessionEvent[]
gitRepoUrl: string | null
branch: string
signal: AbortSignal
baseUrl?: string
getAccessToken?: () => string | undefined
permissionMode?: string
}): Promise<string | null> {
// ...
const requestBody = {
...(title !== undefined && { title }),
events,
session_context: {
sources: gitSource ? [gitSource] : [],
outcomes: gitOutcome ? [gitOutcome] : [],
model: getMainLoopModel(),
},
environment_id: environmentId,
source: 'remote-control',
...(permissionMode && { permission_mode: permissionMode }),
}
// ...
}
会话创建时携带了丰富的上下文信息:
- Git 源信息:仓库 URL、分支名,让 Web 端显示代码来源
- Git 输出信息:预期的输出分支(如
claude/task),用于 PR 创建 - 模型信息:当前使用的 LLM 模型
- 权限模式:工具执行的权限策略
对于 CCR v2 路径,codeSessionApi.ts 提供了更轻量的会话创建接口:
export async function createCodeSession(
baseUrl: string,
accessToken: string,
title: string,
timeoutMs: number,
tags?: string[],
): Promise<string | null> {
const url = `${baseUrl}/v1/code/sessions`
const response = await axios.post(
url,
{ title, bridge: {}, ...(tags?.length ? { tags } : {}) },
{
headers: oauthHeaders(accessToken),
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
// ...
}
注意 bridge: {} 这个空对象——它是服务器端 oneof runner 的正向信号,告诉服务器这是一个桥接会话(而非容器会话)。省略它会导致 400 错误。这种"空对象作为类型标记"的模式在 protobuf/gRPC 系统中很常见。
会话运行:进程生成
在独立桥接模式下,每个会话对应一个子进程。sessionRunner.ts 中的 createSessionSpawner 负责生成和管理这些子进程:
function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
// ...
return {
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
// 构建子进程参数
// 子进程通过 stdin/stdout 与桥接进程通信
// 返回 SessionHandle 用于控制子进程生命周期
}
}
}
SessionHandle(定义在 types.ts)提供了对子进程的完整控制接口:
// SessionHandle 的关键方法
function kill(): void // 优雅终止
function forceKill(): void // 强制终止
function writeStdin(data: string): void // 写入消息
function updateAccessToken(token: string): void // 更新 Token
sessionRunner.ts 还负责从子进程的输出中提取活动信息,用于在 Web 端显示会话状态:
function toolSummary(name: string, input: Record<string, unknown>): string {
// 生成工具调用的简短摘要,如 "Read src/main.ts"
}
function extractActivities(
// 从子进程输出中提取工具调用活动
// 用于在 Web 端实时显示 "正在读取文件..." 等状态
): SessionActivity[]
崩溃恢复:Bridge Pointer
如果桥接进程意外崩溃(kill -9、终端关闭等),如何恢复之前的会话?bridgePointer.ts 实现了一个优雅的崩溃恢复机制:
/**
* Crash-recovery pointer for Remote Control sessions.
*
* Written immediately after a bridge session is created, periodically
* refreshed during the session, and cleared on clean shutdown.
*/
export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000 // 4 小时
const BridgePointerSchema = lazySchema(() =>
z.object({
sessionId: z.string(),
environmentId: z.string(),
source: z.enum(['standalone', 'repl']),
}),
)
Bridge Pointer 的工作原理:
- 写入时机:会话创建后立即写入
~/.claude/projects/{dir}/bridge-pointer.json - 定期刷新:会话运行期间定期重写(更新 mtime),保持"新鲜度"
- 清理时机:正常关闭时删除
- 恢复检测:下次启动
claude remote-control时检测到未清理的 pointer,提示用户恢复 - 过期判断:基于文件 mtime(而非内嵌时间戳),超过 4 小时视为过期并自动清理
/**
* Worktree-aware read for `--continue`. Fans out across git worktree
* siblings to find the freshest pointer.
*/
export async function readBridgePointerAcrossWorktrees(
dir: string,
): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> {
// Fast path: current dir
const here = await readBridgePointer(dir)
if (here) return { pointer: here, dir }
// Fanout: scan worktree siblings
const worktrees = await getWorktreePathsPortable(dir)
if (worktrees.length <= 1) return null
if (worktrees.length > MAX_WORKTREE_FANOUT) return null
// Parallel stat+read, pick freshest
const results = await Promise.all(
candidates.map(async wt => {
const p = await readBridgePointer(wt)
return p ? { pointer: p, dir: wt } : null
}),
)
// ...
}
恢复机制支持 Git Worktree——如果用户在 worktree A 中启动了桥接,然后在 worktree B 中执行 --continue,系统会扫描所有 worktree 兄弟目录找到最新的 pointer。这种设计考虑到了实际开发中多 worktree 并行工作的场景。
6.5 消息协议
消息类型体系
bridgeMessaging.ts 定义了桥接系统的消息协议。消息分为三大类:
- SDK 消息(SDKMessage):用户消息、助手响应等标准对话消息
- 控制请求(SDKControlRequest):权限审批请求等需要 Web 端响应的请求
- 控制响应(SDKControlResponse):Web 端对控制请求的回复
function isSDKMessage(value: unknown): value is SDKMessage {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
typeof value.type === 'string' &&
!('control' in value) // 排除控制消息
)
}
function isSDKControlResponse(
value: unknown,
): value is SDKControlResponse {
return (
value !== null &&
typeof value === 'object' &&
'control' in value &&
value.control === 'response' &&
'request_id' in value
)
}
function isSDKControlRequest(
value: unknown,
): value is SDKControlRequest {
return (
value !== null &&
typeof value === 'object' &&
'control' in value &&
value.control === 'request'
)
}
入站消息处理
handleIngressMessage 是消息处理的核心函数,它根据消息类型分发到不同的处理逻辑:
function handleIngressMessage(
// 处理从服务器接收的入站消息
// 根据消息类型分发:
// - SDK 消息 → 注入到会话消息队列
// - 控制响应 → 传递给权限回调
// - 控制请求 → 处理服务器发起的控制指令
)
对于用户消息,inboundMessages.ts 负责提取和规范化消息内容:
/**
* Process an inbound user message from the bridge, extracting content
* and UUID for enqueueing. Supports both string content and
* ContentBlockParam[] (e.g. messages containing images).
*/
export function extractInboundMessageFields(
msg: SDKMessage,
):
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
| undefined {
if (msg.type !== 'user') return undefined
const content = msg.message?.content
if (!content) return undefined
if (Array.isArray(content) && content.length === 0) return undefined
return {
content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
uuid: /* ... */,
}
}
normalizeImageBlocks 处理了一个实际的跨平台问题:iOS/Web 客户端可能发送 mediaType(camelCase)而非 media_type(snake_case),或者完全省略该字段。如果不做规范化,一个格式错误的图片块会"毒化"整个会话——后续所有 API 调用都会因为 media_type: Field required 而失败。
消息去重:BoundedUUIDSet
网络重传可能导致消息重复。BoundedUUIDSet 是一个固定容量的环形缓冲区,用于高效去重:
class BoundedUUIDSet {
private capacity: number
private set = new Set<string>()
private queue: string[] = []
constructor(capacity: number) {
this.capacity = capacity
}
add(uuid: string): void {
if (this.set.has(uuid)) return
if (this.set.size >= this.capacity) {
// 环形淘汰:移除最早的 UUID
const oldest = this.queue.shift()!
this.set.delete(oldest)
}
this.set.add(uuid)
this.queue.push(uuid)
}
has(uuid: string): boolean {
return this.set.has(uuid)
}
}
默认容量为 2000(通过 envLessBridgeConfig.ts 的 uuid_dedup_buffer_size 配置),足以覆盖正常会话中的消息量,同时保持内存占用可控。