source dump of claude code
at main 523 lines 15 kB view raw
1/** 2 * Download functionality for native installer 3 * 4 * Handles downloading Claude binaries from various sources: 5 * - Artifactory NPM packages 6 * - GCS bucket 7 */ 8 9import { feature } from 'bun:bundle' 10import axios from 'axios' 11import { createHash } from 'crypto' 12import { chmod, writeFile } from 'fs/promises' 13import { join } from 'path' 14import { logEvent } from 'src/services/analytics/index.js' 15import type { ReleaseChannel } from '../config.js' 16import { logForDebugging } from '../debug.js' 17import { toError } from '../errors.js' 18import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 19import { getFsImplementation } from '../fsOperations.js' 20import { logError } from '../log.js' 21import { sleep } from '../sleep.js' 22import { jsonStringify, writeFileSync_DEPRECATED } from '../slowOperations.js' 23import { getBinaryName, getPlatform } from './installer.js' 24 25const GCS_BUCKET_URL = 26 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases' 27export const ARTIFACTORY_REGISTRY_URL = 28 'https://artifactory.infra.ant.dev/artifactory/api/npm/npm-all/' 29 30export async function getLatestVersionFromArtifactory( 31 tag: string = 'latest', 32): Promise<string> { 33 const startTime = Date.now() 34 const { stdout, code, stderr } = await execFileNoThrowWithCwd( 35 'npm', 36 [ 37 'view', 38 `${MACRO.NATIVE_PACKAGE_URL}@${tag}`, 39 'version', 40 '--prefer-online', 41 '--registry', 42 ARTIFACTORY_REGISTRY_URL, 43 ], 44 { 45 timeout: 30000, 46 preserveOutputOnError: true, 47 }, 48 ) 49 50 const latencyMs = Date.now() - startTime 51 52 if (code !== 0) { 53 logEvent('tengu_version_check_failure', { 54 latency_ms: latencyMs, 55 source_npm: true, 56 exit_code: code, 57 }) 58 const error = new Error(`npm view failed with code ${code}: ${stderr}`) 59 logError(error) 60 throw error 61 } 62 63 logEvent('tengu_version_check_success', { 64 latency_ms: latencyMs, 65 source_npm: true, 66 }) 67 logForDebugging( 68 `npm view ${MACRO.NATIVE_PACKAGE_URL}@${tag} version: ${stdout}`, 69 ) 70 const latestVersion = stdout.trim() 71 return latestVersion 72} 73 74export async function getLatestVersionFromBinaryRepo( 75 channel: ReleaseChannel = 'latest', 76 baseUrl: string, 77 authConfig?: { auth: { username: string; password: string } }, 78): Promise<string> { 79 const startTime = Date.now() 80 try { 81 const response = await axios.get(`${baseUrl}/${channel}`, { 82 timeout: 30000, 83 responseType: 'text', 84 ...authConfig, 85 }) 86 const latencyMs = Date.now() - startTime 87 logEvent('tengu_version_check_success', { 88 latency_ms: latencyMs, 89 }) 90 return response.data.trim() 91 } catch (error) { 92 const latencyMs = Date.now() - startTime 93 const errorMessage = error instanceof Error ? error.message : String(error) 94 let httpStatus: number | undefined 95 if (axios.isAxiosError(error) && error.response) { 96 httpStatus = error.response.status 97 } 98 99 logEvent('tengu_version_check_failure', { 100 latency_ms: latencyMs, 101 http_status: httpStatus, 102 is_timeout: errorMessage.includes('timeout'), 103 }) 104 const fetchError = new Error( 105 `Failed to fetch version from ${baseUrl}/${channel}: ${errorMessage}`, 106 ) 107 logError(fetchError) 108 throw fetchError 109 } 110} 111 112export async function getLatestVersion( 113 channelOrVersion: string, 114): Promise<string> { 115 // Direct version - match internal format too (e.g. 1.0.30-dev.shaf4937ce) 116 if (/^v?\d+\.\d+\.\d+(-\S+)?$/.test(channelOrVersion)) { 117 const normalized = channelOrVersion.startsWith('v') 118 ? channelOrVersion.slice(1) 119 : channelOrVersion 120 // 99.99.x is reserved for CI smoke-test fixtures on real GCS. 121 // feature() is false in all shipped builds — DCE collapses this to an 122 // unconditional throw. Only `bun --feature=ALLOW_TEST_VERSIONS` (the 123 // smoke test's source-level invocation) bypasses. 124 if (/^99\.99\./.test(normalized) && !feature('ALLOW_TEST_VERSIONS')) { 125 throw new Error( 126 `Version ${normalized} is not available for installation. Use 'stable' or 'latest'.`, 127 ) 128 } 129 return normalized 130 } 131 132 // ReleaseChannel validation 133 const channel = channelOrVersion as ReleaseChannel 134 if (channel !== 'stable' && channel !== 'latest') { 135 throw new Error( 136 `Invalid channel: ${channelOrVersion}. Use 'stable' or 'latest'`, 137 ) 138 } 139 140 // Route to appropriate source 141 if (process.env.USER_TYPE === 'ant') { 142 // Use Artifactory for ant users 143 const npmTag = channel === 'stable' ? 'stable' : 'latest' 144 return getLatestVersionFromArtifactory(npmTag) 145 } 146 147 // Use GCS for external users 148 return getLatestVersionFromBinaryRepo(channel, GCS_BUCKET_URL) 149} 150 151export async function downloadVersionFromArtifactory( 152 version: string, 153 stagingPath: string, 154) { 155 const fs = getFsImplementation() 156 157 // If we get here, we own the lock and can delete a partial download 158 await fs.rm(stagingPath, { recursive: true, force: true }) 159 160 // Get the platform-specific package name 161 const platform = getPlatform() 162 const platformPackageName = `${MACRO.NATIVE_PACKAGE_URL}-${platform}` 163 164 // Fetch integrity hash for the platform-specific package 165 logForDebugging( 166 `Fetching integrity hash for ${platformPackageName}@${version}`, 167 ) 168 const { 169 stdout: integrityOutput, 170 code, 171 stderr, 172 } = await execFileNoThrowWithCwd( 173 'npm', 174 [ 175 'view', 176 `${platformPackageName}@${version}`, 177 'dist.integrity', 178 '--registry', 179 ARTIFACTORY_REGISTRY_URL, 180 ], 181 { 182 timeout: 30000, 183 preserveOutputOnError: true, 184 }, 185 ) 186 187 if (code !== 0) { 188 throw new Error(`npm view integrity failed with code ${code}: ${stderr}`) 189 } 190 191 const integrity = integrityOutput.trim() 192 if (!integrity) { 193 throw new Error( 194 `Failed to fetch integrity hash for ${platformPackageName}@${version}`, 195 ) 196 } 197 198 logForDebugging(`Got integrity hash for ${platform}: ${integrity}`) 199 200 // Create isolated npm project in staging 201 await fs.mkdir(stagingPath) 202 203 const packageJson = { 204 name: 'claude-native-installer', 205 version: '0.0.1', 206 dependencies: { 207 [MACRO.NATIVE_PACKAGE_URL!]: version, 208 }, 209 } 210 211 // Create package-lock.json with integrity verification for platform-specific package 212 const packageLock = { 213 name: 'claude-native-installer', 214 version: '0.0.1', 215 lockfileVersion: 3, 216 requires: true, 217 packages: { 218 '': { 219 name: 'claude-native-installer', 220 version: '0.0.1', 221 dependencies: { 222 [MACRO.NATIVE_PACKAGE_URL!]: version, 223 }, 224 }, 225 [`node_modules/${MACRO.NATIVE_PACKAGE_URL}`]: { 226 version: version, 227 optionalDependencies: { 228 [platformPackageName]: version, 229 }, 230 }, 231 [`node_modules/${platformPackageName}`]: { 232 version: version, 233 integrity: integrity, 234 }, 235 }, 236 } 237 238 writeFileSync_DEPRECATED( 239 join(stagingPath, 'package.json'), 240 jsonStringify(packageJson, null, 2), 241 { encoding: 'utf8', flush: true }, 242 ) 243 244 writeFileSync_DEPRECATED( 245 join(stagingPath, 'package-lock.json'), 246 jsonStringify(packageLock, null, 2), 247 { encoding: 'utf8', flush: true }, 248 ) 249 250 // Install with npm - it will verify integrity from package-lock.json 251 // Use --prefer-online to force fresh metadata checks, helping with Artifactory replication delays 252 const result = await execFileNoThrowWithCwd( 253 'npm', 254 ['ci', '--prefer-online', '--registry', ARTIFACTORY_REGISTRY_URL], 255 { 256 timeout: 60000, 257 preserveOutputOnError: true, 258 cwd: stagingPath, 259 }, 260 ) 261 262 if (result.code !== 0) { 263 throw new Error(`npm ci failed with code ${result.code}: ${result.stderr}`) 264 } 265 266 logForDebugging( 267 `Successfully downloaded and verified ${MACRO.NATIVE_PACKAGE_URL}@${version}`, 268 ) 269} 270 271// Stall timeout: abort if no bytes received for this duration 272const DEFAULT_STALL_TIMEOUT_MS = 60000 // 60 seconds 273const MAX_DOWNLOAD_RETRIES = 3 274 275function getStallTimeoutMs(): number { 276 return ( 277 Number(process.env.CLAUDE_CODE_STALL_TIMEOUT_MS_FOR_TESTING) || 278 DEFAULT_STALL_TIMEOUT_MS 279 ) 280} 281 282class StallTimeoutError extends Error { 283 constructor() { 284 super('Download stalled: no data received for 60 seconds') 285 this.name = 'StallTimeoutError' 286 } 287} 288 289/** 290 * Common logic for downloading and verifying a binary. 291 * Includes stall detection (aborts if no bytes for 60s) and retry logic. 292 */ 293async function downloadAndVerifyBinary( 294 binaryUrl: string, 295 expectedChecksum: string, 296 binaryPath: string, 297 requestConfig: Record<string, unknown> = {}, 298) { 299 let lastError: Error | undefined 300 301 for (let attempt = 1; attempt <= MAX_DOWNLOAD_RETRIES; attempt++) { 302 const controller = new AbortController() 303 let stallTimer: ReturnType<typeof setTimeout> | undefined 304 305 const clearStallTimer = () => { 306 if (stallTimer) { 307 clearTimeout(stallTimer) 308 stallTimer = undefined 309 } 310 } 311 312 const resetStallTimer = () => { 313 clearStallTimer() 314 stallTimer = setTimeout(c => c.abort(), getStallTimeoutMs(), controller) 315 } 316 317 try { 318 // Start the stall timer before the request 319 resetStallTimer() 320 321 const response = await axios.get(binaryUrl, { 322 timeout: 5 * 60000, // 5 minute total timeout 323 responseType: 'arraybuffer', 324 signal: controller.signal, 325 onDownloadProgress: () => { 326 // Reset stall timer on each chunk of data received 327 resetStallTimer() 328 }, 329 ...requestConfig, 330 }) 331 332 clearStallTimer() 333 334 // Verify checksum 335 const hash = createHash('sha256') 336 hash.update(response.data) 337 const actualChecksum = hash.digest('hex') 338 339 if (actualChecksum !== expectedChecksum) { 340 throw new Error( 341 `Checksum mismatch: expected ${expectedChecksum}, got ${actualChecksum}`, 342 ) 343 } 344 345 // Write binary to disk 346 await writeFile(binaryPath, Buffer.from(response.data)) 347 await chmod(binaryPath, 0o755) 348 349 // Success - return early 350 return 351 } catch (error) { 352 clearStallTimer() 353 354 // Check if this was a stall timeout (axios wraps abort signals in CanceledError) 355 const isStallTimeout = axios.isCancel(error) 356 357 if (isStallTimeout) { 358 lastError = new StallTimeoutError() 359 } else { 360 lastError = toError(error) 361 } 362 363 // Only retry on stall timeouts 364 if (isStallTimeout && attempt < MAX_DOWNLOAD_RETRIES) { 365 logForDebugging( 366 `Download stalled on attempt ${attempt}/${MAX_DOWNLOAD_RETRIES}, retrying...`, 367 ) 368 // Brief pause before retry to let network recover 369 await sleep(1000) 370 continue 371 } 372 373 // Don't retry other errors (HTTP errors, checksum mismatches, etc.) 374 throw lastError 375 } 376 } 377 378 // Should not reach here, but just in case 379 throw lastError ?? new Error('Download failed after all retries') 380} 381 382export async function downloadVersionFromBinaryRepo( 383 version: string, 384 stagingPath: string, 385 baseUrl: string, 386 authConfig?: { 387 auth?: { username: string; password: string } 388 headers?: Record<string, string> 389 }, 390) { 391 const fs = getFsImplementation() 392 393 // If we get here, we own the lock and can delete a partial download 394 await fs.rm(stagingPath, { recursive: true, force: true }) 395 396 // Get platform 397 const platform = getPlatform() 398 const startTime = Date.now() 399 400 // Log download attempt start 401 logEvent('tengu_binary_download_attempt', {}) 402 403 // Fetch manifest to get checksum 404 let manifest 405 try { 406 const manifestResponse = await axios.get( 407 `${baseUrl}/${version}/manifest.json`, 408 { 409 timeout: 10000, 410 responseType: 'json', 411 ...authConfig, 412 }, 413 ) 414 manifest = manifestResponse.data 415 } catch (error) { 416 const latencyMs = Date.now() - startTime 417 const errorMessage = error instanceof Error ? error.message : String(error) 418 let httpStatus: number | undefined 419 if (axios.isAxiosError(error) && error.response) { 420 httpStatus = error.response.status 421 } 422 423 logEvent('tengu_binary_manifest_fetch_failure', { 424 latency_ms: latencyMs, 425 http_status: httpStatus, 426 is_timeout: errorMessage.includes('timeout'), 427 }) 428 logError( 429 new Error( 430 `Failed to fetch manifest from ${baseUrl}/${version}/manifest.json: ${errorMessage}`, 431 ), 432 ) 433 throw error 434 } 435 436 const platformInfo = manifest.platforms[platform] 437 438 if (!platformInfo) { 439 logEvent('tengu_binary_platform_not_found', {}) 440 throw new Error( 441 `Platform ${platform} not found in manifest for version ${version}`, 442 ) 443 } 444 445 const expectedChecksum = platformInfo.checksum 446 447 // Both GCS and generic bucket use identical layout: ${baseUrl}/${version}/${platform}/${binaryName} 448 const binaryName = getBinaryName(platform) 449 const binaryUrl = `${baseUrl}/${version}/${platform}/${binaryName}` 450 451 // Write to staging 452 await fs.mkdir(stagingPath) 453 const binaryPath = join(stagingPath, binaryName) 454 455 try { 456 await downloadAndVerifyBinary( 457 binaryUrl, 458 expectedChecksum, 459 binaryPath, 460 authConfig || {}, 461 ) 462 const latencyMs = Date.now() - startTime 463 logEvent('tengu_binary_download_success', { 464 latency_ms: latencyMs, 465 }) 466 } catch (error) { 467 const latencyMs = Date.now() - startTime 468 const errorMessage = error instanceof Error ? error.message : String(error) 469 let httpStatus: number | undefined 470 if (axios.isAxiosError(error) && error.response) { 471 httpStatus = error.response.status 472 } 473 474 logEvent('tengu_binary_download_failure', { 475 latency_ms: latencyMs, 476 http_status: httpStatus, 477 is_timeout: errorMessage.includes('timeout'), 478 is_checksum_mismatch: errorMessage.includes('Checksum mismatch'), 479 }) 480 logError( 481 new Error(`Failed to download binary from ${binaryUrl}: ${errorMessage}`), 482 ) 483 throw error 484 } 485} 486 487export async function downloadVersion( 488 version: string, 489 stagingPath: string, 490): Promise<'npm' | 'binary'> { 491 // Test-fixture versions route to the private sentinel bucket. DCE'd in all 492 // shipped builds — the string 'claude-code-ci-sentinel' and the gcloud call 493 // never exist in compiled binaries. Same gcloud-token pattern as 494 // remoteSkillLoader.ts:175-195. 495 if (feature('ALLOW_TEST_VERSIONS') && /^99\.99\./.test(version)) { 496 const { stdout } = await execFileNoThrowWithCwd('gcloud', [ 497 'auth', 498 'print-access-token', 499 ]) 500 await downloadVersionFromBinaryRepo( 501 version, 502 stagingPath, 503 'https://storage.googleapis.com/claude-code-ci-sentinel', 504 { headers: { Authorization: `Bearer ${stdout.trim()}` } }, 505 ) 506 return 'binary' 507 } 508 509 if (process.env.USER_TYPE === 'ant') { 510 // Use Artifactory for ant users 511 await downloadVersionFromArtifactory(version, stagingPath) 512 return 'npm' 513 } 514 515 // Use GCS for external users 516 await downloadVersionFromBinaryRepo(version, stagingPath, GCS_BUCKET_URL) 517 return 'binary' 518} 519 520// Exported for testing 521export { StallTimeoutError, MAX_DOWNLOAD_RETRIES } 522export const STALL_TIMEOUT_MS = DEFAULT_STALL_TIMEOUT_MS 523export const _downloadAndVerifyBinaryForTesting = downloadAndVerifyBinary