Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

Remote Control 的「用户可见面」与「服务端契约面」集中在四文件:bridgePermissionCallbacks.ts 定义 can_use_tool 双向协议类型;bridgeUI.ts(约 530 行)实现 remote-control TUI(QR、状态机、多 session 列表);bridgeApi.ts(约 539 行)封装 Environments API(register/poll/ack/heartbeat/reconnect);trustedDevice.ts(约 210 行)管理 ELEVATED 会话的 X-Trusted-Device-Token。本章要求你能从 claude.ai 权限弹窗追到 CLI sendResponse,并理解 OAuth 401 刷新与设备注册的 staged rollout。

学完本章你应该能

  • 说明 BridgePermissionCallbacks 与 isBridgePermissionResponse 的职责
  • 解释 createBridgeLogger 的状态机与终端行计数
  • 描述 createBridgeApiClient 的 withOAuthRetry 与 validateBridgeId
  • 理解 getTrustedDeviceToken 与 enrollTrustedDevice 的门控关系
  • 能在 bridgeMain 中定位 logger 与 api 的协作点

核心概念(先读懂这些)

bridgePermissionCallbacks 仅类型,无实现

实现由 useReplBridge / replBridge 闭包提供:sendRequest 走 sendControlRequest,onResponse 注册一次性 handler。文件保持零 runtime 依赖,避免 permissions 模块反向 import bridge 重逻辑。

bridgeApi 注入 onAuth401 的原因

utils/auth handleOAuth401Error 经 config→file→permissions→sessionStorage→commands 拖入 ~1300 模块。Daemon 用 env token 不传 onAuth401,401 直接 BridgeFatalError。

CLI 与 server 双 flag 可信设备

tengu_sessions_elevated_auth_enforcement 控制是否发送 header;服务端 sessions_elevated_auth_enforcement 控制是否强制。可先开 CLI 再开 server,实现 staged rollout。

建议学习步骤

  1. 阅读源码块 A:BridgePermissionCallbacks 类型
  2. 阅读源码块 B:createBridgeLogger 状态渲染
  3. 阅读源码块 C:createBridgeApiClient 注册与 poll
  4. 阅读源码块 D:withOAuthRetry 与 getHeaders
  5. 阅读源码块 E:getTrustedDeviceToken
  6. 阅读源码块 F:enrollTrustedDevice

常见误区

注意

bridgePermissionCallbacks 不包含 can_use_tool 业务裁决,仅在 REPL permissions 层

注意

clearStatusLines 依赖 countVisualLines,窄终端换行估算偏差

注意

enrollTrustedDevice 须在 login 10min 内,否则 stale_session 403

注意

validateBridgeId 拒绝含 / 的 id,防路径遍历

在架构中的位置

权限与 UI 在 bridge 栈中的横向关系:

claude.ai 用户点击 Allow/Deny
  → server control_response
  → handleIngressMessage → onPermissionResponse
  → BridgePermissionCallbacks.onResponse handler
  → REPL canUseTool 继续或拒绝

本地工具要权限
  → canUseTool → sendRequest (control_request)
  → server 推送到 web → 用户操作
  → sendResponse 经 transport.write 回传

bridgeMain TUI
  → createBridgeLogger
  → printBanner / updateIdleStatus / session 列表
  → 底层仍用 bridgeApi poll

bridgeStatusUtil.ts 提供 URL 构建、时长格式化,被 bridgeUI 引用,本章不展开。

BridgePermissionCallbacks 协议

BridgePermissionResponse 判别式:

  • behavior: 'allow' | 'deny'
  • 可选 updatedInput、updatedPermissions(PermissionUpdate[])、message

BridgePermissionCallbacks 四个方法:

方法方向
sendRequestCLI → server,携带 toolName、input、toolUseId、description、suggestions、blockedPath
sendResponseCLI → server,回复用户决定
cancelRequest取消 pending prompt,web 端 dismiss
onResponse注册 requestId 级订阅,返回 unsubscribe

isBridgePermissionResponse 用 behavior 字段做类型谓词,避免对 control_response payload 无脑 as 转型。

