/ utils / nativeInstaller / download.ts
download.ts
  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  
  9  import { feature } from 'bun:bundle'
 10  import axios from 'axios'
 11  import { createHash } from 'crypto'
 12  import { chmod, writeFile } from 'fs/promises'
 13  import { join } from 'path'
 14  import { logEvent } from 'src/services/analytics/index.js'
 15  import type { ReleaseChannel } from '../config.js'
 16  import { logForDebugging } from '../debug.js'
 17  import { toError } from '../errors.js'
 18  import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
 19  import { getFsImplementation } from '../fsOperations.js'
 20  import { logError } from '../log.js'
 21  import { sleep } from '../sleep.js'
 22  import { jsonStringify, writeFileSync_DEPRECATED } from '../slowOperations.js'
 23  import { getBinaryName, getPlatform } from './installer.js'
 24  
 25  const GCS_BUCKET_URL =
 26    'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'
 27  export const ARTIFACTORY_REGISTRY_URL =
 28    'https://artifactory.infra.ant.dev/artifactory/api/npm/npm-all/'
 29  
 30  export 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  
 74  export 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  
112  export 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  
151  export 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
272  const DEFAULT_STALL_TIMEOUT_MS = 60000 // 60 seconds
273  const MAX_DOWNLOAD_RETRIES = 3
274  
275  function getStallTimeoutMs(): number {
276    return (
277      Number(process.env.CLAUDE_CODE_STALL_TIMEOUT_MS_FOR_TESTING) ||
278      DEFAULT_STALL_TIMEOUT_MS
279    )
280  }
281  
282  class 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   */
293  async 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  
382  export 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  
487  export 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
521  export { StallTimeoutError, MAX_DOWNLOAD_RETRIES }
522  export const STALL_TIMEOUT_MS = DEFAULT_STALL_TIMEOUT_MS
523  export const _downloadAndVerifyBinaryForTesting = downloadAndVerifyBinary