diff --git a/src/utils/autoUpdater.publicSource.test.ts b/src/utils/autoUpdater.publicSource.test.ts new file mode 100644 index 0000000..96165aa --- /dev/null +++ b/src/utils/autoUpdater.publicSource.test.ts @@ -0,0 +1,65 @@ +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { getLatestVersionFromGcs } from './autoUpdater.js' + +const originalAxiosGet = axios.get +const originalMacro = (globalThis as { MACRO?: unknown }).MACRO +const originalNativePackageUrl = process.env.NCODE_NATIVE_PACKAGE_URL + +function setMacro(nativePackageUrl?: string): void { + ;(globalThis as { MACRO?: { NATIVE_PACKAGE_URL?: string } }).MACRO = { + NATIVE_PACKAGE_URL: nativePackageUrl, + } +} + +beforeEach(() => { + axios.get = originalAxiosGet + setMacro(undefined) + delete process.env.NCODE_NATIVE_PACKAGE_URL +}) + +afterEach(() => { + axios.get = originalAxiosGet + ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro + if (originalNativePackageUrl === undefined) { + delete process.env.NCODE_NATIVE_PACKAGE_URL + } else { + process.env.NCODE_NATIVE_PACKAGE_URL = originalNativePackageUrl + } +}) + +describe('package-manager updater public source', () => { + it('uses GitHub releases when no binary repo override is configured', async () => { + const calls: Array = [] + axios.get = (async (...args: unknown[]) => { + calls.push(args) + return { + data: [ + { draft: false, prerelease: true, tag_name: 'v2.0.0-beta.1' }, + { draft: false, prerelease: false, tag_name: 'v1.9.0' }, + ], + } + }) as typeof axios.get + + await expect(getLatestVersionFromGcs('latest')).resolves.toBe('2.0.0-beta.1') + await expect(getLatestVersionFromGcs('stable')).resolves.toBe('1.9.0') + + expect(calls[0]?.[0]).toBe( + 'https://api.github.com/repos/Noumena-Network/code/releases', + ) + }) + + it('uses NCODE_NATIVE_PACKAGE_URL as the explicit binary-repo override', async () => { + process.env.NCODE_NATIVE_PACKAGE_URL = 'https://storage.example.test/ncode' + const calls: Array = [] + axios.get = (async (...args: unknown[]) => { + calls.push(args) + return { data: '1.8.0\n' } + }) as typeof axios.get + + await expect(getLatestVersionFromGcs('latest')).resolves.toBe('1.8.0') + + expect(calls).toHaveLength(1) + expect(calls[0]?.[0]).toBe('https://storage.example.test/ncode/latest') + }) +}) diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index 38c58d8..590a190 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -28,8 +28,17 @@ import { import { jsonParse } from './slowOperations.js' import { isInternalBuild } from 'src/capabilities/static.js' -const GCS_BUCKET_URL = - 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases' +const GITHUB_RELEASES_API_URL = + process.env.NCODE_GITHUB_RELEASES_API_URL ?? + 'https://api.github.com/repos/Noumena-Network/code/releases' + +function getPublicBinaryUrl(): string | undefined { + return MACRO.NATIVE_PACKAGE_URL || process.env.NCODE_NATIVE_PACKAGE_URL +} + +function githubReleaseVersionFromTag(tag: string): string { + return tag.startsWith('v') ? tag.slice(1) : tag +} class AutoUpdaterError extends ClaudeError {} @@ -385,14 +394,42 @@ export async function getNpmDistTags(): Promise { export async function getLatestVersionFromGcs( channel: ReleaseChannel, ): Promise { + const publicBinaryUrl = getPublicBinaryUrl() + if (publicBinaryUrl) { + try { + const response = await axios.get(`${publicBinaryUrl}/${channel}`, { + timeout: 5000, + responseType: 'text', + }) + return response.data.trim() + } catch (error) { + logForDebugging(`Failed to fetch ${channel} from binary repo: ${error}`) + return null + } + } + try { - const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, { + const response = await axios.get(GITHUB_RELEASES_API_URL, { timeout: 5000, - responseType: 'text', + responseType: 'json', + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + params: { per_page: 25 }, + }) + const release = (response.data as Array<{ + draft?: boolean + prerelease?: boolean + tag_name?: string + }>).find(candidate => { + if (candidate.draft) return false + if (channel === 'stable' && candidate.prerelease) return false + return Boolean(candidate.tag_name) }) - return response.data.trim() + return release?.tag_name ? githubReleaseVersionFromTag(release.tag_name) : null } catch (error) { - logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`) + logForDebugging(`Failed to fetch ${channel} from GitHub releases: ${error}`) return null } } diff --git a/src/utils/nativeInstaller/download.test.ts b/src/utils/nativeInstaller/download.test.ts new file mode 100644 index 0000000..197c7fa --- /dev/null +++ b/src/utils/nativeInstaller/download.test.ts @@ -0,0 +1,176 @@ +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { createHash } from 'crypto' +import { mkdtemp, readFile, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { zipSync } from 'fflate' +import { + downloadVersionFromGithubRelease, + getLatestVersion, + getLatestVersionFromGithubReleases, +} from './download.js' +import { getBinaryName, getPlatform } from './installer.js' + +const originalAxiosGet = axios.get +const originalMacro = (globalThis as { MACRO?: unknown }).MACRO +const originalNativePackageUrl = process.env.NCODE_NATIVE_PACKAGE_URL + +function setMacro(nativePackageUrl?: string): void { + ;(globalThis as { MACRO?: { NATIVE_PACKAGE_URL?: string } }).MACRO = { + NATIVE_PACKAGE_URL: nativePackageUrl, + } +} + +beforeEach(() => { + axios.get = originalAxiosGet + setMacro(undefined) + delete process.env.NCODE_NATIVE_PACKAGE_URL +}) + +afterEach(() => { + axios.get = originalAxiosGet + ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro + if (originalNativePackageUrl === undefined) { + delete process.env.NCODE_NATIVE_PACKAGE_URL + } else { + process.env.NCODE_NATIVE_PACKAGE_URL = originalNativePackageUrl + } +}) + +describe('native installer public update sources', () => { + it('uses GitHub releases as the default latest/stable version source', async () => { + const getCalls: Array = [] + axios.get = (async (...args: unknown[]) => { + getCalls.push(args) + return { + data: [ + { draft: true, prerelease: false, tag_name: 'v9.9.9' }, + { draft: false, prerelease: true, tag_name: 'v1.3.0-beta.1' }, + { draft: false, prerelease: false, tag_name: 'v1.2.3' }, + ], + } + }) as typeof axios.get + + await expect(getLatestVersionFromGithubReleases('latest')).resolves.toBe( + '1.3.0-beta.1', + ) + await expect(getLatestVersionFromGithubReleases('stable')).resolves.toBe( + '1.2.3', + ) + + expect(getCalls).toHaveLength(2) + expect(getCalls[0]?.[0]).toBe( + 'https://api.github.com/repos/Noumena-Network/code/releases', + ) + expect(getCalls[0]?.[1]).toMatchObject({ + responseType: 'json', + params: { per_page: 25 }, + }) + }) + + it('uses NCODE_NATIVE_PACKAGE_URL as an explicit binary-repo override', async () => { + process.env.NCODE_NATIVE_PACKAGE_URL = 'https://storage.example.test/ncode' + const getCalls: Array = [] + axios.get = (async (...args: unknown[]) => { + getCalls.push(args) + return { data: '1.2.4\n' } + }) as typeof axios.get + + await expect(getLatestVersion('latest')).resolves.toBe('1.2.4') + + expect(getCalls).toHaveLength(1) + expect(getCalls[0]?.[0]).toBe('https://storage.example.test/ncode/latest') + }) + + it('downloads, verifies, and extracts a GitHub release zip asset', async () => { + const version = '1.2.3' + const platform = getPlatform() + const binaryName = getBinaryName(platform) + const artifactBaseName = `ncode-${version}-${platform}` + const zipAssetName = `${artifactBaseName}.zip` + const binaryBytes = new TextEncoder().encode('binary-ok') + const zipBytes = Buffer.from( + zipSync({ [`${artifactBaseName}/${binaryName}`]: binaryBytes }), + ) + const checksum = createHash('sha256').update(zipBytes).digest('hex') + const getCalls: Array = [] + axios.get = (async (url: string) => { + getCalls.push(url) + if (url.endsWith('/tags/v1.2.3')) { + return { + data: { + tag_name: 'v1.2.3', + assets: [ + { + name: zipAssetName, + browser_download_url: 'https://github.example.test/asset.zip', + }, + { + name: `${zipAssetName}.sha256`, + browser_download_url: 'https://github.example.test/asset.zip.sha256', + }, + ], + }, + } + } + if (url === 'https://github.example.test/asset.zip.sha256') { + return { data: `${checksum} ${zipAssetName}\n` } + } + if (url === 'https://github.example.test/asset.zip') { + return { data: zipBytes } + } + throw new Error(`unexpected url ${url}`) + }) as typeof axios.get + + const stagingPath = await mkdtemp(join(tmpdir(), 'ncode-gh-release-')) + await rm(stagingPath, { recursive: true, force: true }) + try { + await downloadVersionFromGithubRelease(version, stagingPath) + await expect(readFile(join(stagingPath, binaryName), 'utf8')).resolves.toBe( + 'binary-ok', + ) + } finally { + await rm(stagingPath, { recursive: true, force: true }) + } + + expect(getCalls).toEqual([ + 'https://api.github.com/repos/Noumena-Network/code/releases/tags/v1.2.3', + 'https://github.example.test/asset.zip.sha256', + 'https://github.example.test/asset.zip', + ]) + }) + + it('rejects GitHub release zip checksum mismatches', async () => { + const version = '1.2.3' + const platform = getPlatform() + const zipAssetName = `ncode-${version}-${platform}.zip` + const zipBytes = Buffer.from(zipSync({ 'ncode': new TextEncoder().encode('bad') })) + axios.get = (async (url: string) => { + if (url.endsWith('/tags/v1.2.3')) { + return { + data: { + tag_name: 'v1.2.3', + assets: [ + { name: zipAssetName, browser_download_url: 'https://github.example.test/asset.zip' }, + { name: `${zipAssetName}.sha256`, browser_download_url: 'https://github.example.test/asset.zip.sha256' }, + ], + }, + } + } + if (url.endsWith('.sha256')) return { data: `deadbeef ${zipAssetName}\n` } + if (url.endsWith('.zip')) return { data: zipBytes } + throw new Error(`unexpected url ${url}`) + }) as typeof axios.get + + const stagingPath = await mkdtemp(join(tmpdir(), 'ncode-gh-release-bad-')) + await rm(stagingPath, { recursive: true, force: true }) + try { + await expect( + downloadVersionFromGithubRelease(version, stagingPath), + ).rejects.toThrow('Checksum mismatch') + } finally { + await rm(stagingPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/utils/nativeInstaller/download.ts b/src/utils/nativeInstaller/download.ts index e1447c8..c7a63df 100644 --- a/src/utils/nativeInstaller/download.ts +++ b/src/utils/nativeInstaller/download.ts @@ -2,79 +2,131 @@ * Download functionality for native installer * * Handles downloading NCode binaries from various sources: - * - Artifactory NPM packages - * - GCS bucket + * - public or internal binary repositories */ import { feature } from 'bun:bundle' import axios from 'axios' import { createHash } from 'crypto' +import { unzipSync } from 'fflate' import { chmod, writeFile } from 'fs/promises' import { join } from 'path' import { logEvent } from 'src/services/analytics/index.js' import type { ReleaseChannel } from '../config.js' import { logForDebugging } from '../debug.js' import { toError } from '../errors.js' -import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' import { getFsImplementation } from '../fsOperations.js' import { logError } from '../log.js' import { sleep } from '../sleep.js' -import { jsonStringify, writeFileSync_DEPRECATED } from '../slowOperations.js' import { getBinaryName, getPlatform } from './installer.js' -import { isInternalBuild } from 'src/capabilities/static.js' -// Anthropic-owned bucket removed for public launch. External builds must set -// NCODE_NATIVE_PACKAGE_URL to a Noumena-owned public artifact URL. +// Builds may bake this value into MACRO.NATIVE_PACKAGE_URL. Deployments can +// override it with NCODE_NATIVE_PACKAGE_URL, including internal-only buckets. function getPublicBinaryUrl(): string | undefined { return MACRO.NATIVE_PACKAGE_URL || process.env.NCODE_NATIVE_PACKAGE_URL } -export const ARTIFACTORY_REGISTRY_URL = - 'https://artifactory.infra.ant.dev/artifactory/api/npm/npm-all/' -export async function getLatestVersionFromArtifactory( - tag: string = 'latest', -): Promise { - const startTime = Date.now() - const { stdout, code, stderr } = await execFileNoThrowWithCwd( - 'npm', - [ - 'view', - `${MACRO.NATIVE_PACKAGE_URL}@${tag}`, - 'version', - '--prefer-online', - '--registry', - ARTIFACTORY_REGISTRY_URL, - ], +export const GITHUB_RELEASES_API_URL = + process.env.NCODE_GITHUB_RELEASES_API_URL ?? + 'https://api.github.com/repos/Noumena-Network/code/releases' + +type GithubReleaseAsset = { + browser_download_url?: string + name?: string +} + +type GithubRelease = { + assets?: GithubReleaseAsset[] + draft?: boolean + prerelease?: boolean + tag_name?: string +} + +function normalizeVersionTag(version: string): string { + return version.startsWith('v') ? version : `v${version}` +} + +function releaseVersionFromTag(tag: string): string { + return tag.startsWith('v') ? tag.slice(1) : tag +} + +function getGithubReleaseApiHeaders(): Record { + return { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } +} + +function findGithubAsset( + release: GithubRelease, + assetName: string, +): GithubReleaseAsset { + const asset = release.assets?.find(candidate => candidate.name === assetName) + if (!asset?.browser_download_url) { + throw new Error( + `GitHub release ${release.tag_name ?? 'unknown'} is missing asset ${assetName}`, + ) + } + return asset +} + +async function getGithubReleaseByTag(version: string): Promise { + const tag = normalizeVersionTag(version) + const response = await axios.get( + `${GITHUB_RELEASES_API_URL}/tags/${encodeURIComponent(tag)}`, { timeout: 30000, - preserveOutputOnError: true, + responseType: 'json', + headers: getGithubReleaseApiHeaders(), }, ) + return response.data +} - const latencyMs = Date.now() - startTime - - if (code !== 0) { +export async function getLatestVersionFromGithubReleases( + channel: ReleaseChannel = 'latest', +): Promise { + const startTime = Date.now() + try { + const response = await axios.get(GITHUB_RELEASES_API_URL, { + timeout: 30000, + responseType: 'json', + headers: getGithubReleaseApiHeaders(), + params: { per_page: 25 }, + }) + const release = response.data.find(candidate => { + if (candidate.draft) return false + if (channel === 'stable' && candidate.prerelease) return false + return Boolean(candidate.tag_name) + }) + if (!release?.tag_name) { + throw new Error(`No ${channel} GitHub release is available`) + } + logEvent('ncode_version_check_success', { + latency_ms: Date.now() - startTime, + source_github_releases: true, + }) + return releaseVersionFromTag(release.tag_name) + } catch (error) { + const latencyMs = Date.now() - startTime + const errorMessage = error instanceof Error ? error.message : String(error) + let httpStatus: number | undefined + if (axios.isAxiosError(error) && error.response) { + httpStatus = error.response.status + } logEvent('ncode_version_check_failure', { latency_ms: latencyMs, - source_npm: true, - exit_code: code, + http_status: httpStatus, + is_timeout: errorMessage.includes('timeout'), + source_github_releases: true, }) - const error = new Error(`npm view failed with code ${code}: ${stderr}`) - logError(error) - throw error + const fetchError = new Error( + `Failed to fetch ${channel} version from GitHub releases: ${errorMessage}`, + ) + logError(fetchError) + throw fetchError } - - logEvent('ncode_version_check_success', { - latency_ms: latencyMs, - source_npm: true, - }) - logForDebugging( - `npm view ${MACRO.NATIVE_PACKAGE_URL}@${tag} version: ${stdout}`, - ) - const latestVersion = stdout.trim() - return latestVersion } - export async function getLatestVersionFromBinaryRepo( channel: ReleaseChannel = 'latest', baseUrl: string, @@ -141,142 +193,12 @@ export async function getLatestVersion( ) } - // Route to appropriate source - if (isInternalBuild()) { - // Use Artifactory for internal users - const npmTag = channel === 'stable' ? 'stable' : 'latest' - return getLatestVersionFromArtifactory(npmTag) - } - - const publicBinaryUrl = getPublicBinaryUrl() - if (!publicBinaryUrl) { - throw new Error( - 'No public binary distribution URL is configured for this build. Set NCODE_NATIVE_PACKAGE_URL to check for updates.', - ) - } - - // Use public binary repo for external users - return getLatestVersionFromBinaryRepo(channel, publicBinaryUrl) -} - -export async function downloadVersionFromArtifactory( - version: string, - stagingPath: string, -) { - const fs = getFsImplementation() - - // If we get here, we own the lock and can delete a partial download - await fs.rm(stagingPath, { recursive: true, force: true }) - - // Get the platform-specific package name - const platform = getPlatform() - const platformPackageName = `${MACRO.NATIVE_PACKAGE_URL}-${platform}` - - // Fetch integrity hash for the platform-specific package - logForDebugging( - `Fetching integrity hash for ${platformPackageName}@${version}`, - ) - const { - stdout: integrityOutput, - code, - stderr, - } = await execFileNoThrowWithCwd( - 'npm', - [ - 'view', - `${platformPackageName}@${version}`, - 'dist.integrity', - '--registry', - ARTIFACTORY_REGISTRY_URL, - ], - { - timeout: 30000, - preserveOutputOnError: true, - }, - ) - - if (code !== 0) { - throw new Error(`npm view integrity failed with code ${code}: ${stderr}`) - } - - const integrity = integrityOutput.trim() - if (!integrity) { - throw new Error( - `Failed to fetch integrity hash for ${platformPackageName}@${version}`, - ) - } - - logForDebugging(`Got integrity hash for ${platform}: ${integrity}`) - - // Create isolated npm project in staging - await fs.mkdir(stagingPath) - - const packageJson = { - name: 'ncode-native-installer', - version: '0.0.1', - dependencies: { - [MACRO.NATIVE_PACKAGE_URL!]: version, - }, - } - - // Create package-lock.json with integrity verification for platform-specific package - const packageLock = { - name: 'ncode-native-installer', - version: '0.0.1', - lockfileVersion: 3, - requires: true, - packages: { - '': { - name: 'ncode-native-installer', - version: '0.0.1', - dependencies: { - [MACRO.NATIVE_PACKAGE_URL!]: version, - }, - }, - [`node_modules/${MACRO.NATIVE_PACKAGE_URL}`]: { - version: version, - optionalDependencies: { - [platformPackageName]: version, - }, - }, - [`node_modules/${platformPackageName}`]: { - version: version, - integrity: integrity, - }, - }, + const binaryRepoUrl = getPublicBinaryUrl() + if (binaryRepoUrl) { + return getLatestVersionFromBinaryRepo(channel, binaryRepoUrl) } - writeFileSync_DEPRECATED( - join(stagingPath, 'package.json'), - jsonStringify(packageJson, null, 2), - { encoding: 'utf8', flush: true }, - ) - - writeFileSync_DEPRECATED( - join(stagingPath, 'package-lock.json'), - jsonStringify(packageLock, null, 2), - { encoding: 'utf8', flush: true }, - ) - - // Install with npm - it will verify integrity from package-lock.json - // Use --prefer-online to force fresh metadata checks, helping with Artifactory replication delays - const result = await execFileNoThrowWithCwd( - 'npm', - ['ci', '--prefer-online', '--registry', ARTIFACTORY_REGISTRY_URL], - { - timeout: 60000, - preserveOutputOnError: true, - cwd: stagingPath, - }, - ) - - if (result.code !== 0) { - throw new Error(`npm ci failed with code ${result.code}: ${result.stderr}`) - } - - logForDebugging( - `Successfully downloaded and verified ${MACRO.NATIVE_PACKAGE_URL}@${version}`, - ) + return getLatestVersionFromGithubReleases(channel) } // Stall timeout: abort if no bytes received for this duration @@ -390,6 +312,66 @@ async function downloadAndVerifyBinary( throw lastError ?? new Error('Download failed after all retries') } +export async function downloadVersionFromGithubRelease( + version: string, + stagingPath: string, +) { + const fs = getFsImplementation() + + await fs.rm(stagingPath, { recursive: true, force: true }) + + const platform = getPlatform() + const binaryName = getBinaryName(platform) + const artifactBaseName = `ncode-${version}-${platform}` + const zipAssetName = `${artifactBaseName}.zip` + const checksumAssetName = `${zipAssetName}.sha256` + const startTime = Date.now() + + logEvent('ncode_binary_download_attempt', { source_github_releases: true }) + + const release = await getGithubReleaseByTag(version) + const zipAsset = findGithubAsset(release, zipAssetName) + const checksumAsset = findGithubAsset(release, checksumAssetName) + + const checksumResponse = await axios.get(checksumAsset.browser_download_url!, { + timeout: 30000, + responseType: 'text', + }) + const expectedChecksum = String(checksumResponse.data).trim().split(/\s+/)[0] + if (!expectedChecksum) { + throw new Error(`GitHub release asset ${checksumAssetName} did not contain a checksum`) + } + + const zipResponse = await axios.get(zipAsset.browser_download_url!, { + timeout: 5 * 60000, + responseType: 'arraybuffer', + }) + const zipBuffer = Buffer.from(zipResponse.data) + const actualChecksum = createHash('sha256').update(zipBuffer).digest('hex') + if (actualChecksum !== expectedChecksum) { + throw new Error( + `Checksum mismatch for ${zipAssetName}: expected ${expectedChecksum}, got ${actualChecksum}`, + ) + } + + const entries = unzipSync(new Uint8Array(zipBuffer)) + const binaryEntry = + entries[`${artifactBaseName}/${binaryName}`] ?? entries[binaryName] + if (!binaryEntry) { + throw new Error(`GitHub release asset ${zipAssetName} did not contain ${binaryName}`) + } + + await fs.mkdir(stagingPath) + const binaryPath = join(stagingPath, binaryName) + await writeFile(binaryPath, Buffer.from(binaryEntry)) + await chmod(binaryPath, 0o755) + + logEvent('ncode_binary_download_success', { + latency_ms: Date.now() - startTime, + source_github_releases: true, + }) +} + export async function downloadVersionFromBinaryRepo( version: string, stagingPath: string, @@ -517,21 +499,13 @@ export async function downloadVersion( return 'binary' } - if (isInternalBuild()) { - // Use Artifactory for internal users - await downloadVersionFromArtifactory(version, stagingPath) - return 'npm' - } - - const publicBinaryUrl = getPublicBinaryUrl() - if (!publicBinaryUrl) { - throw new Error( - 'No public binary distribution URL is configured for this build. Set NCODE_NATIVE_PACKAGE_URL to update.', - ) + const binaryRepoUrl = getPublicBinaryUrl() + if (binaryRepoUrl) { + await downloadVersionFromBinaryRepo(version, stagingPath, binaryRepoUrl) + return 'binary' } - // Use public binary repo for external users - await downloadVersionFromBinaryRepo(version, stagingPath, publicBinaryUrl) + await downloadVersionFromGithubRelease(version, stagingPath) return 'binary' }