本章总览
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。
建议学习步骤
- 阅读源码块 A:BridgePermissionCallbacks 类型
- 阅读源码块 B:createBridgeLogger 状态渲染
- 阅读源码块 C:createBridgeApiClient 注册与 poll
- 阅读源码块 D:withOAuthRetry 与 getHeaders
- 阅读源码块 E:getTrustedDeviceToken
- 阅读源码块 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 四个方法:
| 方法 | 方向 |
|---|---|
| sendRequest | CLI → server,携带 toolName、input、toolUseId、description、suggestions、blockedPath |
| sendResponse | CLI → 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 |
|---|---|
| registerBridgeEnvironment | POST /v1/environments/bridge |
| pollForWork | GET .../work/poll |
| acknowledgeWork | POST .../work/{id}/ack |
| heartbeatWork | POST heartbeat |
| reconnectSession | POST 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 token | withOAuthRetry + initReplBridge proactive refresh |
| 设备信任 | 双 flag staged rollout;90d rolling keychain token |
| ID 安全 | validateBridgeId 于 poll/ack/heartbeat 路径 |
| 权限欺骗 | set_permission_mode 无 callback 时 error 而非 success |
| 日志 PII | logForDiagnosticsNoPII 用于 bridge 事件 |
| Suppressible 403 | isSuppressible403 区分可恢复拒绝 |
企业 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。 继续学习: