source dump of claude code
at main 466 lines 13 kB view raw
1import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios' 2import { randomUUID } from 'crypto' 3import { getOauthConfig } from 'src/constants/oauth.js' 4import { getOrganizationUUID } from 'src/services/oauth/client.js' 5import z from 'zod/v4' 6import { getClaudeAIOAuthTokens } from '../auth.js' 7import { logForDebugging } from '../debug.js' 8import { parseGitHubRepository } from '../detectRepository.js' 9import { errorMessage, toError } from '../errors.js' 10import { lazySchema } from '../lazySchema.js' 11import { logError } from '../log.js' 12import { sleep } from '../sleep.js' 13import { jsonStringify } from '../slowOperations.js' 14 15// Retry configuration for teleport API requests 16const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] // 4 retries with exponential backoff 17const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length 18 19export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29' 20 21/** 22 * Checks if an axios error is a transient network error that should be retried 23 */ 24export function isTransientNetworkError(error: unknown): boolean { 25 if (!axios.isAxiosError(error)) { 26 return false 27 } 28 29 // Retry on network errors (no response received) 30 if (!error.response) { 31 return true 32 } 33 34 // Retry on server errors (5xx) 35 if (error.response.status >= 500) { 36 return true 37 } 38 39 // Don't retry on client errors (4xx) - they're not transient 40 return false 41} 42 43/** 44 * Makes an axios GET request with automatic retry for transient network errors 45 * Uses exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts) 46 */ 47export async function axiosGetWithRetry<T>( 48 url: string, 49 config?: AxiosRequestConfig, 50): Promise<AxiosResponse<T>> { 51 let lastError: unknown 52 53 for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) { 54 try { 55 return await axios.get<T>(url, config) 56 } catch (error) { 57 lastError = error 58 59 // Don't retry if this isn't a transient error 60 if (!isTransientNetworkError(error)) { 61 throw error 62 } 63 64 // Don't retry if we've exhausted all retries 65 if (attempt >= MAX_TELEPORT_RETRIES) { 66 logForDebugging( 67 `Teleport request failed after ${attempt + 1} attempts: ${errorMessage(error)}`, 68 ) 69 throw error 70 } 71 72 const delay = TELEPORT_RETRY_DELAYS[attempt] ?? 2000 73 logForDebugging( 74 `Teleport request failed (attempt ${attempt + 1}/${MAX_TELEPORT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage(error)}`, 75 ) 76 await sleep(delay) 77 } 78 } 79 80 throw lastError 81} 82 83// Types matching the actual Sessions API response from api/schemas/sessions/sessions.py 84export type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived' 85 86export type GitSource = { 87 type: 'git_repository' 88 url: string 89 revision?: string | null 90 allow_unrestricted_git_push?: boolean 91} 92 93export type KnowledgeBaseSource = { 94 type: 'knowledge_base' 95 knowledge_base_id: string 96} 97 98export type SessionContextSource = GitSource | KnowledgeBaseSource 99 100// Outcome types from api/schemas/sandbox.py 101export type OutcomeGitInfo = { 102 type: 'github' 103 repo: string 104 branches: string[] 105} 106 107export type GitRepositoryOutcome = { 108 type: 'git_repository' 109 git_info: OutcomeGitInfo 110} 111 112export type Outcome = GitRepositoryOutcome 113 114export type SessionContext = { 115 sources: SessionContextSource[] 116 cwd: string 117 outcomes: Outcome[] | null 118 custom_system_prompt: string | null 119 append_system_prompt: string | null 120 model: string | null 121 // Seed filesystem with a git bundle on Files API 122 seed_bundle_file_id?: string 123 github_pr?: { owner: string; repo: string; number: number } 124 reuse_outcome_branches?: boolean 125} 126 127export type SessionResource = { 128 type: 'session' 129 id: string 130 title: string | null 131 session_status: SessionStatus 132 environment_id: string 133 created_at: string 134 updated_at: string 135 session_context: SessionContext 136} 137 138export type ListSessionsResponse = { 139 data: SessionResource[] 140 has_more: boolean 141 first_id: string | null 142 last_id: string | null 143} 144 145export const CodeSessionSchema = lazySchema(() => 146 z.object({ 147 id: z.string(), 148 title: z.string(), 149 description: z.string(), 150 status: z.enum([ 151 'idle', 152 'working', 153 'waiting', 154 'completed', 155 'archived', 156 'cancelled', 157 'rejected', 158 ]), 159 repo: z 160 .object({ 161 name: z.string(), 162 owner: z.object({ 163 login: z.string(), 164 }), 165 default_branch: z.string().optional(), 166 }) 167 .nullable(), 168 turns: z.array(z.string()), 169 created_at: z.string(), 170 updated_at: z.string(), 171 }), 172) 173 174// Export the inferred type from the Zod schema 175export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>> 176 177/** 178 * Validates and prepares for API requests 179 * @returns Object containing access token and organization UUID 180 */ 181export async function prepareApiRequest(): Promise<{ 182 accessToken: string 183 orgUUID: string 184}> { 185 const accessToken = getClaudeAIOAuthTokens()?.accessToken 186 if (accessToken === undefined) { 187 throw new Error( 188 'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.', 189 ) 190 } 191 192 const orgUUID = await getOrganizationUUID() 193 if (!orgUUID) { 194 throw new Error('Unable to get organization UUID') 195 } 196 197 return { accessToken, orgUUID } 198} 199 200/** 201 * Fetches code sessions from the new Sessions API (/v1/sessions) 202 * @returns Array of code sessions 203 */ 204export async function fetchCodeSessionsFromSessionsAPI(): Promise< 205 CodeSession[] 206> { 207 const { accessToken, orgUUID } = await prepareApiRequest() 208 209 const url = `${getOauthConfig().BASE_API_URL}/v1/sessions` 210 211 try { 212 const headers = { 213 ...getOAuthHeaders(accessToken), 214 'anthropic-beta': 'ccr-byoc-2025-07-29', 215 'x-organization-uuid': orgUUID, 216 } 217 218 const response = await axiosGetWithRetry<ListSessionsResponse>(url, { 219 headers, 220 }) 221 222 if (response.status !== 200) { 223 throw new Error(`Failed to fetch code sessions: ${response.statusText}`) 224 } 225 226 // Transform SessionResource[] to CodeSession[] format 227 const sessions: CodeSession[] = response.data.data.map(session => { 228 // Extract repository info from git sources 229 const gitSource = session.session_context.sources.find( 230 (source): source is GitSource => source.type === 'git_repository', 231 ) 232 233 let repo: CodeSession['repo'] = null 234 if (gitSource?.url) { 235 // Parse GitHub URL using the existing utility function 236 const repoPath = parseGitHubRepository(gitSource.url) 237 if (repoPath) { 238 const [owner, name] = repoPath.split('/') 239 if (owner && name) { 240 repo = { 241 name, 242 owner: { 243 login: owner, 244 }, 245 default_branch: gitSource.revision || undefined, 246 } 247 } 248 } 249 } 250 251 return { 252 id: session.id, 253 title: session.title || 'Untitled', 254 description: '', // SessionResource doesn't have description field 255 status: session.session_status as CodeSession['status'], // Map session_status to status 256 repo, 257 turns: [], // SessionResource doesn't have turns field 258 created_at: session.created_at, 259 updated_at: session.updated_at, 260 } 261 }) 262 263 return sessions 264 } catch (error) { 265 const err = toError(error) 266 logError(err) 267 throw error 268 } 269} 270 271/** 272 * Creates OAuth headers for API requests 273 * @param accessToken The OAuth access token 274 * @returns Headers object with Authorization, Content-Type, and anthropic-version 275 */ 276export function getOAuthHeaders(accessToken: string): Record<string, string> { 277 return { 278 Authorization: `Bearer ${accessToken}`, 279 'Content-Type': 'application/json', 280 'anthropic-version': '2023-06-01', 281 } 282} 283 284/** 285 * Fetches a single session by ID from the Sessions API 286 * @param sessionId The session ID to fetch 287 * @returns The session resource 288 */ 289export async function fetchSession( 290 sessionId: string, 291): Promise<SessionResource> { 292 const { accessToken, orgUUID } = await prepareApiRequest() 293 294 const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` 295 const headers = { 296 ...getOAuthHeaders(accessToken), 297 'anthropic-beta': 'ccr-byoc-2025-07-29', 298 'x-organization-uuid': orgUUID, 299 } 300 301 const response = await axios.get<SessionResource>(url, { 302 headers, 303 timeout: 15000, 304 validateStatus: status => status < 500, 305 }) 306 307 if (response.status !== 200) { 308 // Extract error message from response if available 309 const errorData = response.data as { error?: { message?: string } } 310 const apiMessage = errorData?.error?.message 311 312 if (response.status === 404) { 313 throw new Error(`Session not found: ${sessionId}`) 314 } 315 316 if (response.status === 401) { 317 throw new Error('Session expired. Please run /login to sign in again.') 318 } 319 320 throw new Error( 321 apiMessage || 322 `Failed to fetch session: ${response.status} ${response.statusText}`, 323 ) 324 } 325 326 return response.data 327} 328 329/** 330 * Extracts the first branch name from a session's git repository outcomes 331 * @param session The session resource to extract from 332 * @returns The first branch name, or undefined if none found 333 */ 334export function getBranchFromSession( 335 session: SessionResource, 336): string | undefined { 337 const gitOutcome = session.session_context.outcomes?.find( 338 (outcome): outcome is GitRepositoryOutcome => 339 outcome.type === 'git_repository', 340 ) 341 return gitOutcome?.git_info?.branches[0] 342} 343 344/** 345 * Content for a remote session message. 346 * Accepts a plain string or an array of content blocks (text, image, etc.) 347 * following the Anthropic API messages spec. 348 */ 349export type RemoteMessageContent = 350 | string 351 | Array<{ type: string; [key: string]: unknown }> 352 353/** 354 * Sends a user message event to an existing remote session via the Sessions API 355 * @param sessionId The session ID to send the event to 356 * @param messageContent The user message content (string or content blocks) 357 * @param opts.uuid Optional UUID for the event — callers that added a local 358 * UserMessage first should pass its UUID so echo filtering can dedup 359 * @returns Promise<boolean> True if successful, false otherwise 360 */ 361export async function sendEventToRemoteSession( 362 sessionId: string, 363 messageContent: RemoteMessageContent, 364 opts?: { uuid?: string }, 365): Promise<boolean> { 366 try { 367 const { accessToken, orgUUID } = await prepareApiRequest() 368 369 const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events` 370 const headers = { 371 ...getOAuthHeaders(accessToken), 372 'anthropic-beta': 'ccr-byoc-2025-07-29', 373 'x-organization-uuid': orgUUID, 374 } 375 376 const userEvent = { 377 uuid: opts?.uuid ?? randomUUID(), 378 session_id: sessionId, 379 type: 'user', 380 parent_tool_use_id: null, 381 message: { 382 role: 'user', 383 content: messageContent, 384 }, 385 } 386 387 const requestBody = { 388 events: [userEvent], 389 } 390 391 logForDebugging( 392 `[sendEventToRemoteSession] Sending event to session ${sessionId}`, 393 ) 394 // The endpoint may block until the CCR worker is ready. Observed ~2.6s 395 // in normal cases; allow a generous margin for cold-start containers. 396 const response = await axios.post(url, requestBody, { 397 headers, 398 validateStatus: status => status < 500, 399 timeout: 30000, 400 }) 401 402 if (response.status === 200 || response.status === 201) { 403 logForDebugging( 404 `[sendEventToRemoteSession] Successfully sent event to session ${sessionId}`, 405 ) 406 return true 407 } 408 409 logForDebugging( 410 `[sendEventToRemoteSession] Failed with status ${response.status}: ${jsonStringify(response.data)}`, 411 ) 412 return false 413 } catch (error) { 414 logForDebugging(`[sendEventToRemoteSession] Error: ${errorMessage(error)}`) 415 return false 416 } 417} 418 419/** 420 * Updates the title of an existing remote session via the Sessions API 421 * @param sessionId The session ID to update 422 * @param title The new title for the session 423 * @returns Promise<boolean> True if successful, false otherwise 424 */ 425export async function updateSessionTitle( 426 sessionId: string, 427 title: string, 428): Promise<boolean> { 429 try { 430 const { accessToken, orgUUID } = await prepareApiRequest() 431 432 const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` 433 const headers = { 434 ...getOAuthHeaders(accessToken), 435 'anthropic-beta': 'ccr-byoc-2025-07-29', 436 'x-organization-uuid': orgUUID, 437 } 438 439 logForDebugging( 440 `[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`, 441 ) 442 const response = await axios.patch( 443 url, 444 { title }, 445 { 446 headers, 447 validateStatus: status => status < 500, 448 }, 449 ) 450 451 if (response.status === 200) { 452 logForDebugging( 453 `[updateSessionTitle] Successfully updated title for session ${sessionId}`, 454 ) 455 return true 456 } 457 458 logForDebugging( 459 `[updateSessionTitle] Failed with status ${response.status}: ${jsonStringify(response.data)}`, 460 ) 461 return false 462 } catch (error) { 463 logForDebugging(`[updateSessionTitle] Error: ${errorMessage(error)}`) 464 return false 465 } 466}