REPL 侧 useCanUseTool 在 bridge 连接时把 web 决策与本地规则合并;deny 时 message 可展示给用户。

源码引用: src/bridge/bridgePermissionCallbacks.ts · 第 1–43 行(共 44 行)

   1| import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
   2| 
   3| type BridgePermissionResponse = {
   4|   behavior: 'allow' | 'deny'
   5|   updatedInput?: Record<string, unknown>
   6|   updatedPermissions?: PermissionUpdate[]
   7|   message?: string
   8| }
   9| 
  10| type BridgePermissionCallbacks = {
  11|   sendRequest(
  12|     requestId: string,
  13|     toolName: string,
  14|     input: Record<string, unknown>,
  15|     toolUseId: string,
  16|     description: string,
  17|     permissionSuggestions?: PermissionUpdate[],
  18|     blockedPath?: string,
  19|   ): void
  20|   sendResponse(requestId: string, response: BridgePermissionResponse): void
  21|   /** Cancel a pending control_request so the web app can dismiss its prompt. */
  22|   cancelRequest(requestId: string): void
  23|   onResponse(
  24|     requestId: string,
  25|     handler: (response: BridgePermissionResponse) => void,
  26|   ): () => void // returns unsubscribe
  27| }
  28| 
  29| /** Type predicate for validating a parsed control_response payload
  30|  *  as a BridgePermissionResponse. Checks the required `behavior`
  31|  *  discriminant rather than using an unsafe `as` cast. */
  32| function isBridgePermissionResponse(
  33|   value: unknown,
  34| ): value is BridgePermissionResponse {
  35|   if (!value || typeof value !== 'object') return false
  36|   return (
  37|     'behavior' in value &&
  38|     (value.behavior === 'allow' || value.behavior === 'deny')
  39|   )
  40| }
  41| 
  42| export { isBridgePermissionResponse }
  43| export type { BridgePermissionCallbacks, BridgePermissionResponse }

createBridgeLogger TUI

createBridgeLogger 返回 BridgeLogger 接口,供 bridgeMain 驱动终端 UX:

状态机:idle / connecting(spinner)/ active / reconnecting / failed。connecting 用 BRIDGE_SPINNER_FRAMES 150ms 刷新。

行计数:writeStatus 通过 countVisualLines 考虑 ANSI 宽度与自动换行;clearStatusLines 用 ESC 光标上移 + 擦除下方,避免污染 scrollback。

QR:printBanner 时 buildBridgeConnectUrl + qrcode utf8 小码;空格键 toggle qrVisible。

多 session:sessionDisplayInfo Map 存 title、url、activity;Capacity N/M 与 spawn mode hint(same-dir vs worktree)。

工具行:单 session 且非 idle 时显示 lastToolSummary(TOOL_DISPLAY_EXPIRY_MS 内)。

ant-only:debugLogPath 提示 tail 路径,与 sessionRunner 日志 glob 一致。

OSC8 链接:wrapWithOsc8Link 让支持超链接的终端点击打开 claude.ai session。

