source dump of claude code
at main 539 lines 18 kB view raw
1import axios from 'axios' 2 3import { debugBody, extractErrorDetail } from './debugUtils.js' 4import { 5 BRIDGE_LOGIN_INSTRUCTION, 6 type BridgeApiClient, 7 type BridgeConfig, 8 type PermissionResponseEvent, 9 type WorkResponse, 10} from './types.js' 11 12type 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} 37 38const BETA_HEADER = 'environments-2025-11-01' 39 40/** Allowlist pattern for server-provided IDs used in URL path segments. */ 41const 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 */ 48export 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). */ 56export 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} 67 68export 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 } 140 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 }, 198 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 }, 248 249 async acknowledgeWork( 250 environmentId: string, 251 workId: string, 252 sessionToken: string, 253 ): Promise<void> { 254 validateBridgeId(environmentId, 'environmentId') 255 validateBridgeId(workId, 'workId') 256 257 debug(`[bridge:api] POST .../work/${workId}/ack`) 258 259 const response = await axios.post( 260 `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`, 261 {}, 262 { 263 headers: getHeaders(sessionToken), 264 timeout: 10_000, 265 validateStatus: s => s < 500, 266 }, 267 ) 268 269 handleErrorStatus(response.status, response.data, 'Acknowledge') 270 debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`) 271 }, 272 273 async stopWork( 274 environmentId: string, 275 workId: string, 276 force: boolean, 277 ): Promise<void> { 278 validateBridgeId(environmentId, 'environmentId') 279 validateBridgeId(workId, 'workId') 280 281 debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`) 282 283 const response = await withOAuthRetry( 284 (token: string) => 285 axios.post( 286 `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`, 287 { force }, 288 { 289 headers: getHeaders(token), 290 timeout: 10_000, 291 validateStatus: s => s < 500, 292 }, 293 ), 294 'StopWork', 295 ) 296 297 handleErrorStatus(response.status, response.data, 'StopWork') 298 debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`) 299 }, 300 301 async deregisterEnvironment(environmentId: string): Promise<void> { 302 validateBridgeId(environmentId, 'environmentId') 303 304 debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`) 305 306 const response = await withOAuthRetry( 307 (token: string) => 308 axios.delete( 309 `${deps.baseUrl}/v1/environments/bridge/${environmentId}`, 310 { 311 headers: getHeaders(token), 312 timeout: 10_000, 313 validateStatus: s => s < 500, 314 }, 315 ), 316 'Deregister', 317 ) 318 319 handleErrorStatus(response.status, response.data, 'Deregister') 320 debug( 321 `[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`, 322 ) 323 }, 324 325 async archiveSession(sessionId: string): Promise<void> { 326 validateBridgeId(sessionId, 'sessionId') 327 328 debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`) 329 330 const response = await withOAuthRetry( 331 (token: string) => 332 axios.post( 333 `${deps.baseUrl}/v1/sessions/${sessionId}/archive`, 334 {}, 335 { 336 headers: getHeaders(token), 337 timeout: 10_000, 338 validateStatus: s => s < 500, 339 }, 340 ), 341 'ArchiveSession', 342 ) 343 344 // 409 = already archived (idempotent, not an error) 345 if (response.status === 409) { 346 debug( 347 `[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`, 348 ) 349 return 350 } 351 352 handleErrorStatus(response.status, response.data, 'ArchiveSession') 353 debug( 354 `[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`, 355 ) 356 }, 357 358 async reconnectSession( 359 environmentId: string, 360 sessionId: string, 361 ): Promise<void> { 362 validateBridgeId(environmentId, 'environmentId') 363 validateBridgeId(sessionId, 'sessionId') 364 365 debug( 366 `[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`, 367 ) 368 369 const response = await withOAuthRetry( 370 (token: string) => 371 axios.post( 372 `${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`, 373 { session_id: sessionId }, 374 { 375 headers: getHeaders(token), 376 timeout: 10_000, 377 validateStatus: s => s < 500, 378 }, 379 ), 380 'ReconnectSession', 381 ) 382 383 handleErrorStatus(response.status, response.data, 'ReconnectSession') 384 debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`) 385 }, 386 387 async heartbeatWork( 388 environmentId: string, 389 workId: string, 390 sessionToken: string, 391 ): Promise<{ lease_extended: boolean; state: string }> { 392 validateBridgeId(environmentId, 'environmentId') 393 validateBridgeId(workId, 'workId') 394 395 debug(`[bridge:api] POST .../work/${workId}/heartbeat`) 396 397 const response = await axios.post<{ 398 lease_extended: boolean 399 state: string 400 last_heartbeat: string 401 ttl_seconds: number 402 }>( 403 `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`, 404 {}, 405 { 406 headers: getHeaders(sessionToken), 407 timeout: 10_000, 408 validateStatus: s => s < 500, 409 }, 410 ) 411 412 handleErrorStatus(response.status, response.data, 'Heartbeat') 413 debug( 414 `[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`, 415 ) 416 return response.data 417 }, 418 419 async sendPermissionResponseEvent( 420 sessionId: string, 421 event: PermissionResponseEvent, 422 sessionToken: string, 423 ): Promise<void> { 424 validateBridgeId(sessionId, 'sessionId') 425 426 debug( 427 `[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`, 428 ) 429 430 const response = await axios.post( 431 `${deps.baseUrl}/v1/sessions/${sessionId}/events`, 432 { events: [event] }, 433 { 434 headers: getHeaders(sessionToken), 435 timeout: 10_000, 436 validateStatus: s => s < 500, 437 }, 438 ) 439 440 handleErrorStatus( 441 response.status, 442 response.data, 443 'SendPermissionResponseEvent', 444 ) 445 debug( 446 `[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`, 447 ) 448 debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`) 449 debug(`[bridge:api] <<< ${debugBody(response.data)}`) 450 }, 451 } 452} 453 454function handleErrorStatus( 455 status: number, 456 data: unknown, 457 context: string, 458): void { 459 if (status === 200 || status === 204) { 460 return 461 } 462 const detail = extractErrorDetail(data) 463 const errorType = extractErrorTypeFromData(data) 464 switch (status) { 465 case 401: 466 throw new BridgeFatalError( 467 `${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`, 468 401, 469 errorType, 470 ) 471 case 403: 472 throw new BridgeFatalError( 473 isExpiredErrorType(errorType) 474 ? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.' 475 : `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`, 476 403, 477 errorType, 478 ) 479 case 404: 480 throw new BridgeFatalError( 481 detail ?? 482 `${context}: Not found (404). Remote Control may not be available for this organization.`, 483 404, 484 errorType, 485 ) 486 case 410: 487 throw new BridgeFatalError( 488 detail ?? 489 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.', 490 410, 491 errorType ?? 'environment_expired', 492 ) 493 case 429: 494 throw new Error(`${context}: Rate limited (429). Polling too frequently.`) 495 default: 496 throw new Error( 497 `${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`, 498 ) 499 } 500} 501 502/** Check whether an error type string indicates a session/environment expiry. */ 503export function isExpiredErrorType(errorType: string | undefined): boolean { 504 if (!errorType) { 505 return false 506 } 507 return errorType.includes('expired') || errorType.includes('lifetime') 508} 509 510/** 511 * Check whether a BridgeFatalError is a suppressible 403 permission error. 512 * These are 403 errors for scopes like 'external_poll_sessions' or operations 513 * like StopWork that fail because the user's role lacks 'environments:manage'. 514 * They don't affect core functionality and shouldn't be shown to users. 515 */ 516export function isSuppressible403(err: BridgeFatalError): boolean { 517 if (err.status !== 403) { 518 return false 519 } 520 return ( 521 err.message.includes('external_poll_sessions') || 522 err.message.includes('environments:manage') 523 ) 524} 525 526function extractErrorTypeFromData(data: unknown): string | undefined { 527 if (data && typeof data === 'object') { 528 if ( 529 'error' in data && 530 data.error && 531 typeof data.error === 'object' && 532 'type' in data.error && 533 typeof data.error.type === 'string' 534 ) { 535 return data.error.type 536 } 537 } 538 return undefined 539}