源码引用: src/bridge/bridgeUI.ts · 第 30–131 行(共 531 行)

  30| const QR_OPTIONS = {
  31|   type: 'utf8' as const,
  32|   errorCorrectionLevel: 'L' as const,
  33|   small: true,
  34| }
  35| 
  36| /** Generate a QR code and return its lines. */
  37| async function generateQr(url: string): Promise<string[]> {
  38|   const qr = await qrToString(url, QR_OPTIONS)
  39|   return qr.split('\n').filter((line: string) => line.length > 0)
  40| }
  41| 
  42| export function createBridgeLogger(options: {
  43|   verbose: boolean
  44|   write?: (s: string) => void
  45| }): BridgeLogger {
  46|   const write = options.write ?? ((s: string) => process.stdout.write(s))
  47|   const verbose = options.verbose
  48| 
  49|   // Track how many status lines are currently displayed at the bottom
  50|   let statusLineCount = 0
  51| 
  52|   // Status state machine
  53|   let currentState: StatusState = 'idle'
  54|   let currentStateText = 'Ready'
  55|   let repoName = ''
  56|   let branch = ''
  57|   let debugLogPath = ''
  58| 
  59|   // Connect URL (built in printBanner with correct base for staging/prod)
  60|   let connectUrl = ''
  61|   let cachedIngressUrl = ''
  62|   let cachedEnvironmentId = ''
  63|   let activeSessionUrl: string | null = null
  64| 
  65|   // QR code lines for the current URL
  66|   let qrLines: string[] = []
  67|   let qrVisible = false
  68| 
  69|   // Tool activity for the second status line
  70|   let lastToolSummary: string | null = null
  71|   let lastToolTime = 0
  72| 
  73|   // Session count indicator (shown when multi-session mode is enabled)
  74|   let sessionActive = 0
  75|   let sessionMax = 1
  76|   // Spawn mode shown in the session-count line + gates the `w` hint
  77|   let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
  78|   let spawnMode: SpawnMode = 'single-session'
  79| 
  80|   // Per-session display info for the multi-session bullet list (keyed by compat sessionId)
  81|   const sessionDisplayInfo = new Map<
  82|     string,
  83|     { title?: string; url: string; activity?: SessionActivity }
  84|   >()
  85| 
  86|   // Connecting spinner state
  87|   let connectingTimer: ReturnType<typeof setInterval> | null = null
  88|   let connectingTick = 0
  89| 
  90|   /**
  91|    * Count how many visual terminal rows a string occupies, accounting for
  92|    * line wrapping. Each `\n` is one row, and content wider than the terminal
  93|    * wraps to additional rows.
  94|    */
  95|   function countVisualLines(text: string): number {
  96|     // eslint-disable-next-line custom-rules/prefer-use-terminal-size
  97|     const cols = process.stdout.columns || 80 // non-React CLI context
  98|     let count = 0
  99|     // Split on newlines to get logical lines
 100|     for (const logical of text.split('\n')) {
 101|       if (logical.length === 0) {
 102|         // Empty segment between consecutive \n — counts as 1 row
 103|         count++
 104|         continue
 105|       }
 106|       const width = stringWidth(logical)
 107|       count += Math.max(1, Math.ceil(width / cols))
 108|     }
 109|     // The trailing \n in "line\n" produces an empty last element — don't count it
 110|     // because the cursor sits at the start of the next line, not a new visual row.
 111|     if (text.endsWith('\n')) {
 112|       count--
 113|     }
 114|     return count
 115|   }
 116| 
 117|   /** Write a status line and track its visual line count. */
 118|   function writeStatus(text: string): void {
 119|     write(text)
 120|     statusLineCount += countVisualLines(text)
 121|   }
 122| 
 123|   /** Clear any currently displayed status lines. */
 124|   function clearStatusLines(): void {
 125|     if (statusLineCount <= 0) return
 126|     logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
 127|     // Move cursor up to the start of the status block, then erase everything below
 128|     write(`\x1b[${statusLineCount}A`) // cursor up N lines
 129|     write('\x1b[J') // erase from cursor to end of screen
 130|     statusLineCount = 0
 131|   }

源码引用: src/bridge/bridgeUI.ts · 第 187–292 行(共 531 行)

 187|   /** Render and write the current status lines based on state. */
 188|   function renderStatusLine(): void {
 189|     if (currentState === 'reconnecting' || currentState === 'failed') {
 190|       // These states are handled separately (updateReconnectingStatus /
 191|       // updateFailedStatus). Return before clearing so callers like toggleQr
 192|       // and setSpawnModeDisplay don't blank the display during these states.
 193|       return
 194|     }
 195| 
 196|     clearStatusLines()
 197| 
 198|     const isIdle = currentState === 'idle'
 199| 
 200|     // QR code above the status line
 201|     if (qrVisible) {
 202|       for (const line of qrLines) {
 203|         writeStatus(`${chalk.dim(line)}\n`)
 204|       }
 205|     }
 206| 
 207|     // Determine indicator and colors based on state
 208|     const indicator = BRIDGE_READY_INDICATOR
 209|     const indicatorColor = isIdle ? chalk.green : chalk.cyan
 210|     const baseColor = isIdle ? chalk.green : chalk.cyan
 211|     const stateText = baseColor(currentStateText)
 212| 
 213|     // Build the suffix with repo and branch
 214|     let suffix = ''
 215|     if (repoName) {
 216|       suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
 217|     }
 218|     // In worktree mode each session gets its own branch, so showing the
 219|     // bridge's branch would be misleading.
 220|     if (branch && spawnMode !== 'worktree') {
 221|       suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
 222|     }
 223| 
 224|     if (process.env.USER_TYPE === 'ant' && debugLogPath) {
 225|       writeStatus(
 226|         `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
 227|       )
 228|     }
 229|     writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
 230| 
 231|     // Session count and per-session list (multi-session mode only)
 232|     if (sessionMax > 1) {
 233|       const modeHint =
 234|         spawnMode === 'worktree'
 235|           ? 'New sessions will be created in an isolated worktree'
 236|           : 'New sessions will be created in the current directory'
 237|       writeStatus(
 238|         `    ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
 239|       )
 240|       for (const [, info] of sessionDisplayInfo) {
 241|         const titleText = info.title
 242|           ? truncatePrompt(info.title, 35)
 243|           : chalk.dim('Attached')
 244|         const titleLinked = wrapWithOsc8Link(titleText, info.url)
 245|         const act = info.activity
 246|         const showAct = act && act.type !== 'result' && act.type !== 'error'
 247|         const actText = showAct
 248|           ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
 249|           : ''
 250|         writeStatus(`    ${titleLinked}${actText}
 251| `)
 252|       }
 253|     }
 254| 
 255|     // Mode line for spawn modes with a single slot (or true single-session mode)
 256|     if (sessionMax === 1) {
 257|       const modeText =
 258|         spawnMode === 'single-session'
 259|           ? 'Single session \u00b7 exits when complete'
 260|           : spawnMode === 'worktree'
 261|             ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
 262|             : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
 263|       writeStatus(`    ${chalk.dim(modeText)}\n`)
 264|     }
 265| 
 266|     // Tool activity line for single-session mode
 267|     if (
 268|       sessionMax === 1 &&
 269|       !isIdle &&
 270|       lastToolSummary &&
 271|       Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
 272|     ) {
 273|       writeStatus(`  ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
 274|     }
 275| 
 276|     // Blank line separator before footer
 277|     const url = activeSessionUrl ?? connectUrl
 278|     if (url) {
 279|       writeStatus('\n')
 280|       const footerText = isIdle
 281|         ? buildIdleFooterText(url)
 282|         : buildActiveFooterText(url)
 283|       const qrHint = qrVisible
 284|         ? chalk.dim.italic('space to hide QR code')
 285|         : chalk.dim.italic('space to show QR code')
 286|       const toggleHint = spawnModeDisplay
 287|         ? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
 288|         : ''
 289|       writeStatus(`${chalk.dim(footerText)}\n`)
 290|       writeStatus(`${qrHint}${toggleHint}\n`)
 291|     }
 292|   }

源码引用: src/bridge/bridgeUI.ts · 第 294–299 行(共 531 行)

 294|   return {
 295|     printBanner(config: BridgeConfig, environmentId: string): void {
 296|       cachedIngressUrl = config.sessionIngressUrl
 297|       cachedEnvironmentId = environmentId
 298|       connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
 299|       regenerateQr(connectUrl)

createBridgeApiClient

bridgeApi.ts 是 Environments API 的 typed axios 封装:

BridgeFatalError:不可重试(auth、environment_expired 等),携带 status 与 errorType。

validateBridgeId:SAFE_ID_PATTERN 防 URL 路径注入。

getHeaders:Bearer + anthropic-version + beta environments-2025-11-01 + runner version;可选 X-Trusted-Device-Token。

withOAuthRetry:401 时调注入的 onAuth401 刷新一次;无 handler 则直接 fatal 路径。

主要方法:

方法HTTP
registerBridgeEnvironmentPOST /v1/environments/bridge
pollForWorkGET .../work/poll
acknowledgeWorkPOST .../work/{id}/ack
heartbeatWorkPOST heartbeat
reconnectSessionPOST bridge/reconnect
stopWorkWithRetry带退避的停止

consecutiveEmptyPolls 每 100 次空 poll 打 debug,避免日志洪水。

源码引用: src/bridge/bridgeApi.ts · 第 40–66 行(共 540 行)

  40| /** Allowlist pattern for server-provided IDs used in URL path segments. */
  41| const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
  42| 
  43| /**
  44|  * Validate that a server-provided ID is safe to interpolate into a URL path.
  45|  * Prevents path traversal (e.g. `../../admin`) and injection via IDs that
  46|  * contain slashes, dots, or other special characters.
  47|  */
  48| export function validateBridgeId(id: string, label: string): string {
  49|   if (!id || !SAFE_ID_PATTERN.test(id)) {
  50|     throw new Error(`Invalid ${label}: contains unsafe characters`)
  51|   }
  52|   return id
  53| }
  54| 
  55| /** Fatal bridge errors that should not be retried (e.g. auth failures). */
  56| export class BridgeFatalError extends Error {
  57|   readonly status: number
  58|   /** Server-provided error type, e.g. "environment_expired". */
  59|   readonly errorType: string | undefined
  60|   constructor(message: string, status: number, errorType?: string) {
  61|     super(message)
  62|     this.name = 'BridgeFatalError'
  63|     this.status = status
  64|     this.errorType = errorType
  65|   }
  66| }

源码引用: src/bridge/bridgeApi.ts · 第 68–139 行(共 540 行)

  68| export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
  69|   function debug(msg: string): void {
  70|     deps.onDebug?.(msg)
  71|   }
  72| 
  73|   let consecutiveEmptyPolls = 0
  74|   const EMPTY_POLL_LOG_INTERVAL = 100
  75| 
  76|   function getHeaders(accessToken: string): Record<string, string> {
  77|     const headers: Record<string, string> = {
  78|       Authorization: `Bearer ${accessToken}`,
  79|       'Content-Type': 'application/json',
  80|       'anthropic-version': '2023-06-01',
  81|       'anthropic-beta': BETA_HEADER,
  82|       'x-environment-runner-version': deps.runnerVersion,
  83|     }
  84|     const deviceToken = deps.getTrustedDeviceToken?.()
  85|     if (deviceToken) {
  86|       headers['X-Trusted-Device-Token'] = deviceToken
  87|     }
  88|     return headers
  89|   }
  90| 
  91|   function resolveAuth(): string {
  92|     const accessToken = deps.getAccessToken()
  93|     if (!accessToken) {
  94|       throw new Error(BRIDGE_LOGIN_INSTRUCTION)
  95|     }
  96|     return accessToken
  97|   }
  98| 
  99|   /**
 100|    * Execute an OAuth-authenticated request with a single retry on 401.
 101|    * On 401, attempts token refresh via handleOAuth401Error (same pattern as
 102|    * withRetry.ts for v1/messages). If refresh succeeds, retries the request
 103|    * once with the new token. If refresh fails or the retry also returns 401,
 104|    * the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
 105|    */
 106|   async function withOAuthRetry<T>(
 107|     fn: (accessToken: string) => Promise<{ status: number; data: T }>,
 108|     context: string,
 109|   ): Promise<{ status: number; data: T }> {
 110|     const accessToken = resolveAuth()
 111|     const response = await fn(accessToken)
 112| 
 113|     if (response.status !== 401) {
 114|       return response
 115|     }
 116| 
 117|     if (!deps.onAuth401) {
 118|       debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
 119|       return response
 120|     }
 121| 
 122|     // Attempt token refresh — matches the pattern in withRetry.ts
 123|     debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
 124|     const refreshed = await deps.onAuth401(accessToken)
 125|     if (refreshed) {
 126|       debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
 127|       const newToken = resolveAuth()
 128|       const retryResponse = await fn(newToken)
 129|       if (retryResponse.status !== 401) {
 130|         return retryResponse
 131|       }
 132|       debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
 133|     } else {
 134|       debug(`[bridge:api] ${context}: Token refresh failed`)
 135|     }
 136| 
 137|     // Refresh failed — return 401 for handleErrorStatus to throw
 138|     return response
 139|   }

源码引用: src/bridge/bridgeApi.ts · 第 141–197 行(共 540 行)

 141|   return {
 142|     async registerBridgeEnvironment(
 143|       config: BridgeConfig,
 144|     ): Promise<{ environment_id: string; environment_secret: string }> {
 145|       debug(
 146|         `[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
 147|       )
 148| 
 149|       const response = await withOAuthRetry(
 150|         (token: string) =>
 151|           axios.post<{
 152|             environment_id: string
 153|             environment_secret: string
 154|           }>(
 155|             `${deps.baseUrl}/v1/environments/bridge`,
 156|             {
 157|               machine_name: config.machineName,
 158|               directory: config.dir,
 159|               branch: config.branch,
 160|               git_repo_url: config.gitRepoUrl,
 161|               // Advertise session capacity so claude.ai/code can show
 162|               // "2/4 sessions" badges and only block the picker when
 163|               // actually at capacity. Backends that don't yet accept
 164|               // this field will silently ignore it.
 165|               max_sessions: config.maxSessions,
 166|               // worker_type lets claude.ai filter environments by origin
 167|               // (e.g. assistant picker only shows assistant-mode workers).
 168|               // Desktop cowork app sends "cowork"; we send a distinct value.
 169|               metadata: { worker_type: config.workerType },
 170|               // Idempotent re-registration: if we have a backend-issued
 171|               // environment_id from a prior session (--session-id resume),
 172|               // send it back so the backend reattaches instead of creating
 173|               // a new env. The backend may still hand back a fresh ID if
 174|               // the old one expired — callers must compare the response.
 175|               ...(config.reuseEnvironmentId && {
 176|                 environment_id: config.reuseEnvironmentId,
 177|               }),
 178|             },
 179|             {
 180|               headers: getHeaders(token),
 181|               timeout: 15_000,
 182|               validateStatus: status => status < 500,
 183|             },
 184|           ),
 185|         'Registration',
 186|       )
 187| 
 188|       handleErrorStatus(response.status, response.data, 'Registration')
 189|       debug(
 190|         `[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
 191|       )
 192|       debug(
 193|         `[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
 194|       )
 195|       debug(`[bridge:api] <<< ${debugBody(response.data)}`)
 196|       return response.data
 197|     },

源码引用: src/bridge/bridgeApi.ts · 第 199–247 行(共 540 行)

 199|     async pollForWork(
 200|       environmentId: string,
 201|       environmentSecret: string,
 202|       signal?: AbortSignal,
 203|       reclaimOlderThanMs?: number,
 204|     ): Promise<WorkResponse | null> {
 205|       validateBridgeId(environmentId, 'environmentId')
 206| 
 207|       // Save and reset so errors break the "consecutive empty" streak.
 208|       // Restored below when the response is truly empty.
 209|       const prevEmptyPolls = consecutiveEmptyPolls
 210|       consecutiveEmptyPolls = 0
 211| 
 212|       const response = await axios.get<WorkResponse | null>(
 213|         `${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
 214|         {
 215|           headers: getHeaders(environmentSecret),
 216|           params:
 217|             reclaimOlderThanMs !== undefined
 218|               ? { reclaim_older_than_ms: reclaimOlderThanMs }
 219|               : undefined,
 220|           timeout: 10_000,
 221|           signal,
 222|           validateStatus: status => status < 500,
 223|         },
 224|       )
 225| 
 226|       handleErrorStatus(response.status, response.data, 'Poll')
 227| 
 228|       // Empty body or null = no work available
 229|       if (!response.data) {
 230|         consecutiveEmptyPolls = prevEmptyPolls + 1
 231|         if (
 232|           consecutiveEmptyPolls === 1 ||
 233|           consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
 234|         ) {
 235|           debug(
 236|             `[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
 237|           )
 238|         }
 239|         return null
 240|       }
 241| 
 242|       debug(
 243|         `[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
 244|       )
 245|       debug(`[bridge:api] <<< ${debugBody(response.data)}`)
 246|       return response.data
 247|     },

可信设备 trustedDevice

Bridge 会话在服务端标记 SecurityTier=ELEVATED(CCR v2)。ConnectBridgeWorker 在服务端 flag 开启时要求可信设备参与 JWT 签发。

getTrustedDeviceToken:

  • Gate tengu_sessions_elevated_auth_enforcement off → undefined(不发 header)
  • on:读 secureStorage memoized(~40ms macOS security 子进程),或 env CLAUDE_TRUSTED_DEVICE_TOKEN 覆盖

enrollTrustedDevice:login 后 POST /auth/trusted_devices,须 account_session.created_at < 10min。clearTrustedDeviceToken 在 enroll 前清陈旧 token,避免 enroll 异步窗口仍发旧 header。

clearTrustedDeviceTokenCache 在 logout 链路调用。

与 bridgeApi 关系:getTrustedDeviceToken 注入 createBridgeApiClient deps,每次 poll/heartbeat 带 header;server flag off 时 header 被忽略。

源码引用: src/bridge/trustedDevice.ts · 第 15–59 行(共 211 行)

  15| /**
  16|  * Trusted device token source for bridge (remote-control) sessions.
  17|  *
  18|  * Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
  19|  * The server gates ConnectBridgeWorker on its own flag
  20|  * (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
  21|  * flag controls whether the CLI sends X-Trusted-Device-Token at all.
  22|  * Two flags so rollout can be staged: flip CLI-side first (headers
  23|  * start flowing, server still no-ops), then flip server-side.
  24|  *
  25|  * Enrollment (POST /auth/trusted_devices) is gated server-side by
  26|  * account_session.created_at < 10min, so it must happen during /login.
  27|  * Token is persistent (90d rolling expiry) and stored in keychain.
  28|  *
  29|  * See anthropics/anthropic#274559 (spec), #310375 (B1b tenant RPCs),
  30|  * #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
  31|  */
  32| 
  33| const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
  34| 
  35| function isGateEnabled(): boolean {
  36|   return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
  37| }
  38| 
  39| // Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
  40| // bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
  41| // Cache cleared after enrollment (below) and on logout (clearAuthRelatedCaches).
  42| //
  43| // Only the storage read is memoized — the GrowthBook gate is checked live so
  44| // that a gate flip after GrowthBook refresh takes effect without a restart.
  45| const readStoredToken = memoize((): string | undefined => {
  46|   // Env var takes precedence for testing/canary.
  47|   const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
  48|   if (envToken) {
  49|     return envToken
  50|   }
  51|   return getSecureStorage().read()?.trustedDeviceToken
  52| })
  53| 
  54| export function getTrustedDeviceToken(): string | undefined {
  55|   if (!isGateEnabled()) {
  56|     return undefined
  57|   }
  58|   return readStoredToken()
  59| }

源码引用: src/bridge/trustedDevice.ts · 第 89–117 行(共 211 行)

  89| /**
  90|  * Enroll this device via POST /auth/trusted_devices and persist the token
  91|  * to keychain. Best-effort — logs and returns on failure so callers
  92|  * (post-login hooks) don't block the login flow.
  93|  *
  94|  * The server gates enrollment on account_session.created_at < 10min, so
  95|  * this must be called immediately after a fresh /login. Calling it later
  96|  * (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
  97|  */
  98| export async function enrollTrustedDevice(): Promise<void> {
  99|   try {
 100|     // checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
 101|     // (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
 102|     // reading the gate, so we get the post-refresh value.
 103|     if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
 104|       logForDebugging(
 105|         `[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
 106|       )
 107|       return
 108|     }
 109|     // If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
 110|     // skip enrollment — the env var takes precedence in readStoredToken() so
 111|     // any enrolled token would be shadowed and never used.
 112|     if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
 113|       logForDebugging(
 114|         '[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
 115|       )
 116|       return
 117|     }

源码引用: src/bridge/bridgeApi.ts · 第 26–36 行(共 540 行)

  26|   /**
  27|    * Returns the trusted device token to send as X-Trusted-Device-Token on
  28|    * bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
  29|    * server (CCR v2); when the server's enforcement flag is on,
  30|    * ConnectBridgeWorker requires a trusted device at JWT-issuance.
  31|    * Optional — when absent or returning undefined, the header is omitted
  32|    * and the server falls through to its flag-off/no-op path. The CLI-side
  33|    * gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
  34|    */
  35|   getTrustedDeviceToken?: () => string | undefined
  36| }

bridgeUI 与 bridgeMain 协作

runBridgeLoop 构造 logger = createBridgeLogger({ verbose, write }),在关键事件调用:

  • printBanner:展示 environmentId、connect URL、启动 QR
  • updateConnectingStatus / updateIdleStatus / updateActiveStatus
  • updateSessionCount、setSessionDisplayInfo 多 session 模式
  • logVerbose / logError 永久行(先 clearStatusLines)

子 session 活动经 sessionRunner onActivity 回调更新 activity 字段;permission 请求经 onPermissionRequest 转发 API。

失败态 updateFailedStatus 显示 BRIDGE_FAILED_INDICATOR 与 FAILED_FOOTER_TEXT。

verbose 模式绕过部分状态行简化,适合 CI 日志采集。

源码引用: src/bridge/bridgeMain.ts · 第 31–38 行(共 3000 行)

  31| import { createBridgeLogger } from './bridgeUI.js'
  32| import { createCapacityWake } from './capacityWake.js'
  33| import { describeAxiosError } from './debugUtils.js'
  34| import { createTokenRefreshScheduler } from './jwtUtils.js'
  35| import { getPollIntervalConfig } from './pollConfig.js'
  36| import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
  37| import { createSessionSpawner, safeFilenameId } from './sessionRunner.js'
  38| import { getTrustedDeviceToken } from './trustedDevice.js'

安全与合规要点

主题机制
OAuth tokenwithOAuthRetry + initReplBridge proactive refresh
设备信任双 flag staged rollout;90d rolling keychain token
ID 安全validateBridgeId 于 poll/ack/heartbeat 路径
权限欺骗set_permission_mode 无 callback 时 error 而非 success
日志 PIIlogForDiagnosticsNoPII 用于 bridge 事件
Suppressible 403isSuppressible403 区分可恢复拒绝

企业 FedStart:getBridgeBaseUrl 白名单失败时附件与部分 API 降级,见 inboundAttachments 章。

调试 permission 卡住:查 server 是否收到 control_response;查 replBridge transport 是否 connected;查 cancelRequest 是否在用户 dismiss 时调用。

源码引用: src/bridge/bridgeApi.ts · 第 12–36 行(共 540 行)

  12| type BridgeApiDeps = {
  13|   baseUrl: string
  14|   getAccessToken: () => string | undefined
  15|   runnerVersion: string
  16|   onDebug?: (msg: string) => void
  17|   /**
  18|    * Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
  19|    * in which case the request is retried once. Injected because
  20|    * handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
  21|    * file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
  22|    * (~1300 modules). Daemon callers using env-var tokens omit this — their
  23|    * tokens don't refresh, so 401 goes straight to BridgeFatalError.
  24|    */
  25|   onAuth401?: (staleAccessToken: string) => Promise<boolean>
  26|   /**
  27|    * Returns the trusted device token to send as X-Trusted-Device-Token on
  28|    * bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
  29|    * server (CCR v2); when the server's enforcement flag is on,
  30|    * ConnectBridgeWorker requires a trusted device at JWT-issuance.
  31|    * Optional — when absent or returning undefined, the header is omitted
  32|    * and the server falls through to its flag-off/no-op path. The CLI-side
  33|    * gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
  34|    */
  35|   getTrustedDeviceToken?: () => string | undefined
  36| }

本章小结与延伸

bridge-permissions-ui = 远程控制的信任链与终端 UX。回到 repl-bridge 查句柄如何 sendControlRequest。 继续学习:

  • repl-bridge
  • bridge-messaging
Prev
remote-bridge-core · env-less 核心与守护主循环
Next
Structured IO · NDJSON SDK 协议